# System API
URL: https://openship.io/docs/api/system.md

Self-hosted instance administration — onboarding, SSH servers, component install, tunnels, filesystem browse, team-mode migration, and whole-instance data transfer.

import { TypeTable } from 'fumadocs-ui/components/type-table';

The System API runs the machinery behind a self-hosted Openship box: first-run onboarding, the SSH
**servers** you deploy onto, installing the components those servers need (Docker, OpenResty, certbot…),
port-forward tunnels, browsing the host filesystem, migrating a single-user instance into team mode, and
exporting or importing the entire instance. In the dashboard this is spread across the onboarding wizard and
the **Settings → Servers** area; from the terminal it's [`openship server`](/docs/cli/self-host) and
[`openship system`](/docs/cli/self-host).

<Callout title="Self-hosted only">
This module is mounted only when the API is **not** running in cloud mode — in cloud mode the routes and
their controllers are never loaded (`node:fs`, admin-user creation, and SSH management don't exist in the
cloud runtime). Every path below is unavailable on Openship Cloud.
</Callout>

<Callout title="Base path & auth">
All paths are relative to your instance, under **`/api`** — e.g. `https://your-host/api/system/servers`.
Send a personal access token as a bearer header (`Authorization: Bearer <token>`), created with
[`openship token create`](/docs/api/tokens). The dashboard uses your session cookie instead. See the
[API overview](/docs/api) and [auth model](/docs/security/auth) for details.

Three route groups are exceptions: `/onboarding` and `/upgrade-to-auth` are **public** (they run before any
user or session exists), and the two `/setup` routes are public to the user session but guarded by
`internalAuth` — a shared token used only by the desktop app pushing config to its embedded API.
</Callout>

## Endpoints

| Method & path | Permission | What it does |
|---|---|---|
| `GET /api/system/onboarding` | `public` | First-run check — is any server configured yet? |
| `POST /api/system/onboarding` | `public` | First-run setup; persists settings and creates the first server. Returns 403 once configured. |
| `POST /api/system/setup` | `public` · `internalAuth` | Push instance config from the desktop app. |
| `GET /api/system/setup` | `public` · `internalAuth` | Read instance config for the desktop app. |
| `POST /api/system/upgrade-to-auth` | `public` | Promote a zero-auth instance to email/password login. |
| `GET /api/system/settings` | `settings:read` | Read instance settings. |
| `PATCH /api/system/settings` | `settings:write` | Update instance-level (non-SSH) settings. |
| `DELETE /api/system/settings` | `settings:admin` | Reset instance settings and remove all servers. |
| `GET /api/system/servers` | `server:list` | List servers in the active organization. |
| `GET /api/system/servers/:id` | `server:read` | Get one server (SSH secrets omitted). |
| `POST /api/system/servers` | `server:write` | Add an SSH server. |
| `PATCH /api/system/servers/:id` | `server:write` | Update an SSH server. |
| `DELETE /api/system/servers/:id` | `server:admin` | Delete an SSH server. |
| `GET /api/system/servers/:id/rate-limit` | `server:read` | Read the server's OpenResty rate-limit config. |
| `PATCH /api/system/servers/:id/rate-limit` | `server:write` | Update the OpenResty rate-limit config (handler requires admin). |
| `GET /api/system/servers/:id/tunnels` | `server:read` | List port-forward tunnels and live status (desktop only). |
| `POST /api/system/servers/:id/tunnels` | `server:write` | Save a port-forward config (desktop only). |
| `POST /api/system/servers/:id/tunnels/:tunnelId/start` | `server:write` | Open a tunnel. |
| `POST /api/system/servers/:id/tunnels/:tunnelId/stop` | `server:write` | Close a tunnel. |
| `DELETE /api/system/servers/:id/tunnels/:tunnelId` | `server:write` | Delete a tunnel config. |
| `POST /api/system/test-connection` | `server:write` | Test SSH credentials without saving them. |
| `POST /api/system/check` | `server:write` | Run component health checks against a saved server. |
| `POST /api/system/install` | `server:admin` | Install one component on a server. |
| `POST /api/system/remove` | `server:admin` | Remove one component from a server. |
| `POST /api/system/install/stream` | `server:admin` | Install multiple components with live SSE logs. |
| `GET /api/system/install/stream` | `server:read` | Re-attach to a running install session's SSE stream. |
| `GET /api/system/install/session` | `server:read` | Get the active (or a specific) install session's state. |
| `GET /api/system/monitor/stream` | `server:read` | Stream live CPU / memory / disk stats over SSE. |
| `GET /api/system/browse` | `settings:read` | List child directories of a path (folder picker). |
| `POST /api/system/migration/preflight` | `settings:admin` | Team-migration readiness check (read-only). |
| `POST /api/system/migration/start` | `settings:admin` | Migrate single-user → your own remote server. |
| `POST /api/system/migration/start-cloud` | `settings:admin` | Migrate → Openship Cloud. |
| `POST /api/system/migration/start-tunnel` | `settings:admin` | Expose this instance via an edge tunnel. |
| `POST /api/system/migration/switch-back` | `settings:admin` | Reverse a migration back to single-user. |
| `POST /api/system/data-transfer/export` | `settings:admin` | Export the whole instance (owner only). |
| `POST /api/system/data-transfer/import` | `settings:admin` | Import a whole-instance file, ≤500 MB (owner only). |

