Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,20 @@ name: Build
on:
workflow_dispatch:
pull_request:
paths-ignore: ['*.md']
types: [opened, synchronize, reopened, ready_for_review]
branches: ['master']
paths-ignore: ['*.md']

permissions: {}

concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
group: package-build-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
build:
if: github.event.pull_request.draft == false
if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
permissions:
contents: read
uses: Start9Labs/start-technologies/.github/workflows/build.yml@master
# No DEV_KEY — a PR build doesn't publish, so it doesn't need the signing key.
18 changes: 18 additions & 0 deletions .github/workflows/pr-retarget.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: Retarget Build

on:
pull_request:
types: [edited]

permissions: {}

concurrency:
group: package-build-${{ github.event.changes.base && github.event.pull_request.number || format('metadata-{0}', github.event.pull_request.number) }}
cancel-in-progress: true

jobs:
build:
if: github.event.changes.base && github.event.pull_request.draft == false
permissions:
contents: read
uses: Start9Labs/start-technologies/.github/workflows/build.yml@master
8 changes: 8 additions & 0 deletions .github/workflows/syncNext.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ name: Sync next
# Carries every change that lands on the base branch onto the paired `next`
# iteration branch, so `next` never drifts behind what has already shipped.
# `next` is created on the first run if the repo does not have one yet.
#
# List every base branch this package maintains. Most packages have one; the
# multi-branch packages list each major line or flavor (e.g. 28.x, 29.x). A
# package whose default branch is `main` must say `main` here — a workflow
# pointed at a branch the repo does not use never runs.
#
# No paths-ignore: a docs-only commit on the base still has to reach `next`,
# or the next merge back re-introduces the stale copy.
on:
push:
branches: ['master']
Expand Down
1 change: 1 addition & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"@start9labs/start-sdk/prettier.config.json"
24 changes: 14 additions & 10 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,23 @@ Freshly scaffolded? Work the
guide page, not a file in this repo — read it, don't copy it in.

Keep `README.md` (technical reference for an AI support or administering agent) and
`instructions.md` (end-user docs) in sync with your changes.
`instructions.md` (end-user docs) in sync with your changes. This file restates neither:
whoever changes the package has both, so it carries only what they don't — repo mechanics,
a change that looks right and is not, where the next thing gets added, a naming trap, a
build or test invocation particular to this repo.

**Fix a defect you spot rather than reporting it** — you have the package open and the
context to be sure. File **a GitHub issue on this repo** only when the call isn't yours to
make: you can't pin the cause down, two defensible fixes exist, or it's too large to ride on
the work in hand. An open issue is a report, not a queue — implement one when you're asked
to or when it's labelled `Approved`, then close it with `Closes #<n>`.

**Bugs and feature requests are GitHub issues on this repo** — file them as you find them.
Don't record work in the repo instead: no `TODO.md`, no `NOTES.md`, no `PLAN.md`. What you
verified, tried, and decided belongs in the commit message and the PR body.

## This repo

