From 35c10135208da02cb0ff8b7341873144824b6ab9 Mon Sep 17 00:00:00 2001 From: spacesops Date: Tue, 26 May 2026 10:00:08 -0400 Subject: [PATCH] Build SpacesOps StartOS package (v1.0.0:0) Initial .s9pk for SpacesOps, targeting StartOS 0.4.0.x with SDK 1.5.1. - Single managed daemon from spacesops/spacesops:v1.0.0 (x86_64 + aarch64), keeping the image entrypoint (/app/docker-entrypoint.sh node server.js). Forces PLATFORM_HOST=0.0.0.0 / PLATFORM_PORT=7264 so the StartOS proxy can reach the app. Single `ui` interface on 7264. - Depends on the Spaces service (>=0.0.9:3) and auto-wires the spaced RPC creds: main.ts mounts the Spaces `main` volume read-only at /spaces-data, execs a read of its store.json inside the subcontainer, and injects SPACED_RPC_USER/ PASSWORD + SPACED_RPC_URL=http://spaces.startos:7225. Throws to retry until Spaces is installed and seeded. - Generates a Nostr operator keypair (nostr-tools, bundled by ncc) and a strong session secret in idempotent init tasks (.once() reads, allowWriteAfterConst merges). Actions: show-operator-credentials, import-operator-key, show-admin-credentials (surfaces the fixed admin/Whatever! login with a warning), configure-platform (optional relay/mode/CoinGecko/SUBSD). - Install alert warns to install Spaces first and about the fixed admin credential. Backs up the `main` volume. - Icon: icon.svg (source spacesops.svg). The `spaces` dependency uses assets/spaces-icon.png for its Marketplace metadata. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 9 + CONTRIBUTING.md | 58 +++ LICENSE | 21 + Makefile | 4 + README.md | 207 +++++++++ assets/README.md | 8 + assets/spaces-icon.png | Bin 0 -> 53043 bytes icon.svg | 53 +++ instructions.md | 53 +++ package-lock.json | 514 +++++++++++++++++++++ package.json | 24 + s9pk.mk | 141 ++++++ spacesops.svg | 53 +++ startos/actions/configurePlatform.ts | 163 +++++++ startos/actions/importOperatorKey.ts | 101 ++++ startos/actions/index.ts | 11 + startos/actions/showAdminCredentials.ts | 52 +++ startos/actions/showOperatorCredentials.ts | 88 ++++ startos/backups.ts | 5 + startos/dependencies.ts | 14 + startos/fileModels/storeJson.ts | 24 + startos/i18n/dictionaries/default.ts | 60 +++ startos/i18n/dictionaries/translations.ts | 3 + startos/i18n/index.ts | 8 + startos/index.ts | 11 + startos/init/index.ts | 20 + startos/init/taskOperatorKeys.ts | 24 + startos/init/taskSessionSecret.ts | 16 + startos/interfaces.ts | 27 ++ startos/main.ts | 131 ++++++ startos/manifest/i18n.ts | 13 + startos/manifest/index.ts | 42 ++ startos/nostr.ts | 25 + startos/sdk.ts | 9 + startos/utils.ts | 44 ++ startos/versions/index.ts | 7 + startos/versions/v1.0.0.0.ts | 17 + tsconfig.json | 11 + 38 files changed, 2071 insertions(+) create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 assets/README.md create mode 100644 assets/spaces-icon.png create mode 100644 icon.svg create mode 100644 instructions.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 s9pk.mk create mode 100644 spacesops.svg create mode 100644 startos/actions/configurePlatform.ts create mode 100644 startos/actions/importOperatorKey.ts create mode 100644 startos/actions/index.ts create mode 100644 startos/actions/showAdminCredentials.ts create mode 100644 startos/actions/showOperatorCredentials.ts create mode 100644 startos/backups.ts create mode 100644 startos/dependencies.ts create mode 100644 startos/fileModels/storeJson.ts create mode 100644 startos/i18n/dictionaries/default.ts create mode 100644 startos/i18n/dictionaries/translations.ts create mode 100644 startos/i18n/index.ts create mode 100644 startos/index.ts create mode 100644 startos/init/index.ts create mode 100644 startos/init/taskOperatorKeys.ts create mode 100644 startos/init/taskSessionSecret.ts create mode 100644 startos/interfaces.ts create mode 100644 startos/main.ts create mode 100644 startos/manifest/i18n.ts create mode 100644 startos/manifest/index.ts create mode 100644 startos/nostr.ts create mode 100644 startos/sdk.ts create mode 100644 startos/utils.ts create mode 100644 startos/versions/index.ts create mode 100644 startos/versions/v1.0.0.0.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e7c93f2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +*.s9pk +startos/*.js +node_modules/ +.DS_Store +.vscode/ +docker-images +javascript +ncc-cache +NOTES.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9c89f49 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,58 @@ +# Contributing + +This repo packages [SpacesOps](https://git.spacesops.com/spacesops/spacesops) +for StartOS. + +## Documentation — keep it in sync + +- **`README.md`** — what this package is and how it's built (image, volume, + interface, dependency, actions). For developers and AI assistants. +- **`instructions.md`** — the user-facing instructions packed into the `.s9pk` + and shown on the **Instructions** tab in StartOS. +- **`CONTRIBUTING.md`** — this file. + +**Any code change that warrants it must update `README.md` and `instructions.md` +in the same change** — a new or renamed action, an added or removed volume / +port / interface / dependency, a changed default, a new limitation, any altered +user-visible behavior. A package that ships with a stale README or stale +instructions is not done, even if the code is perfect. Content rules: +[Writing READMEs](https://docs.start9.com/packaging/writing-readmes.html). + +## Building + +See the [StartOS Packaging Guide](https://docs.start9.com/packaging/) for +environment setup, then: + +```bash +npm ci # install dependencies +make # build the .s9pk for x86_64 and aarch64 +``` + +`npm run check` runs the TypeScript type-check; `npm run build` bundles +`startos/` into `javascript/index.js` with `@vercel/ncc` (which inlines +`nostr-tools`, used to derive and encode the operator keypair). + +## Updating the upstream version + +SpacesOps runs the `docker.io/spacesops/spacesops` image. To track a new +upstream release: + +1. Bump `dockerTag` in `startos/manifest/index.ts` to + `spacesops/spacesops:`. +2. Update `version` and `releaseNotes` in the file under `startos/versions/`, + renaming it to the new version string. A *new* version file is only needed + when the bump carries an `up`/`down` migration, or when you want the old + release notes preserved in git history — see + [Versions](https://docs.start9.com/packaging/versions.html). +3. Rebuild (`make`), sideload the `.s9pk`, and confirm it starts and connects to + Spaces. +4. Review `README.md` and `instructions.md` for anything the bump changed. + +## How auto-wiring to Spaces works + +`startos/main.ts` mounts the Spaces `main` volume read-only at `/spaces-data`, +execs `cat /spaces-data/store.json` inside the subcontainer, and reads +`spacedAuth.{username,password}` to populate `SPACED_RPC_USER` / +`SPACED_RPC_PASSWORD`. If the file or credentials are missing, it throws so +StartOS restarts the service until Spaces is ready. The Spaces dependency must +expose its `spaces-api` interface and seed `spacedAuth` (Spaces `>=0.0.9:3`). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..25aa3b0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 SpacesOps + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..171a1ea --- /dev/null +++ b/Makefile @@ -0,0 +1,4 @@ +# spacesops ships linux/amd64 + linux/arm64 only (no riscv), so build both. +ARCHES := x86 arm +# overrides to s9pk.mk must precede the include statement +include s9pk.mk diff --git a/README.md b/README.md index e69de29..48e9042 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,207 @@ +

+ SpacesOps +