<Callout title="Server routes are org-scoped and IDOR-safe">
A `:id` that isn't a server in your active organization returns **404**, indistinguishable from a missing
one. The `server:*` permission is resolved per-server on top of the route tag.
</Callout>

## Onboarding & setup

`GET /api/system/onboarding` returns `{ configured: boolean }` — `true` once at least one server exists. The
`POST` variant runs the same logic as the internal `/setup` push but only while the instance is unconfigured
(it returns **403 "Instance already configured"** afterward), so first-run flows don't need a token.

```
POST /api/system/onboarding
```

<TypeTable
  type={{
    sshHost: { type: 'string', description: 'SSH host / IP of the target server. When present, a server row is created.' },
    serverName: { type: 'string', description: 'Display name for the server.' },
    sshPort: { type: 'number', description: 'SSH port.', default: '22' },
    sshUser: { type: 'string', description: 'SSH user.', default: 'root' },
    sshAuthMethod: { type: 'string', description: 'Auth method: password, key, or agent.' },
    sshPassword: { type: 'string', description: 'SSH password (encrypted at rest).' },
    sshKeyPath: { type: 'string', description: 'Path to a private key on the host.' },
    sshKeyPassphrase: { type: 'string', description: 'Private-key passphrase (encrypted at rest).' },
    sshJumpHost: { type: 'string', description: 'SSH jump / bastion host.' },
    sshArgs: { type: 'string', description: 'Extra raw ssh args.' },
    authMode: { type: '"none" | "local" | "cloud"', description: 'Instance auth mode.', default: 'none' },
    tunnelProvider: { type: 'string', description: 'Tunnel provider.' },
    tunnelToken: { type: 'string', description: 'Tunnel token.' },
    defaultBuildMode: { type: 'string', description: 'Default build mode for new deployments.', default: 'auto' },
    defaultRollbackWindow: { type: 'number', description: 'How many past releases to retain for rollback.' },
  }}
/>

```bash
openship system onboarding apply --ssh-host 203.0.113.10 --ssh-user root --ssh-auth-method agent
```

The `POST /api/system/setup` route accepts the same body but is reserved for the desktop app and requires the
`internalAuth` shared token rather than a user session; it can also target an existing server by passing
`serverId`.

## Instance settings

`GET /api/system/settings` returns the instance config (auth mode, tunnel provider, default build mode and
rollback window, invitation mail source, team mode, migration target). `PATCH` updates only the fields you
send. `DELETE` wipes instance settings **and** every server row.

```
PATCH /api/system/settings
```

<TypeTable
  type={{
    authMode: { type: '"none" | "local" | "cloud"', description: 'Instance auth mode. Switching to none on a non-desktop deployment is gated (see below).' },
    confirm: { type: 'string', description: 'Required when setting authMode to "none" on a non-desktop box — must be exactly "I-understand-no-auth".' },
    tunnelProvider: { type: 'string', description: 'Tunnel provider (send empty to clear).' },
    tunnelToken: { type: 'string', description: 'Tunnel token.' },
    defaultBuildMode: { type: 'string', description: 'Default build mode.', default: 'auto' },
    defaultRollbackWindow: { type: 'number', description: 'Releases retained for rollback.' },
    invitationMailSource: { type: '"platform" | "cloud"', description: 'Where team-invite emails are sent from.' },
  }}
/>

```bash
curl -X PATCH https://your-host/api/system/settings \
  -H "Authorization: Bearer $OPENSHIP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"defaultBuildMode":"docker"}'
```

```bash
# Same thing from the CLI:
openship system settings set --default-build-mode docker
```

<Callout title="Zero-auth is double-gated" type="warn">
Setting `authMode: "none"` on anything other than a desktop deployment means anyone who can reach the API acts
as admin. The operator must **both** start the process with `OPENSHIP_ALLOW_ZERO_AUTH=true` (else **403**) and
send `confirm: "I-understand-no-auth"` in the body (else **400**).
</Callout>

## Promote zero-auth to a real login