- **Host id and interface id are different, and the lookup takes the host id.** `startos/interfaces.ts` exports both; `sdk.host.getOwn` takes the **host** id, and you find the interface inside the returned host's bindings. Passing an interface id resolves to nothing, silently.
- **Three vantage points on the `main` volume, and they are all different.** `sdk.volumes.main.subpath(x)` resolves to `/media/startos/volumes/main/x` for the JS runtime; a subcontainer sees the same file at `/data/x` (`mountpoint` in `utils.ts`); an operator over SSH sees it at `/media/startos/data/package-data/volumes/synapse/data/main/x`. `utils.ts` keeps the import paths **volume-relative** for exactly this reason — prefix them yourself. Writing a JS-side path into `homeserver.yaml` (or a `/data` path into a `copyFile`) fails silently or at Synapse startup.
- **Port 8448 is deliberately unbound, and that is a decision, not an omission.** Federation rides 443 via the always-on `.well-known/matrix/server` delegation nginx serves. 8448 would only ever catch peers holding a cached "no well-known" result — a 1-hour window (`WELL_KNOWN_INVALID_CACHE_PERIOD`) that only arises when importing a homeserver whose old host published no delegation, which `instructions.md` step 1 closes by having the operator publish one days before the cutover. Adding the binding back means a second interface and a router forward for every user, to cover a case the procedure already handles.
- **The import flow is deliberately split across an action and a oneshot.** `import-homeserver` is `only-stopped`, so it has no Postgres to restore into; standing one up inside the action would mean duplicating the daemon's cluster initialization. It validates and stages, sets `store.json.pendingImport`, and the `restore-import` oneshot — ordered before the `synapse` daemon — runs `pg_restore`. Don't "simplify" the restore back into the action.
- **`fileModels/importedHomeserver.yml.ts` has no `.catch()` anywhere, on purpose.** It is the one file model here that must fail rather than heal: defaulting a missing `macaroon_secret_key` would mint a fresh one and silently log out every user on the homeserver being imported.
- **`log_config` is an enforced `z.literal`, not a `.catch()` default, and it has to stay that way.** `synapse generate` writes `<server_name>.log.config` and points `log_config` at it — a valid string, so a `.catch()` never fires. Softening it back to `z.string().catch(...)` silently re-breaks the log level: the package writes `homeserver.log.config` and Synapse reads a different file. That was live for as long as the log config existed, and the rotating file handler it declared had never run once.
- **`caches.cache_autotuning` is `clamp(os.totalmem() / 4, 1 GiB, 2 GiB)`, and both bounds are load-bearing.** Synapse evicts on whole-process allocated memory, not cache size, so the threshold must clear real usage: the upstream playbook's `memtotal/16` target lands below the ~815 MiB a real ten-user homeserver holds, which is why the divisor is looser. The ceiling matters for the opposite reason — an uncapped fraction puts the threshold out of reach on a big box (8 GiB on a 32 GiB machine) and the guard never fires. Don't restore the playbook's divisor or drop either bound without re-measuring. The SDK exposes no RAM effect, which is why this reaches for a Node builtin.
- **Dependent bridge services register appservices via `ensureAppserviceRegistration`** (exported from `startos/public.ts`), which mounts Synapse's volume read-only and creates a `register-appservice` task on Synapse. Treat that export and the appservice action inputs as a small API for dependents.
- **`sdk.host.getOwn` takes the host id, not the interface id.** `startos/interfaces.ts` exports both; an interface id resolves to nothing, silently.
- **Keep the import paths in `utils.ts` volume-relative** and prefix them for whoever reads them: `sdk.volumes.main.subpath()` for the JS runtime, `mountpoint` (`/data`) inside a subcontainer. A path from the wrong side fails silently or at Synapse startup.
- **Don't add a `.catch()` to `fileModels/importedHomeserver.yml.ts` or soften `log_config` from its `z.literal`**, don't bind 8448, don't fold `restore-import` back into the import action, and don't drop either `cache_autotuning` bound. Each looks like a simplification; `README.md` says what each one breaks.
- **Other packages import this repo's code** — `ensureAppserviceRegistration` and its `AppserviceParams` from `startos/public.ts`, the host and interface ids from `startos/interfaces.ts`, and `manifest` — and `register-appservice`'s input is filled by their tasks. Renaming any of them breaks a dependent.
28 changes: 15 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ Five models. Only two are ordinary configuration; the rest exist for specific jo
| `appservices/<id>.yaml` | YAML | Yes, one per appservice | The appservice actions |
| `import/homeserver.yaml` | YAML | Read-only | You, when staging an import |

`import/homeserver.yaml` is read strictly: a file missing `server_name`, `macaroon_secret_key` or `form_secret` fails the import rather than defaulting one, since a fresh macaroon key would log out every user of the imported server.

