diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..5dfbdc6
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,7 @@
+.git
+.gitmodules
+node_modules
+javascript
+*.s9pk
+readstr/node_modules
+readstr/.next
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..40c292d
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,20 @@
+name: Build
+
+on:
+ workflow_dispatch:
+ pull_request:
+ paths-ignore: ['*.md']
+ branches: ['main']
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ build:
+ if: github.event.pull_request.draft == false
+ uses: start9labs/shared-workflows/.github/workflows/build.yml@master
+ with:
+ FREE_DISK_SPACE: true
+ secrets:
+ DEV_KEY: ${{ secrets.DEV_KEY }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..75c6036
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,19 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - 'v*.*'
+
+jobs:
+ release:
+ uses: start9labs/shared-workflows/.github/workflows/release.yml@master
+ with:
+ RELEASE_REGISTRY: ${{ vars.RELEASE_REGISTRY }}
+ S3_S9PKS_BASE_URL: ${{ vars.S3_S9PKS_BASE_URL }}
+ secrets:
+ DEV_KEY: ${{ secrets.DEV_KEY }}
+ S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
+ S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
+ permissions:
+ contents: write
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7faf44f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+javascript/
+*.s9pk
+.DS_Store
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..c39ada0
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,4 @@
+[submodule "readstr"]
+ path = readstr
+ url = https://github.com/privkeyio/readstr.git
+ branch = main
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..b90bfda
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,41 @@
+FROM node:22-alpine AS deps
+RUN apk add --no-cache libc6-compat openssl
+WORKDIR /app
+COPY readstr/package.json readstr/package-lock.json* ./
+COPY readstr/prisma ./prisma
+RUN npm ci
+
+FROM node:22-alpine AS builder
+WORKDIR /app
+RUN apk add --no-cache openssl
+COPY --from=deps /app/node_modules ./node_modules
+COPY readstr/ .
+RUN npx prisma generate
+ENV NEXT_TELEMETRY_DISABLED=1
+RUN npm run build
+
+FROM node:22-alpine AS runner
+WORKDIR /app
+RUN apk add --no-cache openssl postgresql16 postgresql16-contrib su-exec
+
+ENV NODE_ENV=production
+ENV NEXT_TELEMETRY_DISABLED=1
+
+COPY --from=builder /app/public ./public
+COPY --from=builder /app/.next/standalone ./
+COPY --from=builder /app/.next/static ./.next/static
+COPY --from=builder /app/prisma ./prisma
+COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
+
+# Pin the Prisma CLI into the image so `prisma migrate deploy` resolves locally
+# at boot with no network access.
+RUN npm install --no-save prisma@5.22.0
+
+COPY docker_entrypoint.sh /usr/local/bin/docker_entrypoint.sh
+RUN chmod +x /usr/local/bin/docker_entrypoint.sh
+
+EXPOSE 3000
+ENV PORT=3000
+ENV HOSTNAME=0.0.0.0
+
+ENTRYPOINT ["/usr/local/bin/docker_entrypoint.sh"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..0bd9656
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 PrivKey LLC
+
+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..10d4360
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,3 @@
+ARCHES := x86 arm
+# overrides to s9pk.mk must precede the include statement
+include s9pk.mk
diff --git a/README.md b/README.md
index cdd0a55..3caa240 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,221 @@
-# readstr-startos
-Feed aggregator combining RSS feeds with Nostr long-form content (NIP-23) and videos for StartOS
+
+
+
+
+# Readstr on StartOS
+
+> **Upstream docs:**
+>
+> Everything not listed in this document should behave the same as upstream
+> Readstr. If a feature, setting, or behavior is not mentioned here, the
+> upstream documentation is accurate and fully applicable.
+
+[Readstr](https://github.com/privkeyio/readstr) is a self-hosted, Google Reader-style feed aggregator for RSS/Atom, Nostr, and video. This package runs its Next.js standalone server alongside a bundled PostgreSQL database, so your subscriptions and reading state live entirely on your own server. You sign in with your own Nostr key (NIP-07 or NIP-46), organize subscriptions with tags or categories, and sync them across devices over Nostr.
+
+---
+
+## 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)
+- [Configuration Management](#configuration-management)
+- [Network Access and Interfaces](#network-access-and-interfaces)
+- [Actions (StartOS UI)](#actions-startos-ui)
+- [Dependencies](#dependencies)
+- [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)
+- [Contributing](#contributing)
+- [Quick Reference for AI Consumers](#quick-reference-for-ai-consumers)
+
+---
+
+## Image and Container Runtime
+
+| Property | Value |
+|----------|-------|
+| Image | `readstr`, built from source via the root `Dockerfile` |
+| Source | `readstr` git submodule (upstream Next.js app + Prisma + tRPC) |
+| Build | Next.js standalone build (Node 22 Alpine) bundled with PostgreSQL 16 and `su-exec` on an Alpine runtime |
+| Architectures | x86_64, aarch64 |
+| Entrypoint | `/usr/local/bin/docker_entrypoint.sh`, which boots PostgreSQL on localhost, runs Prisma migrations, then starts `node server.js` |
+
+---
+
+## Volume and Data Layout
+
+| Volume | Mount Point | Purpose |
+|--------|-------------|---------|
+| `main` | `/data` | Bundled PostgreSQL database and StartOS settings |
+
+**Key paths on the `main` volume:**
+
+- `/data/postgres`: the bundled PostgreSQL data directory (`PGDATA`) holding all feeds, subscriptions, and reading state
+- `/data/start9/store.json`: StartOS persistent settings, namely the database password, default relays, and allowed hosts
+
+---
+
+## Installation and First-Run Flow
+
+| Step | Upstream | StartOS |
+|------|----------|---------|
+| Database | Manual (docker-compose PostgreSQL) | Auto-bundled, initialized at `/data/postgres` on first start |
+| DB password | User-supplied (`.env`) | Auto-generated internal secret (never shown) |
+| Migrations | Run manually | Run automatically by the entrypoint (`prisma migrate deploy`) |
+| Sign-in | Nostr key (NIP-07 / NIP-46) | Same |
+
+**First-run steps:**
+
+1. Open the **Web UI** from this service's page in StartOS.
+2. Click **Connect with Nostr** and authorize with a browser extension (NIP-07, e.g. nos2x or Alby) on desktop, or pair a remote signer (NIP-46, e.g. Amber) on mobile. Your npub is your identity, so there is no separate account.
+3. Use **Add Feed** to subscribe to RSS feeds, Nostr authors (npub or NIP-05), or YouTube/Rumble channels.
+4. If you reach this server at a non-default address, set **Allowed Hosts** in the **Configure** action so NIP-98 login is accepted.
+
+See [instructions.md](instructions.md) for the user-facing walkthrough.
+
+---
+
+## Configuration Management
+
+| StartOS-Managed | Upstream-Managed (in-app) |
+|-----------------|---------------------------|
+| Database password (auto-generated internal secret) | Feed subscriptions and tags/categories |
+| Default relays (Configure action) | Nostr identity (your key) |
+| Allowed hosts for NIP-98 login (Configure action) | Reading state |
+
+**Environment variables set by StartOS** (`startos/main.ts`):
+
+| Variable | Value | Purpose |
+|----------|-------|---------|
+| `NODE_ENV` | `production` | Next.js runtime mode |
+| `PORT` | `3000` | Web UI bind port |
+| `HOSTNAME` | `0.0.0.0` | Web UI bind address |
+| `PGDATA` | `/data/postgres` | Bundled PostgreSQL data dir (on the backed-up `main` volume) |
+| `DB_PASSWORD` | (auto-generated) | Password for the bundled PostgreSQL role |
+| `DEFAULT_RELAYS` | (Configure) | Nostr relays for long-form content and profiles, comma-separated |
+| `NIP98_ALLOWED_HOSTS` | (auto + Configure) | Hostnames allowed in NIP-98 auth: the interface's StartOS-assigned addresses (Tor, `.local`, LAN IP), plus any custom hosts from Configure |
+
+Default relays are `wss://relay.damus.io`, `wss://nos.lol`, and `wss://relay.nostr.band`. The Configure action accepts any number of `wss://` relays; saving restarts the service to apply.
+
+---
+
+## Network Access and Interfaces
+
+| Interface | Port | Protocol | Purpose |
+|-----------|------|----------|---------|
+| Web UI (`ui`) | 3000 | HTTP | Three-panel reader and NIP-98-authenticated app |
+
+The Web UI is unmasked and carries no StartOS-level auth, because Readstr authenticates you with your own Nostr key (NIP-07 / NIP-46) and NIP-98 verifies the request host (see Allowed Hosts).
+
+**Access methods:**
+
+- LAN IP with unique port
+- `.local` with unique port
+- Tor `.onion` address (if added)
+- Custom domains (if configured)
+
+---
+
+## Actions (StartOS UI)
+
+### Configure
+
+Set the Nostr relays and allowed hosts Readstr uses, then restart to apply.
+
+| Property | Value |
+|----------|-------|
+| Availability | Any status |
+| Visibility | Always visible |
+| Inputs | Default Relays, Allowed Hosts (optional) |
+| Outputs | Confirmation; restarts the service |
+
+---
+
+## Dependencies
+
+None.
+
+---
+
+## Backups and Restore
+
+**Included in backup:**
+
+- `main` volume, covering the bundled PostgreSQL data (`/data/postgres`) and `store.json`
+
+**Restore behavior:**
+
+- The database and its password are restored together, so your feeds, subscriptions, reading state, relays, and allowed hosts come back as-is, with no re-import or reconfiguration. Init regenerates the database password only on a fresh install, never on restore.
+
+---
+
+## Health Checks
+
+| Check | Display Name | Method | Messages |
+|-------|--------------|--------|----------|
+| `primary` | Web UI | Port-listening check on 3000 | "The Readstr web UI is ready" / "The Readstr web UI is not responding" |
+
+---
+
+## Limitations and Differences
+
+1. **Custom domains require Allowed Hosts.** Readstr verifies the host in the NIP-98 auth token. The package auto-allows every address StartOS assigns the interface (Tor `.onion`, `.local`, LAN IP), so Tor and LAN work out of the box; for a custom domain you added yourself, set it in **Allowed Hosts** in Configure or login at that address may be rejected.
+2. **Bundled single-node PostgreSQL.** The database runs inside the service container, not as a separate StartOS service, and is initialized and migrated automatically on first start.
+3. **Nostr sign-in required.** There is no separate account system; your npub is your identity.
+
+---
+
+## What Is Unchanged from Upstream
+
+- RSS/Atom feed aggregation in a three-panel reader
+- Nostr long-form content (NIP-23) alongside RSS
+- YouTube and Rumble video subscriptions
+- Tag- and category-based organization with cross-device sync over Nostr
+- Nostr sign-in via NIP-07 and NIP-46 remote signers
+- The Next.js standalone server, tRPC API, and Prisma schema
+
+---
+
+## Contributing
+
+This package targets **StartOS 0.4.x** and uses the StartOS TypeScript SDK.
+
+```sh
+git clone --recurse-submodules https://github.com/privkeyio/readstr-startos
+cd readstr-startos
+make # produces readstr_x86_64.s9pk and readstr_aarch64.s9pk
+make install # installs to the host in ~/.startos/config.yaml
+```
+
+The `readstr` git submodule pins the upstream source built into the image. CI builds the `.s9pk` on PRs to `main` (`.github/workflows/build.yml`, requires the `DEV_KEY` secret from `start-cli init-key`) and publishes on `v*` tags (`.github/workflows/release.yml`, plus the registry/S3 vars and secrets).
+
+---
+
+## Quick Reference for AI Consumers
+
+```yaml
+package_id: readstr
+architectures: [x86_64, aarch64]
+image: readstr (built from source; Next.js standalone + bundled PostgreSQL)
+volumes:
+ main: /data
+ports:
+ ui: 3000
+dependencies: none
+startos_managed_env_vars:
+ - NODE_ENV
+ - PORT
+ - HOSTNAME
+ - PGDATA
+ - DB_PASSWORD
+ - DEFAULT_RELAYS
+ - NIP98_ALLOWED_HOSTS
+actions:
+ - configure
+health_checks:
+ - primary: port_check 3000
+backup_volumes:
+ - main
+```
diff --git a/assets/README.md b/assets/README.md
new file mode 100644
index 0000000..8220df2
--- /dev/null
+++ b/assets/README.md
@@ -0,0 +1,3 @@
+# assets
+
+Static assets bundled into the s9pk (screenshots, etc.).
diff --git a/docker_entrypoint.sh b/docker_entrypoint.sh
new file mode 100644
index 0000000..a6dcf0b
--- /dev/null
+++ b/docker_entrypoint.sh
@@ -0,0 +1,86 @@
+#!/bin/sh
+set -e
+
+# The StartOS daemon launch does not inherit the image WORKDIR, so anchor the
+# cwd here: `npx prisma` needs ./prisma and `node server.js` lives in /app.
+cd /app
+
+PGDATA="${PGDATA:-/data/postgres}"
+export PGDATA
+PGHOST=127.0.0.1
+PGPORT=5432
+DB_NAME=readstr
+DB_USER=readstr
+DB_PASSWORD="${DB_PASSWORD:?DB_PASSWORD is required}"
+
+stop_postgres() {
+ su-exec postgres pg_ctl -D "$PGDATA" -m fast -w stop || true
+}
+cleanup() {
+ if [ -n "$APP_PID" ]; then
+ kill "$APP_PID" 2>/dev/null || true
+ wait "$APP_PID" 2>/dev/null || true
+ fi
+ stop_postgres
+}
+# Always stop postgres when the script exits, including on error. pg_ctl
+# daemonizes postgres detached from this script, so without this an early exit
+# would leave it running and the next launch would fail on the postmaster.pid
+# lock, looping forever.
+trap cleanup EXIT
+trap 'exit 143' TERM INT
+
+mkdir -p "$PGDATA"
+# Always re-assert ownership: on restore-from-backup the data dir already has a
+# PG_VERSION but may land owned by root, which would make pg_ctl start fail.
+chown -R postgres:postgres "$PGDATA"
+if [ ! -s "$PGDATA/PG_VERSION" ]; then
+ # Local socket uses peer auth (OS user postgres -> role postgres), so the
+ # admin commands below need no password; the app connects over TCP with scram.
+ su-exec postgres initdb -D "$PGDATA" -U postgres --auth-local=peer --auth-host=scram-sha-256 --encoding=UTF8
+fi
+
+su-exec postgres pg_ctl -D "$PGDATA" -w \
+ -o "-c listen_addresses='$PGHOST' -p $PGPORT -c unix_socket_directories='/tmp'" start
+
+psql() {
+ su-exec postgres psql -h /tmp -p "$PGPORT" -U postgres -v ON_ERROR_STOP=1 "$@"
+}
+
+# Pass the password as a psql variable via stdin: :'pw' is interpolated and
+# safely quoted by psql in stdin/-f mode (it is NOT processed in -c strings).
+# DB_USER/DB_NAME are fixed constants, quoted as identifiers.
+if ! psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'" | grep -q 1; then
+ psql -v pw="$DB_PASSWORD" <&2
+ exit 1
+ fi
+ echo "migrate deploy failed, retrying ($attempts/$max_attempts)..." >&2
+ sleep 2
+done
+
+node server.js &
+APP_PID=$!
+set +e
+wait "$APP_PID"
+APP_EXIT=$?
+set -e
+exit "$APP_EXIT"
diff --git a/icon.png b/icon.png
new file mode 100644
index 0000000..6d30789
Binary files /dev/null and b/icon.png differ
diff --git a/instructions.md b/instructions.md
new file mode 100644
index 0000000..842c6a1
--- /dev/null
+++ b/instructions.md
@@ -0,0 +1,26 @@
+# Readstr
+
+Readstr is a self-hosted, Google Reader-style feed aggregator. It brings RSS/Atom feeds together with Nostr long-form content (NIP-23) and video feeds from YouTube and Rumble in a clean, three-panel reading interface. Your subscriptions and reading state live on your own server in a bundled PostgreSQL database.
+
+## First-time setup
+
+1. **Open the Web UI.** Launch the interface from this service's page in StartOS.
+2. **Sign in with Nostr.** Click "Connect with Nostr" and authorize with a browser extension (NIP-07, e.g. nos2x or Alby) on desktop, or pair a remote signer (NIP-46, e.g. Amber) with a `bunker://` connection string on mobile. Your npub is your identity, so there is no separate account.
+3. **Add feeds.** Use "Add Feed" to subscribe to RSS feeds, Nostr authors (npub or NIP-05), or YouTube/Rumble channels.
+
+## Login on a custom address
+
+Readstr verifies the host in your NIP-98 auth token. Every address StartOS assigns this service (its `.onion`, `.local`, and LAN IP) is allowed automatically, so login works over Tor and on your LAN with no setup. If you reach the server at a custom domain you added yourself, set it in **Allowed Hosts** in the **Configure** action (comma-separated, hostname only); otherwise login at that address may be rejected.
+
+## Configuration
+
+Use the **Configure** action to set:
+
+- **Default Relays:** the Nostr relays used to fetch long-form content and profiles. Defaults to `wss://relay.damus.io`, `wss://nos.lol`, and `wss://relay.nostr.band`.
+- **Allowed Hosts:** extra hostnames to accept for NIP-98 login, for custom domains you added yourself. The service's StartOS addresses (Tor, `.local`, LAN IP) are already allowed automatically.
+
+Saving the configuration restarts the service.
+
+## Data and backups
+
+All state, the PostgreSQL database and this package's settings, lives in the service's data volume and is captured by StartOS backups. Restoring a backup brings back your feeds, subscriptions, and reading state.
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..700f9b7
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,374 @@
+{
+ "name": "readstr-startos",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "readstr-startos",
+ "dependencies": {
+ "@start9labs/start-sdk": "1.5.3"
+ },
+ "devDependencies": {
+ "@types/node": "^22.19.0",
+ "@vercel/ncc": "^0.38.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/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.2.0",
+ "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz",
+ "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodable"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/@start9labs/start-sdk": {
+ "version": "1.5.3",
+ "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-1.5.3.tgz",
+ "integrity": "sha512-OyHe9J6hMvyA5ZavcLkxdVQvZcuTH9J9kagV6NDI83eAG/YpJFIq62gP/n/2PPNdHWwNSXVQmSwnsvsV8Gyg+A==",
+ "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.20.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz",
+ "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==",
+ "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/anynum": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
+ "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT"
+ },
+ "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/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.6.0",
+ "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.0.tgz",
+ "integrity": "sha512-e5y7RCLHKjemsgQ4eqGJtPyr10ILz25HO7flzxhTV8bgvd5yHx98DGtCAtbVW9f2TqnYI/gEVZd+vz7snrdPTw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/prettier": {
+ "version": "3.8.4",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz",
+ "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==",
+ "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.4.1",
+ "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz",
+ "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "anynum": "^1.0.1"
+ }
+ },
+ "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..c0674ac
--- /dev/null
+++ b/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "readstr-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.3"
+ },
+ "devDependencies": {
+ "@types/node": "^22.19.0",
+ "@vercel/ncc": "^0.38.4",
+ "prettier": "^3.6.2",
+ "typescript": "^5.9.3"
+ },
+ "prettier": {
+ "trailingComma": "all",
+ "tabWidth": 2,
+ "semi": false,
+ "singleQuote": true
+ }
+}
diff --git a/readstr b/readstr
new file mode 160000
index 0000000..7299c5a
--- /dev/null
+++ b/readstr
@@ -0,0 +1 @@
+Subproject commit 7299c5ae2011ced0156d0587f0160424f6f5d591
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/startos/actions/configure.ts b/startos/actions/configure.ts
new file mode 100644
index 0000000..80a0aa6
--- /dev/null
+++ b/startos/actions/configure.ts
@@ -0,0 +1,67 @@
+import { sdk } from '../sdk'
+import { i18n } from '../i18n'
+import { storeJson } from '../fileModels/store.json'
+import { defaultRelays } from '../utils'
+
+const { InputSpec, Value, List } = sdk
+
+const relayPattern = {
+ regex: '^wss://.+',
+ description: 'Relay URLs must start with wss://',
+}
+
+const inputSpec = InputSpec.of({
+ defaultRelays: Value.list(
+ List.text(
+ {
+ name: i18n('Default Relays'),
+ description: i18n(
+ 'Nostr relays used to fetch long-form content and profiles',
+ ),
+ default: defaultRelays,
+ },
+ { patterns: [relayPattern], placeholder: 'wss://nos.lol' },
+ ),
+ ),
+ nip98AllowedHosts: Value.text({
+ name: i18n('Allowed Hosts'),
+ description: i18n(
+ 'Comma-separated hostnames clients use to reach this server (required for login when not using the default host). Leave blank to use the app default.',
+ ),
+ required: false,
+ default: null,
+ }),
+})
+
+export const configure = sdk.Action.withInput(
+ 'configure',
+ async ({ effects }) => ({
+ name: i18n('Configure'),
+ description: i18n('Configure the Nostr relays and allowed hosts'),
+ warning: null,
+ allowedStatuses: 'any',
+ group: null,
+ visibility: 'enabled',
+ }),
+ inputSpec,
+ async ({ effects }) => {
+ const s = await storeJson.read().once()
+ return {
+ defaultRelays: s?.defaultRelays ?? defaultRelays,
+ nip98AllowedHosts: s?.nip98AllowedHosts || null,
+ }
+ },
+ async ({ effects, input }) => {
+ await storeJson.merge(effects, {
+ defaultRelays: input.defaultRelays,
+ nip98AllowedHosts: input.nip98AllowedHosts ?? '',
+ })
+ await effects.restart()
+ return {
+ version: '1' as const,
+ title: i18n('Configuration saved'),
+ message: i18n('The service is restarting with the new settings.'),
+ result: null,
+ }
+ },
+)
diff --git a/startos/actions/index.ts b/startos/actions/index.ts
new file mode 100644
index 0000000..99e118d
--- /dev/null
+++ b/startos/actions/index.ts
@@ -0,0 +1,4 @@
+import { sdk } from '../sdk'
+import { configure } from './configure'
+
+export const actions = sdk.Actions.of().addAction(configure)
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..7221c4b
--- /dev/null
+++ b/startos/dependencies.ts
@@ -0,0 +1,5 @@
+import { sdk } from './sdk'
+
+export const setDependencies = sdk.setupDependencies(
+ async ({ effects }) => ({}),
+)
diff --git a/startos/fileModels/store.json.ts b/startos/fileModels/store.json.ts
new file mode 100644
index 0000000..8518643
--- /dev/null
+++ b/startos/fileModels/store.json.ts
@@ -0,0 +1,22 @@
+import { z, FileHelper } from '@start9labs/start-sdk'
+import { sdk } from '../sdk'
+import { defaultRelays } from '../utils'
+
+// Package-internal state. Written only by our init + actions, so .const()
+// gives automatic restart-on-change.
+const storeConfigSchema = z.object({
+ // Generated once at install; password for the bundled PostgreSQL role.
+ dbPassword: z.string().catch(''),
+ // Default Nostr relays the app reads long-form content from.
+ defaultRelays: z.array(z.string()).catch(defaultRelays),
+ // Comma-separated hostnames allowed in NIP-98 auth tokens. Set this to the
+ // address you use to reach this server so login works. Empty = app default.
+ nip98AllowedHosts: z.string().catch(''),
+})
+
+export type StoreConfig = z.infer
+
+export const storeJson = FileHelper.json(
+ { base: sdk.volumes.main, subpath: 'start9/store.json' },
+ storeConfigSchema,
+)
diff --git a/startos/i18n/dictionaries/default.ts b/startos/i18n/dictionaries/default.ts
new file mode 100644
index 0000000..8b8f045
--- /dev/null
+++ b/startos/i18n/dictionaries/default.ts
@@ -0,0 +1,29 @@
+export const DEFAULT_LANG = 'en_US'
+
+const dict = {
+ // main.ts
+ 'Starting Readstr': 0,
+ 'Web UI': 1,
+ 'The Readstr web UI is ready': 2,
+ 'The Readstr web UI is not responding': 3,
+
+ // interfaces.ts
+ 'Browse your RSS feeds, Nostr long-form content, and video subscriptions': 4,
+
+ // actions/configure.ts
+ Configure: 5,
+ 'Configure the Nostr relays and allowed hosts': 6,
+ 'Default Relays': 7,
+ 'Nostr relays used to fetch long-form content and profiles': 8,
+ 'Allowed Hosts': 9,
+ 'Comma-separated hostnames clients use to reach this server (required for login when not using the default host). Leave blank to use the app default.': 10,
+ 'Configuration saved': 11,
+ 'The service is restarting with the new settings.': 12,
+} 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..a07ab38
--- /dev/null
+++ b/startos/init/index.ts
@@ -0,0 +1,18 @@
+import { sdk } from '../sdk'
+import { setDependencies } from '../dependencies'
+import { setInterfaces } from '../interfaces'
+import { versionGraph } from '../versions'
+import { actions } from '../actions'
+import { restoreInit } from '../backups'
+import { initializeService } from './initializeService'
+
+export const init = sdk.setupInit(
+ restoreInit,
+ versionGraph,
+ setInterfaces,
+ setDependencies,
+ actions,
+ initializeService,
+)
+
+export const uninit = sdk.setupUninit(versionGraph)
diff --git a/startos/init/initializeService.ts b/startos/init/initializeService.ts
new file mode 100644
index 0000000..afcad90
--- /dev/null
+++ b/startos/init/initializeService.ts
@@ -0,0 +1,19 @@
+import { utils } from '@start9labs/start-sdk'
+import { storeJson } from '../fileModels/store.json'
+import { sdk } from '../sdk'
+
+export const initializeService = sdk.setupOnInit(async (effects, kind) => {
+ // Seed defaults on every init so new schema defaults apply on upgrade.
+ await storeJson.merge(effects, {})
+
+ if (kind !== 'install') return
+
+ // Generate the PostgreSQL password once at install. It lives in the
+ // (backed-up) main volume's store.json and is URL-safe for DATABASE_URL.
+ await storeJson.merge(effects, {
+ dbPassword: utils.getDefaultString({
+ charset: 'a-z,A-Z,0-9',
+ len: 32,
+ }),
+ })
+})
diff --git a/startos/interfaces.ts b/startos/interfaces.ts
new file mode 100644
index 0000000..3cacd48
--- /dev/null
+++ b/startos/interfaces.ts
@@ -0,0 +1,27 @@
+import { sdk } from './sdk'
+import { i18n } from './i18n'
+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(
+ 'Browse your RSS feeds, Nostr long-form content, and video subscriptions',
+ ),
+ 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..66296a9
--- /dev/null
+++ b/startos/main.ts
@@ -0,0 +1,71 @@
+import { storeJson } from './fileModels/store.json'
+import { i18n } from './i18n'
+import { sdk } from './sdk'
+import { uiPort } from './utils'
+
+export const main = sdk.setupMain(async ({ effects }) => {
+ console.info(i18n('Starting Readstr'))
+
+ const store = await storeJson.read().const(effects)
+ if (!store) throw new Error('no store.json')
+
+ // The image bundles PostgreSQL; the entrypoint boots it on localhost, runs
+ // prisma migrations, then starts the Next.js server. Both the database and
+ // the app data live under the mounted volume so backups capture everything.
+ // Readstr's NIP-98 auth binds each signed request to the host the browser
+ // used and rejects hosts not on its allow-list. Allow every address StartOS
+ // assigns this interface (.onion, .local, LAN IP) so login works over Tor and
+ // LAN out of the box, plus any custom hosts set via Configure. .const() reruns
+ // this (restarting the daemon) if the assigned addresses change later, e.g.
+ // when a Tor address is added.
+ const ui = await sdk.serviceInterface.getOwn(effects, 'ui').const()
+ const assignedHosts = (ui?.addressInfo?.hostnames ?? [])
+ .map((h) => h.hostname)
+ .filter(
+ (h) =>
+ !!h &&
+ !['localhost', '127.0.0.1', '::1'].includes(h) &&
+ !h.startsWith('fe80'),
+ )
+ const userHosts = (store.nip98AllowedHosts ?? '')
+ .split(',')
+ .map((h) => h.trim())
+ .filter(Boolean)
+ const allowedHosts = [...new Set([...assignedHosts, ...userHosts])]
+
+ const env: Record = {
+ NODE_ENV: 'production',
+ PORT: `${uiPort}`,
+ HOSTNAME: '0.0.0.0',
+ PGDATA: '/data/postgres',
+ DB_PASSWORD: store.dbPassword,
+ DEFAULT_RELAYS: store.defaultRelays.join(','),
+ }
+ if (allowedHosts.length) env.NIP98_ALLOWED_HOSTS = allowedHosts.join(',')
+
+ const subcontainer = await sdk.SubContainer.of(
+ effects,
+ { imageId: 'readstr' },
+ sdk.Mounts.of().mountVolume({
+ volumeId: 'main',
+ subpath: null,
+ mountpoint: '/data',
+ readonly: false,
+ }),
+ 'readstr',
+ )
+
+ return sdk.Daemons.of(effects).addDaemon('primary', {
+ subcontainer,
+ exec: { command: ['/usr/local/bin/docker_entrypoint.sh'], env },
+ ready: {
+ display: i18n('Web UI'),
+ fn: () =>
+ sdk.healthCheck.checkPortListening(effects, uiPort, {
+ successMessage: i18n('The Readstr web UI is ready'),
+ errorMessage: i18n('The Readstr web UI is not responding'),
+ }),
+ },
+ requires: [],
+ })
+})
diff --git a/startos/manifest/i18n.ts b/startos/manifest/i18n.ts
new file mode 100644
index 0000000..6ee67b6
--- /dev/null
+++ b/startos/manifest/i18n.ts
@@ -0,0 +1,8 @@
+export const short = {
+ en_US: 'Google Reader-style feed aggregator for RSS and Nostr',
+}
+
+export const long = {
+ en_US:
+ 'Readstr is a self-hosted feed aggregator that brings RSS/Atom feeds together with Nostr long-form content (NIP-23) and video feeds from YouTube and Rumble in a clean, three-panel reading interface. You sign in with your own Nostr key over NIP-07 or a NIP-46 remote signer, organize subscriptions with tags or categories, and sync them across devices over Nostr. Running it on your StartOS server gives you a private, always-on reader with its own bundled database, so your feeds and reading state stay on hardware you control.',
+}
diff --git a/startos/manifest/index.ts b/startos/manifest/index.ts
new file mode 100644
index 0000000..ea4bd28
--- /dev/null
+++ b/startos/manifest/index.ts
@@ -0,0 +1,34 @@
+import { setupManifest } from '@start9labs/start-sdk'
+import { long, short } from './i18n'
+
+export const manifest = setupManifest({
+ id: 'readstr',
+ title: 'Readstr',
+ license: 'MIT',
+ packageRepo: 'https://github.com/privkeyio/readstr-startos',
+ upstreamRepo: 'https://github.com/privkeyio/readstr',
+ marketingUrl: 'https://github.com/privkeyio/readstr',
+ donationUrl: null,
+ description: {
+ short,
+ long,
+ },
+ volumes: ['main'],
+ images: {
+ readstr: {
+ source: {
+ dockerBuild: {},
+ },
+ arch: ['x86_64', 'aarch64'],
+ },
+ },
+ alerts: {
+ install: null,
+ update: null,
+ uninstall: null,
+ restore: null,
+ start: null,
+ stop: null,
+ },
+ dependencies: {},
+})
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..97424b6
--- /dev/null
+++ b/startos/utils.ts
@@ -0,0 +1,7 @@
+export const uiPort = 3000
+
+export const defaultRelays = [
+ 'wss://relay.damus.io',
+ 'wss://nos.lol',
+ 'wss://relay.nostr.band',
+]
diff --git a/startos/versions/index.ts b/startos/versions/index.ts
new file mode 100644
index 0000000..509ffcf
--- /dev/null
+++ b/startos/versions/index.ts
@@ -0,0 +1,7 @@
+import { VersionGraph } from '@start9labs/start-sdk'
+import { v0_1_0_0 } from './v0.1.0_0'
+
+export const versionGraph = VersionGraph.of({
+ current: v0_1_0_0,
+ other: [],
+})
diff --git a/startos/versions/v0.1.0_0.ts b/startos/versions/v0.1.0_0.ts
new file mode 100644
index 0000000..0219326
--- /dev/null
+++ b/startos/versions/v0.1.0_0.ts
@@ -0,0 +1,12 @@
+import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
+
+export const v0_1_0_0 = VersionInfo.of({
+ version: '0.1.0:0',
+ releaseNotes: {
+ en_US: 'Initial release of Readstr for StartOS.',
+ },
+ 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
+ }
+}