Build SpacesOps StartOS package (v1.0.0:0)

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 10:28:18 -04:00
co-authored by Claude Opus 4.7
parent 1e33944be4
commit 35c1013520
38 changed files with 2071 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
*.s9pk
startos/*.js
node_modules/
.DS_Store
.vscode/
docker-images
javascript
ncc-cache
NOTES.md
+58
View File
@@ -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:<new version>`.
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`).
+21
View File
@@ -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.
+4
View File
@@ -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
+207
View File
@@ -0,0 +1,207 @@
<p align="center">
<img src="icon.svg" alt="SpacesOps" width="180" />
</p>
# SpacesOps on StartOS
> **Upstream repo:** <https://git.spacesops.com/spacesops/spacesops>
>
> 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
```
+8
View File
@@ -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`).
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

+53
View File
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="100mm"
height="100mm"
viewBox="0 0 100 100"
version="1.1"
id="svg1"
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
sodipodi:docname="spacesops.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="0.5"
inkscape:cx="365"
inkscape:cy="260"
inkscape:window-width="1312"
inkscape:window-height="995"
inkscape:window-x="550"
inkscape:window-y="115"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:139.331px;font-family:Outfit;-inkscape-font-specification:'Outfit, @wght=400';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;font-variation-settings:'wght' 400;fill:#903369;fill-opacity:1;stroke:none;stroke-width:19.7477;stroke-linecap:round;stroke-miterlimit:3;paint-order:fill markers stroke"
x="-1.9590863"
y="82.934502"
id="text1"><tspan
sodipodi:role="line"
id="tspan1"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:139.331px;font-family:Outfit;-inkscape-font-specification:'Outfit, @wght=400';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;font-variation-settings:'wght' 400;fill:#903369;fill-opacity:1;stroke:none;stroke-width:19.7477"
x="-1.9590863"
y="82.934502">@</tspan></text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+53
View File
@@ -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.
+514
View File
@@ -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"
}
}
}
}
+24
View File
@@ -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
}
}
+141
View File
@@ -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 <main>/.git/worktrees/<name> 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
+53
View File
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="100mm"
height="100mm"
viewBox="0 0 100 100"
version="1.1"
id="svg1"
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
sodipodi:docname="spacesops.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="0.5"
inkscape:cx="365"
inkscape:cy="260"
inkscape:window-width="1312"
inkscape:window-height="995"
inkscape:window-x="550"
inkscape:window-y="115"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:139.331px;font-family:Outfit;-inkscape-font-specification:'Outfit, @wght=400';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;font-variation-settings:'wght' 400;fill:#903369;fill-opacity:1;stroke:none;stroke-width:19.7477;stroke-linecap:round;stroke-miterlimit:3;paint-order:fill markers stroke"
x="-1.9590863"
y="82.934502"
id="text1"><tspan
sodipodi:role="line"
id="tspan1"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:139.331px;font-family:Outfit;-inkscape-font-specification:'Outfit, @wght=400';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;font-variation-settings:'wght' 400;fill:#903369;fill-opacity:1;stroke:none;stroke-width:19.7477"
x="-1.9590863"
y="82.934502">@</tspan></text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

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