Within `homeserver.yaml`:

**Enforced** — rewritten whenever the package writes: the whole database block (the bundled PostgreSQL on loopback), the listeners, the media store and pid paths, telemetry off, the key-server warning suppressed, and `log_config`.
Expand Down Expand Up @@ -139,14 +141,14 @@ Neither is masked.

## Installation and First-Run Flow

Install generates a Synapse configuration under a **placeholder server name** and raises a `critical` task to replace it. That placeholder is also the marker for "this homeserver has never been claimed", which is what the import action checks.
Install generates a Synapse configuration under a **placeholder server name** and raises a `critical` task to replace it. The task comes back whenever the chosen name is not one of the Homeserver interface's domains, and keeps Synapse from starting until that same domain is added back. The placeholder is also the marker for "this homeserver has never been claimed", which is what the import action checks.

Two mutually exclusive paths from there, and **both are only available before the first start**:

1. **Set Server Address/URL** — claim a fresh homeserver under the domain you will run it on, then complete the critical Set Admin Password task it raises.
2. **Import Existing Homeserver** — adopt a homeserver you run elsewhere, keeping its users, logins and history.

**The server name is permanent.** Matrix identity is `@user:server-name`, so changing it later orphans every account and every federated room; that is why both actions are `only-stopped` and why import disables itself with an explanation once a real name is set.
**The server name is permanent.** Matrix identity is `@user:server-name`, so changing it later orphans every account and every federated room; that is why both actions are `only-stopped`, why Set Server Address/URL refuses any other domain once a name is set, and why import disables itself with an explanation.

## Actions

Expand All @@ -156,7 +158,7 @@ Fourteen actions in four groups.

Both run only while the service is stopped, and both are effectively one-time.

- **Set Server Address/URL** is hidden — the install task is what surfaces it. It writes the server name and public base URL.
- **Set Server Address/URL** is hidden — its task is what surfaces it. It offers the Homeserver interface's domains and writes the server name and public base URL. The form preselects no address, so the permanent name is always a deliberate choice. Once a name is set it accepts only that name, fails on any other, and its warning names the server.
- **Import Existing Homeserver** adopts a staged homeserver: its configuration, signing key, database and media. **It cannot be undone**, and it replaces the empty homeserver created at install.
- **Staging happens on the volume, by you**, before running it — the action reads what it finds under `import/`.
- **The media store is rsynced to its final home rather than staged twice**, because it is far too large to copy through a staging directory.
Expand All @@ -165,7 +167,7 @@ Both run only while the service is stopped, and both are effectively one-time.

### Accounts — Set Admin Password, Get Access Token

- **Set Admin Password** works whether or not the service is running: the password is queued in `store.json` and applied by a oneshot once the homeserver answers. It carries a `warning`, so StartOS asks for confirmation first — the action generates a fresh password and restarts the homeserver, and the old password stops working.
- **Set Admin Password** works whether or not the service is running: the password is queued in `store.json` and applied by a oneshot once the homeserver answers. Once an admin password exists — after the action has run once, or after an import — StartOS asks for confirmation first, because the action generates a fresh password, restarts the homeserver, and the old password stops working. The first run, from the install task, needs none; `store.json`'s `adminPasswordSet` tracks which case applies, and an install from before it existed counts as set.
- **Get Access Token** returns a token for an account, and needs the service running.

### Settings — Config, Federation, Media, Registration, Rate Limits, Discoverability, Email/SMTP
Expand All @@ -184,18 +186,18 @@ Seven forms over `homeserver.yaml`, all available whether or not the service is

Appservices are how bridges and bots attach to a homeserver. Each is a registration file on the `main` volume with a pair of tokens.

- **Deleting one revokes that bridge's access**; the bridge stops working until it is registered again.
- **Deleting one revokes that bridge's access**; the bridge stops working until it is registered again. The form preselects no appservice.
- **These are also driven by other packages.** A dependent calls this package's exported helper, which mounts Synapse's volume read-only, compares the tokens, and raises a `critical` Register Appservice task here when they do not match. So a Register Appservice task you did not create yourself is a bridge asking to be connected.