`POST /api/system/upgrade-to-auth` turns the synthetic zero-auth user into an email/password account and flips
`authMode` from `none` to `local`, keeping the same user id so existing projects, deployments, and audit rows
still resolve. It is only callable while `authMode === "none"` (otherwise **400**).

```
POST /api/system/upgrade-to-auth
```

<TypeTable
  type={{
    name: { type: 'string', description: 'Account display name (1–100 chars).', required: true },
    email: { type: 'string', description: 'Account email (valid address, ≤254 chars).', required: true },
    password: { type: 'string', description: 'Account password (8–128 chars).', required: true },
    useOwnMailServer: { type: 'boolean', description: 'Warm the self-hosted mail server so it can send auth emails.', default: 'false' },
  }}
/>

```bash
openship system upgrade-to-auth --name "Ada" --email ada@example.com
```

## Servers

A server is an SSH target you deploy onto. Responses never include SSH secrets — passwords and key passphrases
are encrypted at rest and only decrypted inside the SSH client. See the
[custom servers guide](/docs/guides/custom-servers).

```
POST /api/system/servers
```

<TypeTable
  type={{
    sshHost: { type: 'string', description: 'SSH host / IP.', required: true },
    name: { type: 'string', description: 'Display name.' },
    sshPort: { type: 'number', description: 'SSH port.', default: '22' },
    sshUser: { type: 'string', description: 'SSH user.', default: 'root' },
    sshAuthMethod: { type: 'string', description: 'Auth method: password, key, or agent.' },
    sshPassword: { type: 'string', description: 'SSH password (encrypted at rest).' },
    sshKeyPath: { type: 'string', description: 'Path to a private key on the host.' },
    sshKeyPassphrase: { type: 'string', description: 'Private-key passphrase (encrypted at rest).' },
    sshJumpHost: { type: 'string', description: 'SSH jump / bastion host.' },
    sshArgs: { type: 'string', description: 'Extra raw ssh args.' },
  }}
/>

`PATCH /api/system/servers/:id` takes the same fields, all optional — only the keys you send are changed.

```bash
curl -X POST https://your-host/api/system/servers \
  -H "Authorization: Bearer $OPENSHIP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"sshHost":"203.0.113.10","sshUser":"root","sshAuthMethod":"agent"}'
```

```bash
# Same thing from the CLI:
openship server add --host 203.0.113.10 --user root --auth-method agent
```

## Server health check & install

Test a connection before saving, check what a saved server has installed, then install or remove components.
These routes identify the server by `serverId` in the **body** (not a URL param), and each handler runs its
own per-server `server:admin` (or `server:read`) check.

`POST /api/system/test-connection` opens an ephemeral SSH connection from body credentials and returns
`{ ok, message }` — nothing is persisted. It requires the caller be an org owner/admin.

```
POST /api/system/check
```

<TypeTable
  type={{
    serverId: { type: 'string', description: 'The saved server to check.', required: true },
    components: { type: 'string[]', description: 'Limit the check to these component names (validated against an allowlist). Omit to check the required + detected infrastructure components.' },
  }}
/>

Returns `{ components, ready, missing }`.

```
POST /api/system/install
```

<TypeTable
  type={{
    serverId: { type: 'string', description: 'The saved server to install onto.', required: true },
    component: { type: 'string', description: 'A single installable component name (e.g. docker, openresty, certbot). Validated against an allowlist.', required: true },
    config: { type: 'object', description: 'Optional installer-specific configuration.', default: '{}' },
  }}
/>

`POST /api/system/remove` mirrors `install` for a single removable component. `POST /api/system/install/stream`
takes `{ serverId, components: string[], config? }` and returns a **Server-Sent Events** stream
(`progress`, `log`, `complete`, `end`); it returns **409 `install_in_progress`** if a session is already
running.

```bash
# One-shot install:
openship server install <serverId> --component docker

# Stream logs live (attaches to POST /api/system/install/stream):
openship server install <serverId> --component docker --component openresty --follow
```

<Callout title="SSE, not JSON" type="info">
`install/stream` and `monitor/stream` are event streams. Read `GET /api/system/install/session` to poll a
running session's state instead, or `GET /api/system/install/stream?id=<sessionId>` to re-attach after a page
reload.
</Callout>

`GET /api/system/monitor/stream?serverId=<id>` emits a `stats` event every few seconds with CPU %, memory,
disk, uptime, and load average gathered over SSH — `openship server monitor <serverId>`.

## Per-server rate limiting

Read or update the OpenResty request rate limit for a server. OpenResty on the target server is the source of
truth; the API parses and rewrites its `ratelimit.conf`.

```
PATCH /api/system/servers/:id/rate-limit
```