+ +# SpacesOps on StartOS + +> **Upstream repo:** +> +> Everything not listed in this document should behave the same as upstream +> SpacesOps. If a feature, setting, or behavior is not mentioned here, the +> upstream documentation is accurate and fully applicable. + +SpacesOps is a web platform for confirming [Spaces](https://spacesprotocol.org) +ownership and selling subspace names. It talks to the Spaces `spaced` daemon +over JSON-RPC, signs operator events on Nostr, and optionally integrates pricing +and subname-purchase backends. On StartOS it **requires the Spaces service** and +auto-connects to it — see [Installation and First-Run Flow](#installation-and-first-run-flow). + +--- + +## Table of Contents + +- [Image and Container Runtime](#image-and-container-runtime) +- [Volume and Data Layout](#volume-and-data-layout) +- [Installation and First-Run Flow](#installation-and-first-run-flow) +- [The Spaces Dependency (auto-wiring)](#the-spaces-dependency-auto-wiring) +- [Configuration Management](#configuration-management) +- [Network Access and Interfaces](#network-access-and-interfaces) +- [Actions](#actions) +- [Backups and Restore](#backups-and-restore) +- [Health Checks](#health-checks) +- [Limitations and Differences](#limitations-and-differences) +- [What Is Unchanged from Upstream](#what-is-unchanged-from-upstream) +- [Quick Reference for AI Consumers](#quick-reference-for-ai-consumers) + +--- + +## Image and Container Runtime + +| Field | Value | +| --- | --- | +| Image | `docker.io/spacesops/spacesops:v1.0.0` | +| Base | `node:20-alpine` | +| Architectures | `x86_64` (linux/amd64), `aarch64` (linux/arm64) | +| Entrypoint | The image's `/app/docker-entrypoint.sh` is **kept**. StartOS runs `/app/docker-entrypoint.sh node server.js` as the daemon. | +| Runs as | `root`, working directory `/app` | + +The image bundles Node 20 and the app's `node_modules` (including `nostr-tools` +and `bitcoinjs-lib`). The entrypoint creates `/data{,/spaces,/signed}`, symlinks +`/app/data` → `/data`, and loads defaults from `/app/setup-spacesops-env.sh` for +any environment variable StartOS leaves unset, then exec's `node server.js`. + +## Volume and Data Layout + +| Path | Volume | Purpose | +| --- | --- | --- | +| `/data` | `main` | SpacesOps data directory. SQLite databases (`local.db`, `sessions.db`, `prices.db`, per-tenant DBs), signed artifacts, and StartOS `store.json`. | +| `/data/local.db` | `main` | Primary SQLite database (`PLATFORM_DB_PATH`). DB migrations auto-run on startup. | +| `/data/store.json` | `main` | StartOS-managed settings: the generated Nostr operator keypair, the generated session secret, and optional Configure-Platform values. | +| `/spaces-data` | _Spaces `main`_ | The **Spaces** service's volume, mounted **read-only**. Used only at startup to read the spaced RPC credentials — see below. | + +## Installation and First-Run Flow + +On first install, StartOS: + +1. Generates a **Nostr operator keypair** (`operatorSecretHex` / + `operatorPublicHex` in `store.json`). SpacesOps requires this keypair at + startup; the **Show Operator Credentials** action surfaces it (npub / nsec / + hex), and **Import Operator Key** replaces it with one you supply. +2. Generates a strong **session secret** (`sessionSecret`), replacing the weak + hardcoded default the upstream image ships with. +3. Reads the **spaced RPC credentials from the Spaces service** and connects to + it (see next section). The service will not finish starting until those + credentials are available. + +There is no upstream setup wizard to complete — the operator keypair and session +secret are provisioned automatically. + +## The Spaces Dependency (auto-wiring) + +SpacesOps **requires the Spaces service** (`spaces`, version `>=0.0.9:3`). +**Install and sync Spaces first.** SpacesOps does not ask you for spaced RPC +credentials. Instead, at startup it: + +1. Mounts the Spaces `main` volume read-only at `/spaces-data`. +2. Reads `spacedAuth.{username,password}` from the Spaces `store.json`. +3. Connects to spaced at `http://spaces.startos:7225` with those credentials, + exporting `SPACED_RPC_URL`, `SPACED_RPC_USER`, `SPACED_RPC_PASSWORD`, and + `SPACED_WALLETLOAD_NAME=main`. + +If Spaces is not installed yet, or its credentials have not been seeded, the +service **restarts until they appear** — this is expected. (Dependency +declarations do not gate startup; SpacesOps handles readiness itself.) + +## Configuration Management + +| StartOS-Managed | Upstream-Managed | +| --- | --- | +| `PLATFORM_HOST` / `PLATFORM_PORT` (forced to `0.0.0.0:7264` so the proxy can reach the app) | Everything inside the SpacesOps web UI / admin area | +| `PLATFORM_DB_PATH` (`/data/local.db`) | Tenant management, space-ownership flows | +| `OPERATOR_SECRET_HEX` / `OPERATOR_PUBLIC_HEX` (generated or imported) | | +| `PLATFORM_SESSION_SECRET` (generated) | | +| `SPACED_RPC_*` (auto-wired from Spaces) | | +| Optional: `OPERATOR_RELAY`, `PLATFORM_MODE`, `COINGECKO_*`, `SUBSD_*` (via **Configure Platform**) | | + +## Network Access and Interfaces + +| Interface | Port | Protocol | Exposure | Notes | +| --- | --- | --- | --- | --- | +| Web UI | 7264 | HTTP | LAN / `.local` / Tor / clearnet (via StartOS) | The SpacesOps web platform + admin area. The app provides its own auth; the admin area uses a **fixed built-in credential** (see warning below). | + +## Actions + +| ID | Name | Visibility | Availability | Inputs | Purpose | +| --- | --- | --- | --- | --- | --- | +| `show-operator-credentials` | Show Operator Credentials | Enabled | Any | — | Displays the Nostr operator keypair: npub (QR), hex public key, and the masked nsec / hex secret. | +| `import-operator-key` | Import Operator Key | Enabled | Any | 64-char hex secret key | Replaces the operator keypair. Derives the public key automatically. **Warns** that it changes the operator identity; the service restarts to apply it. | +| `show-admin-credentials` | Show Admin Credentials | Enabled | Any | — | Displays the **fixed** built-in admin login (`admin` / `Whatever!`) with a security warning. | +| `configure-platform` | Configure Platform | Enabled | Any | relay, mode, CoinGecko, SUBSD (all optional) | Sets the Nostr relay, theme mode, pricing, and SUBSD backend. Saving restarts the service. | + +## Backups and Restore + +`sdk.Backups.ofVolumes('main')` — the entire `/data` volume is backed up, +including all SQLite databases, signed artifacts, and `store.json` (operator +keypair, session secret, and Configure-Platform settings). On restore, the same +idempotent init runs and reuses the existing keypair and secret. The Spaces +volume is **not** part of this package's backup. + +## Health Checks + +| ID | Display | Grace period | Behaviour | +| --- | --- | --- | --- | +| `spacesops` (daemon `ready`) | Web Interface | 60 s | TCP listen on `0.0.0.0:7264` | + +## Limitations and Differences + +1. **Requires the Spaces service.** SpacesOps auto-connects to `spaces.startos:7225` + by reading the Spaces RPC credentials from the mounted Spaces volume. Install + and sync Spaces before (or alongside) SpacesOps; the service restarts until + those credentials are available. +2. **The admin password is fixed and well-known.** Admin Basic Auth is hardcoded + in the v1.0.0 image as `admin` / `Whatever!` with **no environment override**. + It cannot be changed without rebuilding the image. **Keep this service private + (Tor-only) and never expose its admin routes to the public internet** — the + admin area can run SQL and manage tenants. See **Show Admin Credentials**. +3. **`PLATFORM_HOST` is forced to `0.0.0.0` and `PLATFORM_PORT` to `7264`** so the + StartOS reverse proxy can reach the app (it otherwise binds `127.0.0.1:3000`). +4. **Subname-purchase and pricing are off by default.** They activate only when + you fill in the relevant fields in **Configure Platform** (`COINGECKO_*` for + pricing, `SUBSD_*` for subname purchase + cert-callback flows). +5. **SUBSD is not auto-wired to the Spaces subs daemon in v1.** The Spaces subs + daemon listens on 7777 with a different API/auth than the SUBSD-on-7244 this + app expects, so SUBSD must be configured manually if used. + +## What Is Unchanged from Upstream + +- All SpacesOps web UI, admin, and REST API routes behave as upstream documents. +- SQLite schema and migrations are managed by the app and run on startup. +- `nostr-tools` / `bitcoinjs-lib` cryptography ships in the image and is used + exactly as upstream. +- Any environment variable not set by StartOS falls back to the image's + `setup-spacesops-env.sh` defaults. + +## Quick Reference for AI Consumers + +```yaml +package_id: spacesops +upstream_version: 1.0.0 +image: docker.io/spacesops/spacesops:v1.0.0 +architectures: [x86_64, aarch64] +volumes: + main: /data +mounted_dependencies: + spaces: /spaces-data # read-only; used to read spaced RPC creds at startup +ports: + ui: 7264 +dependencies: + - spaces # kind: running, versionRange '>=0.0.9:3', healthChecks [spaced, sync] +startos_managed_env_vars: + - PLATFORM_HOST # forced 0.0.0.0 + - PLATFORM_PORT # forced 7264 + - PLATFORM_DB_PATH # /data/local.db + - PLATFORM_MODE # store.platformMode ?? prod + - PLATFORM_SESSION_SECRET # generated + - OPERATOR_SECRET_HEX # generated or imported + - OPERATOR_PUBLIC_HEX # derived + - OPERATOR_RELAY # store.operatorRelay ?? wss://relay.primal.net + - SPACED_RPC_URL # http://spaces.startos:7225 + - SPACED_RPC_USER # from Spaces store.json + - SPACED_RPC_PASSWORD # from Spaces store.json + - SPACED_WALLETLOAD_NAME # main +optional_env_vars: # only set when configured via configure-platform + - COINGECKO_API_KEY + - COINGECKO_TOKEN_COINS + - SUBSD_URI_VALUE + - SUBSD_RPC_USER + - SUBSD_RPC_PASSWORD +admin_credentials: + username: admin + password: Whatever! # FIXED, baked into image, cannot be changed + note: keep service private; admin area can run SQL and manage tenants +actions: + - show-operator-credentials + - import-operator-key + - show-admin-credentials + - configure-platform +``` diff --git a/assets/README.md b/assets/README.md new file mode 100644 index 0000000..0744390 --- /dev/null +++ b/assets/README.md @@ -0,0 +1,8 @@ +# assets + +The package icon is `../icon.svg` at the repo root — that is what `start-cli` +packs into the `.s9pk` (and what `README.md` displays). Its editable Inkscape +source is `../spacesops.svg`; keep the two in sync. + +- `spaces-icon.png` — the Spaces icon used for the `spaces` dependency's + Marketplace metadata (referenced from `startos/manifest/index.ts`). diff --git a/assets/spaces-icon.png b/assets/spaces-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..8e51a5db95701705b5dae7a879fee4bb6b8e0fd2 GIT binary patch literal 53043 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%0DYJf=DaKoj&`s{;N<2;yl2~(EM+6^^+JpifR5?N( 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 + + + + + + + @ + + diff --git a/instructions.md b/instructions.md new file mode 100644 index 0000000..3064001 --- /dev/null +++ b/instructions.md @@ -0,0 +1,53 @@ +# SpacesOps + +SpacesOps is a web platform for confirming Spaces ownership and selling subspace +names. It connects to the Spaces `spaced` daemon, signs operator events on Nostr, +and optionally adds pricing and subname-purchase features. + +## Before you start: install Spaces + +SpacesOps **requires the Spaces service**. Install **Spaces** from the +Marketplace and let it sync **before** using SpacesOps. SpacesOps connects to it +automatically — you do not need to enter any spaced RPC credentials. Until +Spaces is installed and its credentials are ready, SpacesOps will keep +restarting; this is expected. + +## First run + +On install, StartOS automatically: + +1. Generates a **Nostr operator keypair** that SpacesOps signs events with. +2. Generates a strong **session secret**. + +There is no setup wizard. Once Spaces is reachable, open the **Web UI** from the +SpacesOps dashboard. + +## ⚠️ Admin credentials — important + +The admin area is protected by a **fixed, well-known** login that is baked into +the image and **cannot be changed**: + +- **Username:** `admin` +- **Password:** `Whatever!` + +The **Show Admin Credentials** action displays these. Because the admin area can +run SQL and manage tenants, **keep this service private** — use it over Tor or +your LAN, and do **not** expose its admin routes to the public internet. + +## Actions + +- **Show Operator Credentials** — view your Nostr operator npub, nsec, and hex + keys. +- **Import Operator Key** — replace the operator keypair with one you provide (a + 64-character hex secret key). This changes your operator identity and restarts + the service. +- **Show Admin Credentials** — view the fixed admin login (with the warning + above). +- **Configure Platform** — optionally set the Nostr relay, theme mode, CoinGecko + pricing, and a SUBSD backend. Pricing and subname-purchase features stay off + until you configure them here. Saving restarts the service. + +## Backups + +Backing up SpacesOps captures everything under `/data` — all databases, signed +artifacts, your operator keypair, and your settings. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9588961 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,514 @@ +{ + "name": "spacesops-startos", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "spacesops-startos", + "dependencies": { + "@start9labs/start-sdk": "1.5.1" + }, + "devDependencies": { + "@types/node": "^22.19.0", + "@vercel/ncc": "^0.38.4", + "nostr-tools": "^2.10.4", + "prettier": "^3.6.2", + "typescript": "^5.9.3" + } + }, + "node_modules/@iarna/toml": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-3.0.0.tgz", + "integrity": "sha512-td6ZUkz2oS3VeleBcN+m//Q6HlCFCPrnI0FZhrt/h4XqLEdOyYp2u21nd8MdsR+WJy5r9PTDaHTDDfhf4H4l6Q==", + "license": "ISC" + }, + "node_modules/@noble/ciphers": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", + "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@scure/base": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", + "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz", + "integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/curves": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz", + "integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@start9labs/start-sdk": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-1.5.1.tgz", + "integrity": "sha512-iztLiOCtHuTfUCd2JOWio4OvBk5qFGa0NI+G+ZB/dQ1sWtunYEnzqMcF6N/Ss4L6+7bBOMAMU4VuhyxeZoHyIw==", + "license": "MIT", + "dependencies": { + "@iarna/toml": "^3.0.0", + "@noble/curves": "^1.9.7", + "@noble/hashes": "^1.8.0", + "@types/ini": "^4.1.1", + "deep-equality-data-structures": "^2.0.0", + "fast-xml-parser": "~5.7.0", + "ini": "^5.0.0", + "isomorphic-fetch": "^3.0.0", + "mime": "^4.1.0", + "yaml": "^2.8.3", + "zod": "4.3.6", + "zod-deep-partial": "^1.2.0" + } + }, + "node_modules/@types/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@types/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-MIyNUZipBTbyUNnhvuXJTY7B6qNI78meck9Jbv3wk0OgNwRyOOVEKDutAkOs1snB/tx0FafyR6/SN4Ps0hZPeg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vercel/ncc": { + "version": "0.38.4", + "resolved": "https://registry.npmjs.org/@vercel/ncc/-/ncc-0.38.4.tgz", + "integrity": "sha512-8LwjnlP39s08C08J5NstzriPvW1SP8Zfpp1BvC2sI35kPeZnHfxVkCwu4/+Wodgnd60UtT1n8K8zw+Mp7J9JmQ==", + "dev": true, + "license": "MIT", + "bin": { + "ncc": "dist/ncc/cli.js" + } + }, + "node_modules/deep-equality-data-structures": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/deep-equality-data-structures/-/deep-equality-data-structures-2.0.0.tgz", + "integrity": "sha512-qgrUr7MKXq7VRN+WUpQ48QlXVGL0KdibAoTX8KRg18lgOgqbEKMAW1WZsVCtakY4+XX42pbAJzTz/DlXEFM2Fg==", + "license": "MIT", + "dependencies": { + "object-hash": "^3.0.0" + } + }, + "node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/ini": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz", + "integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/isomorphic-fetch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz", + "integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.1", + "whatwg-fetch": "^3.4.1" + } + }, + "node_modules/mime": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", + "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==", + "funding": [ + "https://github.com/sponsors/broofa" + ], + "license": "MIT", + "bin": { + "mime": "bin/cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/nostr-tools": { + "version": "2.23.5", + "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.23.5.tgz", + "integrity": "sha512-Fa7ZlUdjfUW1P4E7H3yBexhOHYi18XNyvd2n7eNHkYR085xADX6Y8V8Vm7nT/XQajaFOBrptXmVIGkJ2E4vfVw==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "@noble/ciphers": "2.1.1", + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0", + "@scure/bip32": "2.0.1", + "@scure/bip39": "2.0.1", + "nostr-wasm": "0.1.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/nostr-tools/node_modules/@noble/curves": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/nostr-tools/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/nostr-wasm": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz", + "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/strnum": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-deep-partial": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/zod-deep-partial/-/zod-deep-partial-1.4.4.tgz", + "integrity": "sha512-aWkPl7hVStgE01WzbbSxCgX4O+sSpgt8JOjvFUtMTF75VgL6MhWQbiZi+AWGN85SfSTtI9gsOtL1vInoqfDVaA==", + "license": "MIT", + "peerDependencies": { + "zod": "^4.1.13" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..cfe855f --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "spacesops-startos", + "scripts": { + "build": "rm -rf ./javascript && ncc build startos/index.ts -o ./javascript", + "prettier": "prettier --write startos", + "check": "tsc --noEmit" + }, + "dependencies": { + "@start9labs/start-sdk": "1.5.1" + }, + "devDependencies": { + "@types/node": "^22.19.0", + "@vercel/ncc": "^0.38.4", + "nostr-tools": "^2.10.4", + "prettier": "^3.6.2", + "typescript": "^5.9.3" + }, + "prettier": { + "trailingComma": "all", + "tabWidth": 2, + "semi": false, + "singleQuote": true + } +} diff --git a/s9pk.mk b/s9pk.mk new file mode 100644 index 0000000..25538af --- /dev/null +++ b/s9pk.mk @@ -0,0 +1,141 @@ +# ** Plumbing. DO NOT EDIT **. +# This file is imported by ./Makefile. Make edits there + +PACKAGE_ID := $(shell awk -F"'" '/id:/ {print $$2}' startos/manifest/index.ts) +INGREDIENTS := $(shell start-cli s9pk list-ingredients 2>/dev/null) +# Resolve the actual git dir so this works inside git worktrees, where .git +# is a file pointing at
/.git/worktrees/ rather than a directory. +GIT_DIR := $(shell git rev-parse --git-dir 2>/dev/null) +GIT_DEPS := $(if $(GIT_DIR),$(GIT_DIR)/HEAD $(GIT_DIR)/index) +ARCHES ?= x86 arm riscv +# TARGETS is the list of leaf make-targets the build matrix fans out over. +# Defaults to the arches; variant packages override (e.g. immich, ollama, vllm +# set this to a list of variant or variant-arch leaf targets). +TARGETS ?= $(ARCHES) +ifdef VARIANT +BASE_NAME := $(PACKAGE_ID)_$(VARIANT) +else +BASE_NAME := $(PACKAGE_ID) +endif + +.PHONY: all arches aarch64 x86_64 riscv64 arm arm64 x86 riscv arch/* clean install check-deps check-init package ingredients +.DELETE_ON_ERROR: +.SECONDARY: + +define SUMMARY + @manifest=$$(start-cli s9pk inspect $(1) manifest); \ + size=$$(du -h $(1) | awk '{print $$1}'); \ + title=$$(printf '%s' "$$manifest" | jq -r .title); \ + version=$$(printf '%s' "$$manifest" | jq -r .version); \ + arches=$$(printf '%s' "$$manifest" | jq -r '[.images[].arch // []] | flatten | unique | join(", ")'); \ + sdkv=$$(printf '%s' "$$manifest" | jq -r .sdkVersion); \ + gitHash=$$(printf '%s' "$$manifest" | jq -r .gitHash | sed -E 's/(.*-modified)$$/\x1b[0;31m\1\x1b[0m/'); \ + printf "\n"; \ + printf "\033[1;32m✅ Build Complete!\033[0m\n"; \ + printf "\n"; \ + printf "\033[1;37m📦 $$title\033[0m \033[36mv$$version\033[0m\n"; \ + printf "───────────────────────────────\n"; \ + printf " \033[1;36mFilename:\033[0m %s\n" "$(1)"; \ + printf " \033[1;36mSize:\033[0m %s\n" "$$size"; \ + printf " \033[1;36mArch:\033[0m %s\n" "$$arches"; \ + printf " \033[1;36mSDK:\033[0m %s\n" "$$sdkv"; \ + printf " \033[1;36mGit:\033[0m %s\n" "$$gitHash"; \ + echo "" +endef + +all: $(TARGETS) + +arches: $(ARCHES) + +# Generic make-variable introspection. Used by the release workflow to +# read $(TARGETS) and fan out one matrix runner per target. `make -s +# print-TARGETS` echoes the list with no other output. +print-%: + @echo '$($*)' + +universal: $(BASE_NAME).s9pk + $(call SUMMARY,$<) + +arch/%: $(BASE_NAME)_%.s9pk + $(call SUMMARY,$<) + +x86 x86_64: arch/x86_64 +arm arm64 aarch64: arch/aarch64 +riscv riscv64: arch/riscv64 + +$(BASE_NAME).s9pk: $(INGREDIENTS) $(GIT_DEPS) + @$(MAKE) --no-print-directory ingredients + @echo " Packing '$@'..." + start-cli s9pk pack -o $@ + +$(BASE_NAME)_%.s9pk: $(INGREDIENTS) $(GIT_DEPS) + @$(MAKE) --no-print-directory ingredients + @echo " Packing '$@'..." + start-cli s9pk pack --arch=$* -o $@ + +ingredients: $(INGREDIENTS) + @echo " Re-evaluating ingredients..." + +install: | check-deps check-init + @HOST=$$(awk -F'/' '/^host:/ {print $$3}' ~/.startos/config.yaml); \ + if [ -z "$$HOST" ]; then \ + echo "Error: You must define \"host: http://server-name.local\" in ~/.startos/config.yaml"; \ + exit 1; \ + fi; \ + S9PK=$$(ls -t *.s9pk 2>/dev/null | head -1); \ + if [ -z "$$S9PK" ]; then \ + echo "Error: No .s9pk file found. Run 'make' first."; \ + exit 1; \ + fi; \ + printf "\n🚀 Installing %s to %s ...\n" "$$S9PK" "$$HOST"; \ + start-cli package install -s "$$S9PK" + +publish: | all + @REGISTRY=$$(awk -F'/' '/^registry:/ {print $$3}' ~/.startos/config.yaml); \ + if [ -z "$$REGISTRY" ]; then \ + echo "Error: You must define \"registry: https://my-registry.tld\" in ~/.startos/config.yaml"; \ + exit 1; \ + fi; \ + S3BASE=$$(awk -F'/' '/^s9pk-s3base:/ {print $$3}' ~/.startos/config.yaml); \ + if [ -z "$$S3BASE" ]; then \ + echo "Error: You must define \"s3base: https://s9pks.my-s3-bucket.tld\" in ~/.startos/config.yaml"; \ + exit 1; \ + fi; \ + command -v s3cmd >/dev/null || \ + (echo "Error: s3cmd not found. It must be installed to publish using s3." && exit 1); \ + printf "\n🚀 Publishing to %s; indexing on %s ...\n" "$$S3BASE" "$$REGISTRY"; \ + for s9pk in *.s9pk; do \ + age=$$(( $$(date +%s) - $$(stat -c %Y "$$s9pk") )); \ + if [ "$$age" -gt 3600 ]; then \ + printf "\033[1;33m⚠️ %s is %d minutes old. Publish anyway? [y/N] \033[0m" "$$s9pk" "$$((age / 60))"; \ + read -r ans; \ + case "$$ans" in [yY]*) ;; *) echo "Skipping $$s9pk"; continue ;; esac; \ + fi; \ + start-cli s9pk publish "$$s9pk"; \ + done + +check-deps: + @command -v start-cli >/dev/null || \ + (echo "Error: start-cli not found. Please see https://docs.start9.com/latest/developer-guide/sdk/installing-the-sdk" && exit 1) + @command -v npm >/dev/null || \ + (echo "Error: npm not found. Please install Node.js and npm." && exit 1) + +check-init: + @if [ ! -f ~/.startos/developer.key.pem ]; then \ + echo "Initializing StartOS developer environment..."; \ + start-cli init-key; \ + fi + +javascript/index.js: $(shell find startos -type f) tsconfig.json node_modules + npm run check + npm run build + +node_modules: package-lock.json + npm ci + +package-lock.json: package.json + npm i + +clean: + @echo "Cleaning up build artifacts..." + @rm -rf $(PACKAGE_ID).s9pk $(PACKAGE_ID)_x86_64.s9pk $(PACKAGE_ID)_aarch64.s9pk $(PACKAGE_ID)_riscv64.s9pk javascript node_modules diff --git a/spacesops.svg b/spacesops.svg new file mode 100644 index 0000000..3a710e9 --- /dev/null +++ b/spacesops.svg @@ -0,0 +1,53 @@ + + + + + + + + @ + + diff --git a/startos/actions/configurePlatform.ts b/startos/actions/configurePlatform.ts new file mode 100644 index 0000000..7e3a8eb --- /dev/null +++ b/startos/actions/configurePlatform.ts @@ -0,0 +1,163 @@ +import { storeJson } from '../fileModels/storeJson' +import { i18n } from '../i18n' +import { sdk } from '../sdk' +import { + DEFAULT_COINGECKO_TOKEN_COINS, + DEFAULT_OPERATOR_RELAY, + DEFAULT_PLATFORM_MODE, +} from '../utils' + +const { InputSpec, Value } = sdk + +const inputSpec = InputSpec.of({ + operatorRelay: Value.text({ + name: i18n('Operator Nostr Relay'), + description: i18n( + 'The Nostr relay SpacesOps publishes operator events to (OPERATOR_RELAY).', + ), + warning: null, + footnote: null, + default: DEFAULT_OPERATOR_RELAY, + required: true, + masked: false, + placeholder: 'wss://relay.example.com', + minLength: 1, + maxLength: null, + }), + platformMode: Value.select({ + name: i18n('Platform Mode'), + description: i18n( + 'Sets PLATFORM_MODE. Only changes the UI theme color; "test" does not change behavior.', + ), + warning: null, + footnote: null, + default: DEFAULT_PLATFORM_MODE, + values: { + prod: 'Production', + test: 'Test', + }, + }), + coingeckoApiKey: Value.text({ + name: i18n('CoinGecko API Key'), + description: i18n( + 'Optional CoinGecko API key (COINGECKO_API_KEY) used for pricing features. Leave blank to disable pricing.', + ), + warning: null, + footnote: null, + default: null, + required: false, + masked: true, + placeholder: null, + minLength: null, + maxLength: null, + }), + coingeckoTokenCoins: Value.text({ + name: i18n('CoinGecko Token Coins'), + description: i18n( + 'The CoinGecko coin id(s) to price against (COINGECKO_TOKEN_COINS).', + ), + warning: null, + footnote: null, + default: DEFAULT_COINGECKO_TOKEN_COINS, + required: false, + masked: false, + placeholder: 'bitcoin', + minLength: null, + maxLength: null, + }), + subsdUrl: Value.text({ + name: i18n('SUBSD URL'), + description: i18n( + 'Optional SUBSD service URL (SUBSD_URI_VALUE) for the subname-purchase flow. Leave blank to disable.', + ), + warning: null, + footnote: null, + default: null, + required: false, + masked: false, + placeholder: 'http://host:7244', + minLength: null, + maxLength: null, + }), + subsdUser: Value.text({ + name: i18n('SUBSD RPC User'), + description: i18n('Optional SUBSD RPC username (SUBSD_RPC_USER).'), + warning: null, + footnote: null, + default: null, + required: false, + masked: false, + placeholder: null, + minLength: null, + maxLength: null, + }), + subsdPassword: Value.text({ + name: i18n('SUBSD RPC Password'), + description: i18n('Optional SUBSD RPC password (SUBSD_RPC_PASSWORD).'), + warning: null, + footnote: null, + default: null, + required: false, + masked: true, + placeholder: null, + minLength: null, + maxLength: null, + }), +}) + +export const configurePlatform = sdk.Action.withInput( + // id + 'configure-platform', + + // metadata + async ({ effects }) => ({ + name: i18n('Configure Platform'), + description: i18n( + 'Set optional SpacesOps settings: Nostr relay, theme mode, CoinGecko pricing, and SUBSD backend. Saving restarts the service so the new settings take effect.', + ), + warning: null, + allowedStatuses: 'any', + group: null, + visibility: 'enabled', + }), + + // input + inputSpec, + + // prefill — load current values from store + async ({ effects }) => { + const store = await storeJson.read().once() + return { + operatorRelay: store?.operatorRelay ?? DEFAULT_OPERATOR_RELAY, + platformMode: store?.platformMode ?? DEFAULT_PLATFORM_MODE, + coingeckoApiKey: store?.coingeckoApiKey ?? null, + coingeckoTokenCoins: + store?.coingeckoTokenCoins ?? DEFAULT_COINGECKO_TOKEN_COINS, + subsdUrl: store?.subsdUrl ?? null, + subsdUser: store?.subsdUser ?? null, + subsdPassword: store?.subsdPassword ?? null, + } + }, + + // run + async ({ effects, input }) => { + await storeJson.merge(effects, { + operatorRelay: input.operatorRelay, + platformMode: input.platformMode, + coingeckoApiKey: input.coingeckoApiKey || null, + coingeckoTokenCoins: input.coingeckoTokenCoins || null, + subsdUrl: input.subsdUrl || null, + subsdUser: input.subsdUser || null, + subsdPassword: input.subsdPassword || null, + }) + + return { + version: '1', + title: i18n('Success'), + message: i18n( + 'Platform configuration saved. The service is restarting to apply the new settings.', + ), + result: null, + } + }, +) diff --git a/startos/actions/importOperatorKey.ts b/startos/actions/importOperatorKey.ts new file mode 100644 index 0000000..5a86b96 --- /dev/null +++ b/startos/actions/importOperatorKey.ts @@ -0,0 +1,101 @@ +import { storeJson } from '../fileModels/storeJson' +import { i18n } from '../i18n' +import { isValidSecretHex, secretHexToPublicHex } from '../nostr' +import { sdk } from '../sdk' + +const { InputSpec, Value } = sdk + +const inputSpec = InputSpec.of({ + secretHex: Value.text({ + name: i18n('Operator Secret Key (hex)'), + description: i18n( + 'A 64-character hex-encoded secp256k1 / Nostr secret key. The public key is derived automatically.', + ), + warning: null, + footnote: null, + default: null, + required: true, + masked: true, + placeholder: '64 hexadecimal characters', + minLength: 64, + maxLength: 64, + patterns: [ + { + regex: '^[0-9a-fA-F]{64}$', + description: i18n('Must be exactly 64 hexadecimal characters.'), + }, + ], + }), +}) + +export const importOperatorKey = sdk.Action.withInput( + // id + 'import-operator-key', + + // metadata + async ({ effects }) => ({ + name: i18n('Import Operator Key'), + description: i18n( + 'Replace the operator keypair with one you provide (hex secret key).', + ), + warning: i18n( + 'This changes the operator identity SpacesOps signs events with. Events already published under the old key stay under it. The service restarts to apply the new key.', + ), + allowedStatuses: 'any', + group: null, + visibility: 'enabled', + }), + + // input + inputSpec, + + // prefill — never pre-populate a secret + async ({ effects }) => {}, + + // run + async ({ effects, input }) => { + const secretHex = input.secretHex.trim().toLowerCase() + + if (!isValidSecretHex(secretHex)) { + return { + version: '1', + title: i18n('Failure'), + message: i18n( + 'The secret key must be exactly 64 hexadecimal characters.', + ), + result: null, + } + } + + let operatorPublicHex: string + try { + operatorPublicHex = secretHexToPublicHex(secretHex) + } catch (e) { + return { + version: '1', + title: i18n('Failure'), + message: i18n( + 'Could not derive a public key from that secret: ${error}', + { + error: (e as Error).message, + }, + ), + result: null, + } + } + + await storeJson.merge(effects, { + operatorSecretHex: secretHex, + operatorPublicHex, + }) + + return { + version: '1', + title: i18n('Success'), + message: i18n( + 'Operator key imported. The service is restarting to apply the new identity.', + ), + result: null, + } + }, +) diff --git a/startos/actions/index.ts b/startos/actions/index.ts new file mode 100644 index 0000000..c33113c --- /dev/null +++ b/startos/actions/index.ts @@ -0,0 +1,11 @@ +import { sdk } from '../sdk' +import { configurePlatform } from './configurePlatform' +import { importOperatorKey } from './importOperatorKey' +import { showAdminCredentials } from './showAdminCredentials' +import { showOperatorCredentials } from './showOperatorCredentials' + +export const actions = sdk.Actions.of() + .addAction(showOperatorCredentials) + .addAction(importOperatorKey) + .addAction(showAdminCredentials) + .addAction(configurePlatform) diff --git a/startos/actions/showAdminCredentials.ts b/startos/actions/showAdminCredentials.ts new file mode 100644 index 0000000..1bef683 --- /dev/null +++ b/startos/actions/showAdminCredentials.ts @@ -0,0 +1,52 @@ +import { i18n } from '../i18n' +import { sdk } from '../sdk' +import { ADMIN_PASSWORD, ADMIN_USER } from '../utils' + +export const showAdminCredentials = sdk.Action.withoutInput( + // id + 'show-admin-credentials', + + // metadata + async ({ effects }) => ({ + name: i18n('Show Admin Credentials'), + description: i18n( + 'Display the built-in admin username and password for the SpacesOps admin area.', + ), + warning: null, + allowedStatuses: 'any', + group: null, + visibility: 'enabled', + }), + + // run + async ({ effects }) => ({ + version: '1', + title: i18n('Admin Credentials'), + message: i18n( + 'WARNING: these are FIXED, well-known credentials baked into the image and cannot be changed without rebuilding it. The admin area can run SQL and manage tenants. Keep this service private (Tor-only) and never expose the admin routes to the public internet.', + ), + result: { + type: 'group', + value: [ + { + type: 'single', + name: i18n('Username'), + description: null, + value: ADMIN_USER, + masked: false, + copyable: true, + qr: false, + }, + { + type: 'single', + name: i18n('Password'), + description: null, + value: ADMIN_PASSWORD, + masked: true, + copyable: true, + qr: false, + }, + ], + }, + }), +) diff --git a/startos/actions/showOperatorCredentials.ts b/startos/actions/showOperatorCredentials.ts new file mode 100644 index 0000000..380a85e --- /dev/null +++ b/startos/actions/showOperatorCredentials.ts @@ -0,0 +1,88 @@ +import { storeJson } from '../fileModels/storeJson' +import { i18n } from '../i18n' +import { encodeNpub, encodeNsec } from '../nostr' +import { sdk } from '../sdk' + +export const showOperatorCredentials = sdk.Action.withoutInput( + // id + 'show-operator-credentials', + + // metadata + async ({ effects }) => ({ + name: i18n('Show Operator Credentials'), + description: i18n( + 'Display the Nostr operator keypair SpacesOps signs events with (npub, nsec, and hex public key).', + ), + warning: null, + allowedStatuses: 'any', + group: null, + visibility: 'enabled', + }), + + // run + async ({ effects }) => { + const store = await storeJson.read().once() + const secretHex = store?.operatorSecretHex ?? null + const publicHex = store?.operatorPublicHex ?? null + + if (!secretHex || !publicHex) { + return { + version: '1', + title: i18n('Operator Credentials'), + message: i18n( + 'The operator keypair has not been generated yet. Start the service once to generate it.', + ), + result: null, + } + } + + return { + version: '1', + title: i18n('Operator Credentials'), + message: i18n( + 'SpacesOps signs operator events on Nostr with this keypair. Keep the secret (nsec / hex) private.', + ), + result: { + type: 'group', + value: [ + { + type: 'single', + name: i18n('Public Key (npub)'), + description: null, + value: encodeNpub(publicHex), + masked: false, + copyable: true, + qr: true, + }, + { + type: 'single', + name: i18n('Public Key (hex)'), + description: null, + value: publicHex, + masked: false, + copyable: true, + qr: false, + }, + { + type: 'single', + name: i18n('Secret Key (nsec)'), + description: null, + value: encodeNsec(secretHex), + masked: true, + copyable: true, + qr: false, + }, + { + type: 'single', + name: i18n('Secret Key (hex)'), + description: null, + value: secretHex, + masked: true, + copyable: true, + qr: false, + }, + ], + }, + } + }, +) diff --git a/startos/backups.ts b/startos/backups.ts new file mode 100644 index 0000000..0a90b1e --- /dev/null +++ b/startos/backups.ts @@ -0,0 +1,5 @@ +import { sdk } from './sdk' + +export const { createBackup, restoreInit } = sdk.setupBackups( + async ({ effects }) => sdk.Backups.ofVolumes('main'), +) diff --git a/startos/dependencies.ts b/startos/dependencies.ts new file mode 100644 index 0000000..2b067f5 --- /dev/null +++ b/startos/dependencies.ts @@ -0,0 +1,14 @@ +import { sdk } from './sdk' + +// Spaces 0.0.9:3 binds spaced to 0.0.0.0:7225 and exports the `spaces-api` +// interface with static RPC creds at the root of its `main` volume. SpacesOps +// reads those creds from the mounted Spaces volume in main.ts and connects to +// http://spaces.startos:7225. Dependency declarations do NOT gate startup — +// main.ts throws/retries until the creds are available. +export const setDependencies = sdk.setupDependencies(async ({ effects }) => ({ + spaces: { + kind: 'running', + versionRange: '>=0.0.9:3', + healthChecks: ['spaced', 'sync'], + }, +})) diff --git a/startos/fileModels/storeJson.ts b/startos/fileModels/storeJson.ts new file mode 100644 index 0000000..5bf62d5 --- /dev/null +++ b/startos/fileModels/storeJson.ts @@ -0,0 +1,24 @@ +import { FileHelper, z } from '@start9labs/start-sdk' +import { sdk } from '../sdk' + +const shape = z.object({ + // Nostr operator keypair — generated once in init, or replaced via the + // "Import Operator Key" action. SpacesOps signs operator events with it. + operatorSecretHex: z.string().nullable().catch(null), + operatorPublicHex: z.string().nullable().catch(null), + // Strong replacement for the app's weak hardcoded session secret. + sessionSecret: z.string().nullable().catch(null), + // Optional config set via the "Configure Platform" action. + platformMode: z.enum(['prod', 'test']).nullable().catch(null), + operatorRelay: z.string().nullable().catch(null), + coingeckoApiKey: z.string().nullable().catch(null), + coingeckoTokenCoins: z.string().nullable().catch(null), + subsdUrl: z.string().nullable().catch(null), + subsdUser: z.string().nullable().catch(null), + subsdPassword: z.string().nullable().catch(null), +}) + +export const storeJson = FileHelper.json( + { base: sdk.volumes.main, subpath: 'store.json' }, + shape, +) diff --git a/startos/i18n/dictionaries/default.ts b/startos/i18n/dictionaries/default.ts new file mode 100644 index 0000000..482e64b --- /dev/null +++ b/startos/i18n/dictionaries/default.ts @@ -0,0 +1,60 @@ +export const DEFAULT_LANG = 'en_US' + +const dict = { + 'Operator Nostr Relay': 0, + 'The Nostr relay SpacesOps publishes operator events to (OPERATOR_RELAY).': 1, + 'Platform Mode': 2, + 'Sets PLATFORM_MODE. Only changes the UI theme color; "test" does not change behavior.': 3, + 'CoinGecko API Key': 4, + 'Optional CoinGecko API key (COINGECKO_API_KEY) used for pricing features. Leave blank to disable pricing.': 5, + 'CoinGecko Token Coins': 6, + 'The CoinGecko coin id(s) to price against (COINGECKO_TOKEN_COINS).': 7, + 'SUBSD URL': 8, + 'Optional SUBSD service URL (SUBSD_URI_VALUE) for the subname-purchase flow. Leave blank to disable.': 9, + 'SUBSD RPC User': 10, + 'Optional SUBSD RPC username (SUBSD_RPC_USER).': 11, + 'SUBSD RPC Password': 12, + 'Optional SUBSD RPC password (SUBSD_RPC_PASSWORD).': 13, + 'Configure Platform': 14, + 'Set optional SpacesOps settings: Nostr relay, theme mode, CoinGecko pricing, and SUBSD backend. Saving restarts the service so the new settings take effect.': 15, + Success: 16, + 'Platform configuration saved. The service is restarting to apply the new settings.': 17, + 'Operator Secret Key (hex)': 18, + 'A 64-character hex-encoded secp256k1 / Nostr secret key. The public key is derived automatically.': 19, + 'Must be exactly 64 hexadecimal characters.': 20, + 'Import Operator Key': 21, + 'Replace the operator keypair with one you provide (hex secret key).': 22, + 'This changes the operator identity SpacesOps signs events with. Events already published under the old key stay under it. The service restarts to apply the new key.': 23, + Failure: 24, + 'The secret key must be exactly 64 hexadecimal characters.': 25, + 'Could not derive a public key from that secret: ${error}': 26, + 'Operator key imported. The service is restarting to apply the new identity.': 27, + 'Show Admin Credentials': 28, + 'Display the built-in admin username and password for the SpacesOps admin area.': 29, + 'Admin Credentials': 30, + 'WARNING: these are FIXED, well-known credentials baked into the image and cannot be changed without rebuilding it. The admin area can run SQL and manage tenants. Keep this service private (Tor-only) and never expose the admin routes to the public internet.': 31, + Username: 32, + Password: 33, + 'Show Operator Credentials': 34, + 'Display the Nostr operator keypair SpacesOps signs events with (npub, nsec, and hex public key).': 35, + 'Operator Credentials': 36, + 'The operator keypair has not been generated yet. Start the service once to generate it.': 37, + 'SpacesOps signs operator events on Nostr with this keypair. Keep the secret (nsec / hex) private.': 38, + 'Public Key (npub)': 39, + 'Public Key (hex)': 40, + 'Secret Key (nsec)': 41, + 'Secret Key (hex)': 42, + 'Web UI': 43, + 'SpacesOps web platform for space ownership confirmation and subspace-name purchases. The app provides its own login; the admin area uses a fixed built-in credential — see "Show Admin Credentials".': 44, + 'Starting SpacesOps!': 45, + 'Web Interface': 46, + 'The web interface is ready': 47, + 'The web interface is not ready': 48, +} as const + +/** + * Plumbing. DO NOT EDIT. + */ +export type I18nKey = keyof typeof dict +export type LangDict = Record<(typeof dict)[I18nKey], string> +export default dict diff --git a/startos/i18n/dictionaries/translations.ts b/startos/i18n/dictionaries/translations.ts new file mode 100644 index 0000000..98f9348 --- /dev/null +++ b/startos/i18n/dictionaries/translations.ts @@ -0,0 +1,3 @@ +import { LangDict } from './default' + +export default {} satisfies Record diff --git a/startos/i18n/index.ts b/startos/i18n/index.ts new file mode 100644 index 0000000..04cea20 --- /dev/null +++ b/startos/i18n/index.ts @@ -0,0 +1,8 @@ +/** + * Plumbing. DO NOT EDIT this file. + */ +import { setupI18n } from '@start9labs/start-sdk' +import defaultDict, { DEFAULT_LANG } from './dictionaries/default' +import translations from './dictionaries/translations' + +export const i18n = setupI18n(defaultDict, translations, DEFAULT_LANG) diff --git a/startos/index.ts b/startos/index.ts new file mode 100644 index 0000000..7af589b --- /dev/null +++ b/startos/index.ts @@ -0,0 +1,11 @@ +/** + * Plumbing. DO NOT EDIT. + */ +export { createBackup } from './backups' +export { main } from './main' +export { init, uninit } from './init' +export { actions } from './actions' +import { buildManifest } from '@start9labs/start-sdk' +import { manifest as sdkManifest } from './manifest' +import { versionGraph } from './versions' +export const manifest = buildManifest(versionGraph, sdkManifest) diff --git a/startos/init/index.ts b/startos/init/index.ts new file mode 100644 index 0000000..0dd7641 --- /dev/null +++ b/startos/init/index.ts @@ -0,0 +1,20 @@ +import { actions } from '../actions' +import { restoreInit } from '../backups' +import { setDependencies } from '../dependencies' +import { setInterfaces } from '../interfaces' +import { sdk } from '../sdk' +import { versionGraph } from '../versions' +import { taskOperatorKeys } from './taskOperatorKeys' +import { taskSessionSecret } from './taskSessionSecret' + +export const init = sdk.setupInit( + restoreInit, + versionGraph, + setInterfaces, + setDependencies, + actions, + taskOperatorKeys, + taskSessionSecret, +) + +export const uninit = sdk.setupUninit(versionGraph) diff --git a/startos/init/taskOperatorKeys.ts b/startos/init/taskOperatorKeys.ts new file mode 100644 index 0000000..e0255e9 --- /dev/null +++ b/startos/init/taskOperatorKeys.ts @@ -0,0 +1,24 @@ +import { storeJson } from '../fileModels/storeJson' +import { sdk } from '../sdk' +import { secretHexToPublicHex } from '../nostr' +import { randomOperatorSecretHex } from '../utils' + +// Generate the Nostr operator keypair once, if absent. SpacesOps requires +// OPERATOR_SECRET_HEX / OPERATOR_PUBLIC_HEX at startup (it exits otherwise). +// Idempotent: only acts when the secret is not yet present. Read with .once() +// — never .const() in init (it arms a write-after-const watcher). +export const taskOperatorKeys = sdk.setupOnInit(async (effects) => { + const existing = await storeJson.read((s) => s.operatorSecretHex).once() + if (existing) return + + const operatorSecretHex = randomOperatorSecretHex() + // Derive before persisting; if derivation rejects the (astronomically rare) + // out-of-range scalar, nothing is written and StartOS retries init. + const operatorPublicHex = secretHexToPublicHex(operatorSecretHex) + + await storeJson.merge( + effects, + { operatorSecretHex, operatorPublicHex }, + { allowWriteAfterConst: true }, + ) +}) diff --git a/startos/init/taskSessionSecret.ts b/startos/init/taskSessionSecret.ts new file mode 100644 index 0000000..304bdfd --- /dev/null +++ b/startos/init/taskSessionSecret.ts @@ -0,0 +1,16 @@ +import { storeJson } from '../fileModels/storeJson' +import { sdk } from '../sdk' +import { randomSessionSecret } from '../utils' + +// Generate a strong PLATFORM_SESSION_SECRET once, if absent. The app otherwise +// falls back to a weak hardcoded default. Idempotent; reads with .once(). +export const taskSessionSecret = sdk.setupOnInit(async (effects) => { + const existing = await storeJson.read((s) => s.sessionSecret).once() + if (existing) return + + await storeJson.merge( + effects, + { sessionSecret: randomSessionSecret() }, + { allowWriteAfterConst: true }, + ) +}) diff --git a/startos/interfaces.ts b/startos/interfaces.ts new file mode 100644 index 0000000..e3bf898 --- /dev/null +++ b/startos/interfaces.ts @@ -0,0 +1,27 @@ +import { i18n } from './i18n' +import { sdk } from './sdk' +import { uiPort } from './utils' + +export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => { + const uiMulti = sdk.MultiHost.of(effects, 'ui-multi') + const uiMultiOrigin = await uiMulti.bindPort(uiPort, { + protocol: 'http', + }) + const ui = sdk.createInterface(effects, { + name: i18n('Web UI'), + id: 'ui', + description: i18n( + 'SpacesOps web platform for space ownership confirmation and subspace-name purchases. The app provides its own login; the admin area uses a fixed built-in credential — see "Show Admin Credentials".', + ), + type: 'ui', + masked: false, + schemeOverride: null, + username: null, + path: '', + query: {}, + }) + + const uiReceipt = await uiMultiOrigin.export([ui]) + + return [uiReceipt] +}) diff --git a/startos/main.ts b/startos/main.ts new file mode 100644 index 0000000..b2949ad --- /dev/null +++ b/startos/main.ts @@ -0,0 +1,131 @@ +import { storeJson } from './fileModels/storeJson' +import { i18n } from './i18n' +import { sdk } from './sdk' +import { + dataDir, + DEFAULT_OPERATOR_RELAY, + DEFAULT_PLATFORM_MODE, + SPACED_RPC_URL, + SPACED_WALLETLOAD_NAME, + spacesDataDir, + uiPort, +} from './utils' + +type SpacedAuth = { username: string; password: string } + +export const main = sdk.setupMain(async ({ effects }) => { + console.info(i18n('Starting SpacesOps!')) + + // Read with .const() so a store.json change (e.g. from Import Operator Key or + // Configure Platform) triggers an automatic service restart. + const store = await storeJson.read().const(effects) + if ( + !store?.operatorSecretHex || + !store?.operatorPublicHex || + !store?.sessionSecret + ) { + // taskOperatorKeys + taskSessionSecret seed these in init; if they aren't + // populated yet, init hasn't finished — let StartOS restart us. + throw new Error( + 'SpacesOps store.json is not yet populated (operator keys / session secret missing).', + ) + } + + // Mount our own volume at /data AND the Spaces 'main' volume (read-only) at + // /spaces-data so we can read the spaced RPC credentials Spaces seeded there. + const mounts = sdk.Mounts.of() + .mountVolume({ + volumeId: 'main', + subpath: null, + mountpoint: dataDir, + readonly: false, + }) + .mountDependency({ + dependencyId: 'spaces', + volumeId: 'main', + subpath: null, + mountpoint: spacesDataDir, + readonly: true, + type: 'directory', + }) + + const sub = await sdk.SubContainer.of( + effects, + { imageId: 'spacesops' }, + mounts, + 'spacesops-sub', + ) + + // setupMain runs in the StartOS runtime, NOT inside the container, so it + // cannot fs-read the mounted Spaces volume directly. Exec a read inside the + // subcontainer instead (mirrors how spaces-startos execs space-cli for its + // sync health check). Throw if the creds aren't there yet — StartOS restarts + // us until Spaces is installed and its store.json is seeded. + const probe = await sub.exec(['cat', `${spacesDataDir}/store.json`]) + if (probe.exitCode !== 0) { + throw new Error( + `Spaces RPC creds not available yet: could not read ${spacesDataDir}/store.json (exit ${probe.exitCode}). Is the Spaces service installed?`, + ) + } + let spacesStore: { spacedAuth?: SpacedAuth | null } + try { + spacesStore = JSON.parse((probe.stdout ?? '').toString()) + } catch { + throw new Error( + 'Spaces RPC creds not available yet: Spaces store.json is not valid JSON.', + ) + } + const spacedAuth = spacesStore.spacedAuth + if (!spacedAuth || !spacedAuth.username || !spacedAuth.password) { + throw new Error( + 'Spaces RPC creds not available yet: spacedAuth missing from Spaces store.json. Wait for the Spaces service init to finish.', + ) + } + + const env: Record = { + // The app defaults to 127.0.0.1:3000, which the StartOS proxy cannot reach. + PLATFORM_HOST: '0.0.0.0', + PLATFORM_PORT: String(uiPort), + PLATFORM_DB_PATH: `${dataDir}/local.db`, + PLATFORM_MODE: store.platformMode ?? DEFAULT_PLATFORM_MODE, + PLATFORM_SESSION_SECRET: store.sessionSecret, + OPERATOR_SECRET_HEX: store.operatorSecretHex, + OPERATOR_PUBLIC_HEX: store.operatorPublicHex, + OPERATOR_RELAY: store.operatorRelay ?? DEFAULT_OPERATOR_RELAY, + SPACED_RPC_URL, + SPACED_RPC_USER: spacedAuth.username, + SPACED_RPC_PASSWORD: spacedAuth.password, + SPACED_WALLETLOAD_NAME, + } + + // Optional integrations — only injected when configured (Configure Platform). + if (store.coingeckoApiKey) env.COINGECKO_API_KEY = store.coingeckoApiKey + if (store.coingeckoTokenCoins) + env.COINGECKO_TOKEN_COINS = store.coingeckoTokenCoins + if (store.subsdUrl) env.SUBSD_URI_VALUE = store.subsdUrl + if (store.subsdUser) env.SUBSD_RPC_USER = store.subsdUser + if (store.subsdPassword) env.SUBSD_RPC_PASSWORD = store.subsdPassword + + return sdk.Daemons.of(effects).addDaemon('spacesops', { + subcontainer: sub, + exec: { + // Keep the image entrypoint: it creates /data dirs, symlinks + // /app/data -> /data, and loads defaults for any UNSET vars before + // exec'ing the command. + command: ['/app/docker-entrypoint.sh', 'node', 'server.js'], + env, + cwd: '/app', + user: 'root', + }, + ready: { + display: i18n('Web Interface'), + fn: () => + sdk.healthCheck.checkPortListening(effects, uiPort, { + successMessage: i18n('The web interface is ready'), + errorMessage: i18n('The web interface is not ready'), + }), + gracePeriod: 60_000, + }, + requires: [], + }) +}) diff --git a/startos/manifest/i18n.ts b/startos/manifest/i18n.ts new file mode 100644 index 0000000..2d1559d --- /dev/null +++ b/startos/manifest/i18n.ts @@ -0,0 +1,13 @@ +export const short = { + en_US: 'Space ownership confirmation and subname sales.', +} + +export const long = { + en_US: + 'SpacesOps is a web platform for confirming Spaces ownership and selling subspace names. It talks to the Spaces spaced daemon over JSON-RPC, signs operator events on Nostr, and optionally integrates pricing and subname-purchase backends. This package auto-connects to the Spaces service running on the same server.', +} + +export const depSpacesDescription = { + en_US: + 'Provides the spaced JSON-RPC endpoint SpacesOps queries. SpacesOps auto-connects to it at spaces.startos:7225 using the Spaces RPC credentials, so Spaces must be installed and synced.', +} diff --git a/startos/manifest/index.ts b/startos/manifest/index.ts new file mode 100644 index 0000000..98ce97e --- /dev/null +++ b/startos/manifest/index.ts @@ -0,0 +1,42 @@ +import { setupManifest } from '@start9labs/start-sdk' +import { depSpacesDescription, long, short } from './i18n' + +export const manifest = setupManifest({ + id: 'spacesops', + title: 'SpacesOps', + license: 'MIT', + packageRepo: 'https://git.spacesops.com/spacesops/spacesops-startos', + upstreamRepo: 'https://git.spacesops.com/spacesops/spacesops', + marketingUrl: 'https://spacesops.com', + donationUrl: null, + docsUrls: [], + description: { short, long }, + volumes: ['main'], + images: { + spacesops: { + source: { dockerTag: 'spacesops/spacesops:v1.0.0' }, + arch: ['x86_64', 'aarch64'], + }, + }, + alerts: { + install: { + en_US: + 'SpacesOps REQUIRES the Spaces service. Install and sync Spaces first — SpacesOps auto-connects to it at spaces.startos:7225 by reading the Spaces RPC credentials, and will not start until they are available.\n\nWARNING: the admin area is protected by a FIXED, well-known credential baked into this image (username "admin", password "Whatever!") that cannot be changed without rebuilding the image. The admin area can run SQL and manage tenants. Keep this service private (Tor-only) and do NOT expose its admin routes to the public internet. See the "Show Admin Credentials" action.', + }, + update: null, + uninstall: null, + restore: null, + start: null, + stop: null, + }, + dependencies: { + spaces: { + description: depSpacesDescription, + optional: false, + metadata: { + title: 'Spaces', + icon: 'assets/spaces-icon.png', + }, + }, + }, +}) diff --git a/startos/nostr.ts b/startos/nostr.ts new file mode 100644 index 0000000..4f76bf5 --- /dev/null +++ b/startos/nostr.ts @@ -0,0 +1,25 @@ +import { getPublicKey, nip19 } from 'nostr-tools' + +const HEX64 = /^[0-9a-f]{64}$/ + +function toBytes(secretHex: string): Uint8Array { + return Uint8Array.from(Buffer.from(secretHex.trim().toLowerCase(), 'hex')) +} + +export function isValidSecretHex(hex: string): boolean { + return HEX64.test(hex.trim().toLowerCase()) +} + +// Deterministic secp256k1 x-only public key derivation (no randomness). Throws +// if the secret is not a valid curve scalar. +export function secretHexToPublicHex(secretHex: string): string { + return getPublicKey(toBytes(secretHex)) +} + +export function encodeNpub(publicHex: string): string { + return nip19.npubEncode(publicHex) +} + +export function encodeNsec(secretHex: string): string { + return nip19.nsecEncode(toBytes(secretHex)) +} diff --git a/startos/sdk.ts b/startos/sdk.ts new file mode 100644 index 0000000..04ae4b1 --- /dev/null +++ b/startos/sdk.ts @@ -0,0 +1,9 @@ +import { StartSdk } from '@start9labs/start-sdk' +import { manifest } from './manifest' + +/** + * Plumbing. DO NOT EDIT. + * + * The exported "sdk" const is used throughout this package codebase. + */ +export const sdk = StartSdk.of().withManifest(manifest).build(true) diff --git a/startos/utils.ts b/startos/utils.ts new file mode 100644 index 0000000..212eda7 --- /dev/null +++ b/startos/utils.ts @@ -0,0 +1,44 @@ +import { utils } from '@start9labs/start-sdk' + +// The SpacesOps HTTP server. The app defaults to 127.0.0.1:3000; we override +// PLATFORM_HOST to 0.0.0.0 and PLATFORM_PORT to this so the StartOS reverse +// proxy can reach it. +export const uiPort = 7264 + +// Our own volume. +export const dataDir = '/data' + +// Where the Spaces 'main' volume is mounted (read-only) so we can read the +// spaced RPC credentials Spaces seeded into its store.json. +export const spacesDataDir = '/spaces-data' + +// The Spaces package this service depends on. +export const SPACES_PACKAGE_ID = 'spaces' + +// Spaces exposes its spaced JSON-RPC as the `spaces-api` interface on 7225, +// reachable from a dependent package at this address. +export const SPACED_RPC_URL = 'http://spaces.startos:7225' +export const SPACED_WALLETLOAD_NAME = 'main' + +// Admin Basic Auth is baked into the v1.0.0 image with NO env override. It +// cannot be changed without rebuilding the image. Surfaced (with a warning) +// via the "Show Admin Credentials" action. +export const ADMIN_USER = 'admin' +export const ADMIN_PASSWORD = 'Whatever!' + +// Optional-config defaults (see the "Configure Platform" action). +export const DEFAULT_OPERATOR_RELAY = 'wss://relay.primal.net' +export const DEFAULT_PLATFORM_MODE = 'prod' +export const DEFAULT_COINGECKO_TOKEN_COINS = 'bitcoin' + +// 32 random bytes as a 64-char hex string — a secp256k1/Nostr secret key. The +// odds of an out-of-range key are ~1 in 2^128; if getPublicKey rejects it, +// init throws and StartOS retries with fresh entropy. +export function randomOperatorSecretHex(): string { + return utils.getDefaultString({ charset: '0-9,a-f', len: 64 }) +} + +// A strong replacement for the app's weak hardcoded PLATFORM_SESSION_SECRET. +export function randomSessionSecret(): string { + return utils.getDefaultString({ charset: 'a-z,A-Z,0-9', len: 48 }) +} diff --git a/startos/versions/index.ts b/startos/versions/index.ts new file mode 100644 index 0000000..06f2296 --- /dev/null +++ b/startos/versions/index.ts @@ -0,0 +1,7 @@ +import { VersionGraph } from '@start9labs/start-sdk' +import { v_1_0_0_0 } from './v1.0.0.0' + +export const versionGraph = VersionGraph.of({ + current: v_1_0_0_0, + other: [], +}) diff --git a/startos/versions/v1.0.0.0.ts b/startos/versions/v1.0.0.0.ts new file mode 100644 index 0000000..50bac33 --- /dev/null +++ b/startos/versions/v1.0.0.0.ts @@ -0,0 +1,17 @@ +import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk' + +export const v_1_0_0_0 = VersionInfo.of({ + version: '1.0.0:0', + releaseNotes: { + en_US: `Initial StartOS package for SpacesOps (upstream v1.0.0). +- Runs the SpacesOps web platform (Express + SQLite) from the prebuilt spacesops/spacesops:v1.0.0 image on x86_64 and aarch64. +- Depends on the Spaces service and auto-connects to its spaced JSON-RPC at spaces.startos:7225 by reading the Spaces RPC credentials from the mounted Spaces volume. +- Generates a Nostr operator keypair and a strong session secret on first install. "Show Operator Credentials" and "Import Operator Key" actions manage the keypair. +- "Show Admin Credentials" surfaces the fixed, well-known admin login baked into the image, with a warning to keep the service private. +- "Configure Platform" optionally sets the Nostr relay, theme mode, CoinGecko pricing, and SUBSD backend.`, + }, + migrations: { + up: async ({ effects }) => {}, + down: IMPOSSIBLE, + }, +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a2945a5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "include": ["startos/**/*.ts", "node_modules/**/startos"], + "compilerOptions": { + "target": "ES2018", + "module": "CommonJS", + "moduleResolution": "node", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true + } +}