## Tasks

Three tasks: two for fresh-server setup, and one requested by dependent bridges.

| Task | Severity | Raised when | Cleared when |
| ---------------------- | ---------- | --------------------------------------------------------- | --------------- |
| Set Server Address/URL | `critical` | At install | The action runs |
| Set Admin Password | `critical` | Set Server Address/URL completes | The action runs |
| Register Appservice | `critical` | A dependent package's tokens don't match its registration | The action runs |
| Task | Severity | Raised when | Cleared when |
| ---------------------- | ---------- | ----------------------------------------------------------------------------- | -------------------------------------------------- |
| Set Server Address/URL | `critical` | No server name is set, or its domain is missing from the Homeserver interface | The name is set and its domain is on the interface |
| Set Admin Password | `critical` | Set Server Address/URL completes | The action runs |
| Register Appservice | `critical` | A dependent package's tokens don't match its registration | The action runs |

The setup tasks block startup until the homeserver has an identity and a queued admin password. Register Appservice is raised by another package and re-raises whenever that package's tokens stop matching.

Expand Down Expand Up @@ -226,7 +228,7 @@ Mixed, with one deliberate exclusion.

## Limitations and Differences

1. **The server name is permanent.** Both setup actions are stopped-only, and import refuses once a real name is set.
1. **The server name is permanent.** Both setup actions are stopped-only, Set Server Address/URL refuses any other domain once a name is set, and import refuses once a real name is set. Removing the name's domain from the Homeserver interface blocks Synapse from starting until it is added back.
2. **Importing cannot be undone** and replaces the homeserver created at install.
3. **A staged import's database is restored on the next start**, not by the action itself.
4. **`import/` is never backed up.**
Expand Down Expand Up @@ -272,7 +274,7 @@ interfaces:
homeserver: { type: api, port: 80 }
admin: { type: ui, port: 8080 }
actions:
- set-server-name # Setup; only-stopped, hidden (surfaced by the install task)
- set-server-name # Setup; only-stopped, hidden (surfaced by its task); refuses a different name once set
- import-homeserver # Setup; only-stopped, self-hiding once claimed
- set-admin-password # Accounts
- get-access-token # Accounts; only-running
Expand All @@ -287,7 +289,7 @@ actions:
- list-appservices # App Services
- delete-appservice # App Services
tasks:
- { action: set-server-name, severity: critical }
- { action: set-server-name, severity: critical } # while no name is set or its domain is missing
- { action: set-admin-password, severity: critical }
- { action: register-appservice, severity: critical } # raised by dependents
health_checks:
Expand Down
2 changes: 1 addition & 1 deletion instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
Fresh-server setup presents two required steps in sequence: choose the server address, then create the admin account. Complete both before starting Synapse.

1. Add a public clearnet domain to the Homeserver interface. This is the domain your users will see in their Matrix IDs (e.g. `@you:matrix.example.com`).
2. Run the **Choose the permanent address/URL of your Synapse Matrix server** task and pick the domain. **This choice is permanent** — it cannot be changed once Synapse starts for the first time, so choose carefully.
2. Run the **Choose the permanent address/URL of your Synapse Matrix server** task and pick the domain. **This choice is permanent** — it can never be changed, so choose carefully. If that domain is ever removed from the Homeserver interface, StartOS asks for the address again and Synapse won't start until you add the same domain back; any other domain is refused.
3. Run the **Create a root admin user for your Synapse Matrix homeserver** task. A username (`admin`) and a random password are generated and shown once — copy and save the password before dismissing. If you lose it, run the **Set Admin Password** action later to set a new one.
4. Start Synapse. On first start, the admin user is created with that password.

Expand Down
Loading
Loading