<TypeTable
  type={{
    rps: { type: 'number', description: 'Requests per second (0 removes the limit). Floored to an integer.' },
    burst: { type: 'number', description: 'Burst allowance above rps.' },
    whitelist: { type: 'string[]', description: 'CIDR ranges exempt from the limit; invalid entries are dropped.' },
  }}
/>

```bash
openship server rate-limit <serverId> --rps 50 --burst 100
```

## Port-forward tunnels

Desktop-only. Forward a remote server port to `localhost` on the operator's machine, VS Code style. Every
handler additionally asserts desktop mode; on a non-desktop instance they refuse.

```
POST /api/system/servers/:id/tunnels
```

<TypeTable
  type={{
    remotePort: { type: 'number', description: 'Remote port to forward (1–65535).', required: true },
    remoteHost: { type: 'string', description: 'Remote host to bind on the server side.', default: '127.0.0.1' },
    localPort: { type: 'number', description: 'Preferred local port; 0 lets the OS choose. Omit for auto.' },
    autoStart: { type: 'boolean', description: 'Reopen this tunnel automatically on desktop startup.', default: 'false' },
  }}
/>

Start / stop / delete a saved tunnel with `POST .../tunnels/:tunnelId/start`, `.../stop`, and
`DELETE .../tunnels/:tunnelId`. A started tunnel returns the actually-bound `localPort` and a
`http://localhost:<port>` URL.

## Browse the host filesystem

`GET /api/system/browse?path=<dir>` lists child directories of a path (defaults to the host's home
directory), flagging which look like deployable projects. Used by the "deploy a local folder" picker.

```bash
openship system browse /srv/apps
```

Returns `{ path, directories: [{ name, path, isProject }] }` — projects first, then alphabetical. Non-directory
paths return **400**, missing paths **404**.

## Team-mode migration

Move a single-user instance into team mode along one of three paths, or reverse it. All four routes require
org admin/owner and refuse (**409**) unless `teamMode === "single_user"`. See the
[cloud connect / migration guide](/docs/guides/cloud-connect-migration).

Preflight and start (Path A — your own server) share a body:

```
POST /api/system/migration/start
```

<TypeTable
  type={{
    serverId: { type: 'string', description: 'The saved server to migrate onto.', required: true },
    domain: { type: '{ kind: "custom", hostname } | { kind: "free", slug }', description: 'Where the migrated instance will be reachable — a custom hostname or a free subdomain slug.', required: true },
  }}
/>

- `POST /api/system/migration/start-cloud` — Path B, migrate to Openship Cloud. Body: `{ allowNonEmptyTarget?: boolean }`.
- `POST /api/system/migration/start-tunnel` — Path C, expose via an edge tunnel. Body: `{ slug: string }` (required).
- `POST /api/system/migration/switch-back` — reverse any of them. Body: `{ abandonRemote?: boolean }`.

```bash
# Readiness check first, then migrate onto your server:
openship system migration preflight --server-id <id> --hostname app.example.com
openship system migration start --server-id <id> --hostname app.example.com
```

<Callout title="Switch-back cuts teammates off" type="warn">
Reversing to single-user mode revokes teammate access. The remote keeps a copy for a 30-day grace period so
you can recover if you change your mind.
</Callout>

## Whole-instance data transfer

Export or import the **entire** database — every organization's data. Both routes are **owner-only**
(`requireRole("owner")` on top of the `settings:admin` tag), because the tag alone would also admit admins and
members.

```
POST /api/system/data-transfer/export
```

<TypeTable
  type={{
    passphrase: { type: 'string', description: 'Seal secrets (SSH keys, tokens) under this passphrase. Omit to export without secrets.' },
  }}
/>

```
POST /api/system/data-transfer/import
```

<TypeTable
  type={{
    file: { type: 'object', description: 'A DataTransferFile produced by export.', required: true },
    passphrase: { type: 'string', description: 'The passphrase used at export time, if secrets were sealed.' },
    mode: { type: '"wipe" | "merge"', description: 'wipe replaces the current data; merge keeps existing rows.', default: 'wipe' },
  }}
/>

```bash
openship system data-transfer export --passphrase "$SECRET" --out instance.json
openship system data-transfer import --file instance.json --passphrase "$SECRET" --mode wipe
```

<Callout title="Import limits & errors" type="error">
The import body is capped at **500 MB** (**413 `PAYLOAD_TOO_LARGE`** above it). A wrong or missing passphrase
or a malformed file returns **400**; primary-key collisions in `merge` mode return **409 `PK_COLLISION`** (use
`wipe`, or clear the conflicting data first); a concurrent migration/import returns **503 `BUSY`**.
</Callout>
