# Kagi Remote Sync Server
This document is the implementation spec for Kagi's self-hosted remote sync
server.
The main goal is simple: a user can sync encrypted Kagi state without committing
`.kagi/` to Git. The server stores and transfers encrypted project state, but it
must never be able to decrypt env values.
## Decisions
- Server: built into this Rust binary as `kagi serve`.
- Packaging: keep one `kagi` binary for the first version; split CLI and server
by internal modules, not by repo or product.
- HTTP framework: Axum on Tokio.
- Database: SQLite through SQLx.
- HTTP client: Reqwest with rustls.
- Transport: every non-public request and response is encrypted by Kagi with
age, even when the URL is plain `http://`.
- Auth: project tokens for project-scoped operations; admin tokens for server
management and project approval.
- Admin token is generated automatically on first server startup and printed to
stdout. The admin stores it via `kagi remote login` or in `KAGI_ADMIN_TOKEN`
for CLI operations. After login, the remote URL is saved locally so admin
commands can omit `--remote`.
- There is no admission token. New projects go through a pending-request
workflow: a user sends a `project join` request, and an admin approves it.
- Project tokens are generated by the server and contain `remote`, `project_id`,
`server_fingerprint`, `token_id`, and capabilities.
- The server stores token hashes only. It never stores token plaintext.
- `.kagi/` is committed in Git mode and ignored in server mode.
- Rate limiting: per-IP rate limiting using `tower_governor` protects all
endpoints from brute-force attacks.
- Server code is gated behind a `server` Cargo feature. Users who only need the
CLI can compile without it, omitting Axum, SQLx, and all server handlers.
- Deployment mode: explicitly single-tenant. One server instance serves one team.
Do not use a single instance for unrelated tenants without additional isolation.
- Storage limits: each project is capped at 1000 files and 50 MB of encrypted
content to prevent a single project from exhausting disk space.
## Feature Flags
```text
server (default) — include the Axum server, SQLx migrations, and rate limiting
--no-default-features — CLI only: no serve, push, pull, status, project, or remote commands
```
Install CLI only (faster compile, smaller binary):
```bash
cargo install kagi-vault --no-default-features
```
Install full binary with server:
```bash
cargo install kagi-vault
```
Server-only dependencies (`axum`, `tower`, `tower-http`, `tower_governor`,
`sqlx`, `subtle`) are marked `optional = true` and pulled in only when the
`server` feature is enabled. `hmac` and `sha2` are shared because the CLI also
verifies response MACs.
## Recommended Rust Stack
Use current, popular Rust crates. Add them through `cargo add` during
implementation so the lockfile resolves the newest compatible patches.
```bash
cargo add tokio --features rt-multi-thread,macros,signal,net,time
cargo add axum
cargo add tower tower-http --features tower-http/trace,tower-http/timeout,tower-http/limit
cargo add tower_governor
cargo add sqlx --no-default-features --features runtime-tokio,sqlite,migrate,macros,json,time
cargo add reqwest --no-default-features --features json,rustls-tls
cargo add tracing tracing-subscriber
cargo add time url hmac sha2 subtle
```
Version targets verified on 2026-05-27:
- `axum = "0.8"` for HTTP routing and JSON handlers.
- `tokio = "1"` for async runtime.
- `sqlx = "0.9"` with SQLite and migrations.
- `reqwest = "0.13"` with `json` and `rustls-tls`.
- `tower_governor = "0.8"` for per-IP rate limiting.
Do not add a separate Node/Bun server. Do not add Postgres for the first
version. SQLite is enough for a lightweight self-hosted server and is easier to
backup.
## Binary Boundary
The first version should ship one command:
```bash
kagi
```
`kagi serve` starts the server, while commands such as `kagi init`, `kagi push`,
and `kagi pull` remain normal CLI commands. Do not create a separate user-facing
`kagi-server` package for the first version.
This keeps install and version management simple:
```bash
cargo install kagi-vault # full binary with server
cargo install kagi-vault --no-default-features # CLI only
kagi serve
kagi project join --remote http://127.0.0.1:8787
```
The `server` Cargo feature gates all server-only code. When disabled, the
following are completely omitted from compilation:
- `src/server/` module and all Axum routes
- `src/domain/sync/` module (envelope, project state, token, remote config)
- `src/infrastructure/remote_client.rs`
- `src/infrastructure/remote_envelope.rs`
- `src/infrastructure/remote_local.rs`
- `src/infrastructure/sqlite_remote.rs` (server-side repository)
- `kagi serve`, `push`, `pull`, `status`, `project`, `remote` CLI commands
- `axum`, `tower`, `tower-http`, `tower_governor`, `sqlx`, `subtle`
The code still needs a hard internal boundary:
- `cli` owns Clap arguments, terminal output, and command dispatch.
- `server` owns Axum routes, HTTP errors, server startup, request limits, and rate
limiting.
- `cli` must not call `server::routes` or depend on Axum types.
- `server` must not depend on `cli` formatting or Clap types.
- Shared request/response structs live under `domain/sync`.
- CLI-side HTTP code lives in `infrastructure/remote_client.rs`.
- Server-side SQLite code lives in `infrastructure/sqlite_remote.rs`.
If the server later becomes a larger product, add a second binary while keeping
shared code in the same crate workspace:
```text
src/bin/kagi.rs
src/bin/kagi-server.rs
```
Do not start there. The first implementation should prefer one installable
binary and clear module boundaries.
## Module Layout
Keep the existing clean-architecture split.
```text
src/domain/sync/
envelope.rs request/response envelope structs
project_state.rs ProjectState, ProjectFile, path validation
project_token.rs token payload, capabilities, ids
remote_config.rs sync settings and local metadata structs
src/application/remote_sync/
push.rs
pull.rs
status.rs
join.rs
tokens.rs
src/infrastructure/
remote_client.rs reqwest client and envelope exchange
remote_envelope.rs age transport encryption
remote_local.rs local token/revision/fingerprint storage
sqlite_remote.rs SQLx-backed server repository
src/server/
mod.rs server startup, rate limiting, body limits
routes.rs Axum handlers and router
state.rs AppState, token hashing, key management
errors.rs server error types and responses
```
The server may depend on infrastructure and application services. Domain structs
must not depend on Axum, SQLx, or filesystem paths.
## Commands
### Start Server
Requires the `server` feature (enabled by default).
```bash
kagi serve --db ./kagi.db --key-file ./server.key.json --bind 127.0.0.1:8787
```
Defaults:
- `--bind 127.0.0.1:8787`
- `--db $KAGI_HOME/server/kagi.db`
- `--key-file $KAGI_HOME/server/server.key.json`
- `--max-body 10mb`
`kagi serve` creates the database, runs migrations, creates the server transport
key if missing, and prints:
```text
kagi: server key fingerprint kgs_...
kagi: generated admin token: kagi_admin_v1_...
kagi: store this in KAGI_ADMIN_TOKEN env var or run:
kagi: kagi remote login --remote http://127.0.0.1:8787 --token <token>
kagi: listening on http://127.0.0.1:8787
```
On the first startup with a fresh database, the server generates a single admin
token, hashes it, stores the hash in `admin_tokens`, and prints the plaintext
token exactly once. The admin must save this token securely. On subsequent
starts, the server checks for an existing admin token and does not generate a
new one.
If `--bind 0.0.0.0:...` is used, print a warning when HTTPS is not configured.
Application-layer encryption still protects payloads, but HTTPS should still be
recommended for metadata and operational safety.
### Admin Token
The admin token is a bearer token with `capabilities: ["admin"]`.
Shape:
```text
kagi_admin_v1_<base64url-json-payload>.<base64url-secret>
```
Decoded payload:
```json
{
"version": 1,
"remote": "admin",
"project_id": "admin",
"token_id": "kat_x",
"server_fingerprint": "kgs_x",
"capabilities": ["admin"]
}
```
Rules:
- The server stores only a keyed hash of the full admin token string.
- The admin token is printed once on first startup and never again.
- The CLI **must** store the admin token in the **OS keychain** (macOS
Keychain, Windows Credential Manager, Linux GNOME/KDE Wallet).
- There is **no file fallback** for admin tokens. If the OS keychain is
unavailable, `kagi remote login` fails with a clear error.
- `KAGI_ADMIN_TOKEN` env var can be used as an override, but interactive login
should prefer the keychain.
- Admin tokens are hashed with the same `HMAC-SHA256(server_token_pepper, ...)`
mechanism as project tokens.
### Save Admin Token
```bash
kagi remote login --remote http://127.0.0.1:8787 --token kagi_admin_v1_...
```
Flow:
1. CLI fetches `GET /v1/server-key` to verify the remote and obtain its
fingerprint.
2. CLI writes the admin token to the OS keychain under the service
`dev.kagi.kagi` and account `admin:{fingerprint}`.
3. CLI prints confirmation.
After login, the remote URL is saved to `$KAGI_HOME/admins/{fingerprint}/remote.json`
so admin commands (`kagi project list`, `approve`, `del`) can omit `--remote`.
The admin token itself is read from the OS keychain automatically.
### Initialize Local Project
```bash
kagi init --nested --envs
```
This creates a local `.kagi/` directory with `kagi.json`, `access.json`, and
empty env stores. It does not interact with the server.
Server mode `.gitignore` entries:
```gitignore
.kagi/
.env
.env.*
!.env.example
```
### Request Project Registration
```bash
kagi project join --remote http://127.0.0.1:8787
```
Flow:
1. CLI fetches `GET /v1/server-key`.
2. CLI pins the expected server fingerprint.
3. CLI creates local member identity if needed.
4. CLI sends encrypted `create_project_request` to `/v1/projects/requests`.
5. Server stores the request in `project_requests` with `status = 'pending'`.
6. CLI saves sync config to `kagi.json` (`mode: "server"`, `remote: url`).
7. CLI prints: `requested project kgp_xxx, waiting for admin approval`.
The project does not exist on the server until an admin approves it.
### List and Manage Projects (Admin)
```bash
# Save admin token to OS keychain (one-time setup)
kagi remote login --remote http://127.0.0.1:8787 --token kagi_admin_v1_...
# List pending and active projects (remote is optional after login)
kagi project list
kagi project list --remote http://127.0.0.1:8787
# Approve a pending project request (remote is optional after login)
kagi project approve kgp_xxx
kagi project approve kgp_xxx --remote http://127.0.0.1:8787
# Delete a project (remote is optional after login)
kagi project del kgp_xxx
kagi project del kgp_xxx --remote http://127.0.0.1:8787
```
`kagi project approve` flow:
1. CLI sends encrypted request to `/v1/projects/requests/{id}/approve`.
2. Server authenticates the admin token.
3. Server looks up the pending request.
4. Server creates the project in `projects` table.
5. Server inserts the requester into `project_members` with `role = 'admin'`.
6. Server generates a full-capability project token for the requester.
7. Server stores the token hash in `project_tokens`.
8. Server deletes the request from `project_requests`.
9. Server returns the plaintext project token inside the encrypted response.
10. CLI prints success with the project token. The admin gives that token to the
requester once.
11. The requester runs `kagi pull <project-token>` to store the token locally
and bootstrap remote sync.
`kagi project del` can be called by either a server admin or the project's
admin (any member with `role = 'admin'` in `project_members`). Deleting a
project cascades to tokens, files, and members.
### Daily Sync
```bash
kagi push
kagi pull
kagi status
```
- `kagi push` uploads local encrypted project state.
- `kagi pull` downloads encrypted project state and writes `.kagi/` atomically.
- `kagi status` compares local and remote revisions.
A user can also pull with only a project token:
```bash
kagi pull <project-token>
```
The token contains the remote URL, project id, token id, server fingerprint, and
capabilities. No separate project id argument is needed.
Pulling encrypted state does not mean the user can decrypt env values. If the
local device is not an approved member, Kagi should print a short message asking
the user to run:
```bash
kagi member join --name alice-laptop
```
### Member Management
```bash
kagi member list
kagi member join --name alice-laptop
kagi member approve kgm_alice
kagi member del kgm_alice
```
`kagi member join` creates a local age identity and sends a `/join` request.
`kagi member approve` issues a token for the pending member.
`kagi member del` rotates the project key and revokes the member's token.
## IDs
Use NanoID-style random ids with stable prefixes:
```text
kgp_... project id
kgm_... member id
kgt_... project token id
kgr_... request id
kgs_... server key id / fingerprint
kat_... admin token id
```
`project_id` is public. It locates the project but does not grant access.
## Project Token
Token shape:
```text
kagi_proj_v1_<base64url-json-payload>.<base64url-secret>
```
Decoded payload:
```json
{
"version": 1,
"remote": "http://kagi.internal:8787",
"project_id": "kgp_x",
"token_id": "kgt_x",
"server_fingerprint": "kgs_x",
"capabilities": ["pull", "join"]
}
```
Rules:
- The secret part is 32 random bytes encoded as base64url without padding.
- The server generates project tokens.
- The server returns token plaintext only inside encrypted responses.
- The server stores only a keyed hash of the full token string.
- The server-side token pepper is stored in `server.key.json`, not SQLite.
- The payload capabilities are informational for the CLI. The server always
checks capabilities from SQLite.
- If the payload is edited, the full-token hash will not match.
- `KAGI_PROJECT_TOKEN` and `KAGI_PROJECT_TOKEN_FILE` are allowed for CI.
Capabilities:
```text
pull read encrypted ProjectState
join create or replace own pending join request
push upload ProjectState when base_revision matches
rotate issue/revoke project tokens and approve/remove members
```
Recommended token types:
```text
owner/member token: pull, join, push, rotate
onboarding token: pull, join
CI token: pull
```
## Local-only State
Do not store these in `.kagi/`:
- project token
- admin token
- local project key cache
- age identity private key
- pinned server fingerprint
- local revision
- pending remote token grants
- pending remote token revocations
- server private transport key
Store local sync metadata under the existing local Kagi data directory:
```text
$KAGI_HOME/projects/<project_id>/remote.json
```
Shape:
```json
{
"version": 1,
"project_id": "kgp_x",
"remote": "http://kagi.internal:8787",
"server_key_id": "kgs_x",
"server_fingerprint": "kgs_x",
"local_revision": 12,
"last_pulled_at": "2026-05-27T10:00:00Z",
"last_pushed_at": "2026-05-27T10:01:00Z"
}
```
Store admin remote config (saved automatically on `kagi remote login`):
```text
$KAGI_HOME/admins/<server_fingerprint>/remote.json
```
Shape:
```json
{
"version": 1,
"remote": "http://kagi.internal:8787",
"server_fingerprint": "kgs_x"
}
```
Store project token plaintext in the OS keychain when possible. On Linux without
a usable keychain, use the existing trusted-device local store and make it clear
that it is local to that machine.
Admin tokens are **never** stored in local files. They live exclusively in the
OS keychain under the account `admin:{server_fingerprint}`.
## `.kagi` State
Use a new schema version. Backward compatibility is not required.
`.kagi/kagi.json`:
```json
{
"version": "3",
"project_id": "kgp_x",
"services": {
"api/development": {
"file": "secrets/api/development.enc"
}
},
"settings": {
"nested": true,
"envs": ["development", "test", "production"],
"default_env": "development",
"sync": {
"mode": "server",
"remote": "http://kagi.internal:8787"
}
}
}
```
`.kagi/access.json`:
```json
{
"version": "3",
"members": [
{
"member_id": "kgm_owner",
"name": "alice",
"recipient": "age1...",
"status": "active",
"token_id": "kgt_owner",
"wrapped_key": "base64-age-message",
"wrapped_project_token": "base64-age-message"
}
]
}
```
`wrapped_project_token` is optional in Git mode. In server mode it should be
present for active members except legacy local-only owner bootstrap during the
first push.
## ProjectState
The server sync unit is `ProjectState`.
```json
{
"project_id": "kgp_x",
"revision": 12,
"kagi_json": "{...}",
"access_json": "{...}",
"files": [
{
"path": "secrets/api/development.enc",
"content": "{...}",
"sha256": "hex..."
}
]
}
```
Rules:
- `revision` is the server revision returned by the last successful pull or push.
- `kagi_json`, `access_json`, and `files[*].content` are JSON text blobs.
- The server stores these blobs but does not decrypt env values.
- The CLI validates full config/access/encrypted-store shape after pull.
- The server validates `project_id`, token, revision, body size, and path safety.
- File paths must be relative, must start with `secrets/`, must end with `.enc`,
and must not contain `..`, empty segments, backslashes, or absolute paths.
- `sha256` is optional for MVP but useful for integrity checks and status output.
## Application-layer Transport Encryption
HTTPS is recommended, but Kagi does not rely on HTTPS for request/response body
confidentiality. Every remote operation except `GET /v1/server-key` uses an age
envelope.
Server key file:
```json
{
"version": 1,
"server_key_id": "kgs_x",
"age_identity": "AGE-SECRET-KEY-...",
"token_pepper": "base64url-32-random-bytes",
"created_at": "2026-05-27T10:00:00Z"
}
```
Rules:
- Store the key file with `0600` permissions on Unix.
- Do not store raw `age_identity` or `token_pepper` in SQLite.
- Server fingerprint is derived from the public age recipient.
- CLI pins the expected fingerprint outside `.kagi/`.
- Non-local HTTP requires the fingerprint from the project token or
`--server-fingerprint`.
- Interactive trust-on-first-use is allowed only for localhost development.
- Noninteractive first use must provide an expected fingerprint.
Request envelope:
```json
{
"version": 1,
"request_id": "kgr_x",
"server_key_id": "kgs_x",
"response_recipient": "age1...",
"ciphertext": "base64-age-message"
}
```
Request plaintext:
```json
{
"version": 1,
"request_id": "kgr_x",
"issued_at": "2026-05-27T10:00:00Z",
"operation": "push",
"method": "POST",
"path": "/v1/projects/kgp_x/push",
"project_id": "kgp_x",
"token": "kagi_proj_v1_...",
"response_recipient": "age1..."
}
```
Response envelope:
```json
{
"version": 1,
"request_id": "kgr_x",
"mac": "base64url-hmac",
"ciphertext": "base64-age-message"
}
```
Success response plaintext:
```json
{
"ok": true,
"request_id": "kgr_x",
"data": {}
}
```
Error response plaintext:
```json
{
"ok": false,
"request_id": "kgr_x",
"error": {
"code": "conflict",
"message": "remote revision changed; run kagi pull first"
}
}
```
Checks:
- Server checks `request_id` in envelope and plaintext match.
- Server checks `method`, `path`, `operation`, and `project_id` after decrypting.
- Server checks `response_recipient` in the encrypted plaintext matches the
envelope before encrypting a response to it.
- Server rejects `issued_at` outside a small clock window, for example 5 minutes.
- CLI rejects responses where `request_id` does not match the request.
- CLI verifies the response `mac` before trusting decrypted data for all
token-authenticated operations.
- CLI verifies the decrypted response `request_id` before using `data`.
- CLI rejects pulled revisions older than local revision unless a reset command is
added later.
- Token plaintext is never placed in HTTP headers, query strings, or unencrypted
bodies.
Public errors are allowed only before decryption, for example malformed JSON,
unknown `server_key_id`, or body too large.
Response MAC input:
```text
## Token Hashing
Project tokens and admin tokens are random high-entropy bearer tokens, so use a
keyed hash instead of an expensive password hash.
Hash input:
```text
HMAC-SHA256(server_token_pepper, full_token_string)
```
Store:
```text
kh1:<base64url-hmac>
```
Rules:
- Compare hashes with constant-time equality from `subtle`.
- Never log token plaintext.
- Never include token plaintext in errors.
- Rotate `server_token_pepper` only with a migration plan, because existing token
hashes depend on it.
## Rate Limiting
The server uses `tower_governor` with per-IP rate limiting to protect against
brute-force attacks on all endpoints.
Configuration:
```text
per_second: 2
burst_size: 30
```
This allows bursts of up to 30 requests and then replenishes one element every
500ms, based on peer IP address. In testing, a separate generous configuration
is used to avoid interfering with test execution.
When rate limited, the server returns `429 Too Many Requests` with optional
`x-ratelimit-*` headers.
## SQLite Storage
Use SQLx migrations under `migrations/`.
Connection setup:
```sql
PRAGMA foreign_keys = ON;
PRAGMA journal_mode = WAL;
PRAGMA synchronous = FULL;
PRAGMA busy_timeout = 5000;
```
Use a small pool:
```text
max_connections = 5
min_connections = 1
acquire_timeout = 5s
```
Schema:
```sql
create table schema_migrations (
version integer primary key,
applied_at text not null
);
create table server_keys (
server_key_id text primary key,
public_recipient text not null,
fingerprint text not null,
active integer not null,
created_at text not null
);
create table projects (
project_id text primary key,
revision integer not null,
state_hash text,
created_at text not null,
updated_at text not null
);
create table project_tokens (
project_id text not null,
token_id text not null,
token_hash text not null,
capabilities_json text not null,
member_id text,
status text not null,
created_at text not null,
activated_at text,
revoked_at text,
last_used_at text,
primary key (project_id, token_id),
foreign key (project_id) references projects(project_id) on delete cascade
);
create table project_files (
project_id text not null,
path text not null,
content text not null,
sha256 text,
updated_at text not null,
primary key (project_id, path),
foreign key (project_id) references projects(project_id) on delete cascade
);
create table join_requests (
project_id text not null,
member_id text not null,
request_token_id text not null,
name text not null,
normalized_name text not null,
recipient text not null,
status text not null,
created_at text not null,
updated_at text not null,
primary key (project_id, member_id),
foreign key (project_id) references projects(project_id) on delete cascade
);
create unique index join_requests_pending_name_unique
on join_requests(project_id, normalized_name)
where status = 'pending';
create table admin_tokens (
token_id text primary key,
token_hash text not null,
capabilities_json text not null,
status text not null default 'active',
created_at text not null,
last_used_at text
);
create table project_requests (
project_id text primary key,
requester_member_id text not null,
requester_name text not null,
requester_recipient text not null,
kagi_json text,
status text not null default 'pending',
created_at text not null,
updated_at text not null
);
create index idx_project_requests_status on project_requests(status);
create table project_members (
project_id text not null,
member_id text not null,
name text not null,
role text not null default 'member',
status text not null,
recipient text,
created_at text not null,
updated_at text not null,
primary key (project_id, member_id),
foreign key (project_id) references projects(project_id) on delete cascade
);
```
Allowed token statuses:
```text
active
pending_activation
revoked
```
Allowed join request statuses:
```text
pending
accepted
cancelled
```
Database backup safety:
- SQLite backups contain encrypted project state, public member metadata, public
join requests, token hashes, and server public key metadata.
- SQLite backups must not contain env plaintext, project keys, token plaintext,
member private identities, raw server private identity, or token pepper.
## Backup and Restore
### What must be backed up
1. **SQLite database file** (`kagi.db` or the path passed to `--db`)
2. **SQLite WAL and SHM files** (`kagi.db-wal`, `kagi.db-shm`) when the database
is in WAL mode. Kagi enables WAL by default (`PRAGMA journal_mode = WAL`).
3. **Server key file** (`server.key.json` or the path passed to `--key-file`)
The server key file contains the age identity and the token pepper. Without it,
all existing token hashes become unverifiable and the server cannot decrypt
incoming envelopes or generate responses.
### What must NOT be shared publicly
- Server key file (`server.key.json`)
- Database backups (`.db`, `.db-wal`, `.db-shm`)
- Server logs containing request IDs, member metadata, or IP addresses
- Admin token plaintext
- Project token plaintext
### Recommended backup commands
For a running server, use SQLite's online backup API to create a consistent snapshot
without stopping the server:
```bash
# Online backup (safe while server is running)
sqlite3 kagi.db ".backup to /backup/kagi-$(date +%Y%m%d-%H%M%S).db"
# Or copy the database file while the server is stopped
systemctl stop kagi
cp kagi.db kagi.db-wal kagi.db-shm /backup/
cp server.key.json /backup/
systemctl start kagi
```
Automated backup example (daily cron):
```bash
#!/bin/bash
BACKUP_DIR="/backup/kagi/$(date +%Y%m%d)"
mkdir -p "$BACKUP_DIR"
sqlite3 /var/lib/kagi/kagi.db ".backup to $BACKUP_DIR/kagi.db"
cp /etc/kagi/server.key.json "$BACKUP_DIR/"
chmod -R 700 "$BACKUP_DIR"
```
### Restore flow
1. Stop the server:
```bash
systemctl stop kagi
```
2. Restore the database and WAL files:
```bash
cp /backup/kagi.db /var/lib/kagi/kagi.db
cp /backup/kagi.db-wal /var/lib/kagi/kagi.db-wal
cp /backup/kagi.db-shm /var/lib/kagi/kagi.db-shm
```
3. Restore the server key file:
```bash
cp /backup/server.key.json /etc/kagi/server.key.json
chmod 600 /etc/kagi/server.key.json
```
4. Start the server:
```bash
systemctl start kagi
```
5. Run a health check:
```bash
curl http://127.0.0.1:8787/
```
6. Verify from a client:
```bash
kagi status
kagi pull
```
### Server key rotation impact
If the server key file is lost or compromised, you must generate a new one.
Regenerating the server key changes:
- Server fingerprint (`kgs_...`)
- Token pepper
**Impact on existing tokens:**
All existing admin and project tokens are pinned to the old server fingerprint.
After key regeneration, the server cannot verify old token hashes because the
pepper changed. Every admin and project member must obtain a new token.
Recovery steps after key loss:
1. Stop the server.
2. Delete the old server key file and start the server to generate a new one.
3. The server will print a new admin token on first startup.
4. Re-run `kagi remote login` with the new admin token.
5. Re-create all projects and re-issue all project tokens.
6. Distribute new project tokens to all members.
Because of this impact, keep the server key file in a secure, backed-up
location with strict file permissions (`0600` on Unix).
## Database Operations
All write operations use one SQL transaction.
For `push`, use an immediate transaction to serialize writes:
```sql
BEGIN IMMEDIATE;
```
Push transaction steps:
1. Load project row.
2. Authenticate token and check `push`.
3. Check `base_revision == projects.revision`.
4. Validate incoming `ProjectState`.
5. Replace all `project_files` rows for the project.
6. Update `projects.revision = revision + 1`.
7. Apply token activations.
8. Apply token revocations.
9. Mark accepted join requests.
10. Commit.
If any step fails, rollback the whole transaction.
Pull transaction steps:
1. Authenticate token and check `pull`.
2. Read project revision and files.
3. If token has `push` or `rotate`, include pending join requests.
4. Update `last_used_at`.
Join transaction steps:
1. Authenticate token and check `join`.
2. Normalize requested member name.
3. Reject duplicate pending normalized name.
4. Upsert the caller's request only when `(project_id, member_id)` belongs to
the same `request_token_id`.
5. Return pending request metadata.
Token issue transaction steps:
1. Authenticate token and check `rotate`.
2. Generate `token_id` and token secret.
3. Store token hash with `pending_activation` unless this is an onboarding token.
4. Return plaintext token once inside encrypted response.
Token revoke transaction steps:
1. Authenticate token and check `rotate`.
2. Mark token ids as `revoked`.
3. Return revoked token ids.
Member removal should revoke tokens in the same push that uploads the rotated
project key. Prefer `push.token_revocations` for normal member removal. Use
`/tokens/revoke` only for emergency server-side token revocation where project
state does not change.
Project request creation steps:
1. Decrypt envelope.
2. Extract `project_id`, `requester_member_id`, `requester_name`,
`requester_recipient`, `kagi_json` from plaintext payload.
3. Insert into `project_requests` with `status = 'pending'`.
4. Return `{"project_id": ..., "status": "pending"}` encrypted.
Project request approval steps:
1. Decrypt envelope.
2. Authenticate admin token via `authenticate_admin`.
3. Verify `capabilities` contains `"admin"`.
4. Look up the request by `project_id`.
5. Create the project in `projects` table.
6. Insert the requester into `project_members` with `role = 'admin'`.
7. Generate a full-capability project token for the requester.
8. Store the token hash in `project_tokens`.
9. Delete the request from `project_requests`.
10. Return `{"project_id": ..., "project_token": "kgt_...", "status": "active"}`
encrypted.
Project deletion steps:
1. Decrypt envelope.
2. Authenticate token (either admin or project admin).
3. If project admin: check `project_members` table for `role = 'admin'`.
4. Delete project (CASCADE handles tokens, files, members, join_requests).
5. Return success.
## API
All endpoints return JSON. Only `GET /v1/server-key` is public plaintext.
Everything else takes and returns encrypted envelopes.
```text
GET /v1/server-key
POST /v1/projects/requests
POST /v1/projects/requests/list
POST /v1/projects/requests/{project_id}/approve
POST /v1/projects/list
POST /v1/projects/{project_id}/push
POST /v1/projects/{project_id}/pull
POST /v1/projects/{project_id}/status
POST /v1/projects/{project_id}/join
POST /v1/projects/{project_id}/tokens/issue
POST /v1/projects/{project_id}/tokens/revoke
POST /v1/projects/{project_id}/delete
```
### GET /v1/server-key
Plain response:
```json
{
"version": 1,
"server_key_id": "kgs_x",
"recipient": "age1...",
"fingerprint": "kgs_x"
}
```
### POST /v1/projects/requests
Create a pending project registration request.
Encrypted plaintext request:
```json
{
"version": 1,
"request_id": "kgr_x",
"issued_at": "2026-05-27T10:00:00Z",
"operation": "create_project_request",
"method": "POST",
"path": "/v1/projects/requests",
"project_id": "kgp_x",
"token": null,
"payload": {
"requester_member_id": "kgm_x",
"requester_name": "alice",
"requester_recipient": "age1...",
"kagi_json": "{...}"
}
}
```
Encrypted success data:
```json
{
"project_id": "kgp_x",
"status": "pending"
}
```
### POST /v1/projects/requests/list
List pending project registration requests. Admin only.
Encrypted plaintext request:
```json
{
"version": 1,
"request_id": "kgr_x",
"issued_at": "2026-05-27T10:00:00Z",
"operation": "list_project_requests",
"method": "POST",
"path": "/v1/projects/requests/list",
"token": "kagi_admin_v1_..."
}
```
Encrypted success data:
```json
{
"requests": [
{
"project_id": "kgp_x",
"requester_member_id": "kgm_x",
"requester_name": "alice",
"status": "pending",
"created_at": "2026-05-27T10:00:00Z"
}
]
}
```
### POST /v1/projects/requests/{project_id}/approve
Approve a pending project request and create the project. Admin only.
Encrypted plaintext request:
```json
{
"version": 1,
"request_id": "kgr_x",
"issued_at": "2026-05-27T10:00:00Z",
"operation": "approve_project_request",
"method": "POST",
"path": "/v1/projects/requests/kgp_x/approve",
"token": "kagi_admin_v1_...",
"remote": "http://127.0.0.1:8787"
}
```
Encrypted success data:
```json
{
"project_id": "kgp_x",
"project_token": "kagi_proj_v1_...",
"status": "active"
}
```
### POST /v1/projects/list
List all active projects. Admin only.
Encrypted plaintext request:
```json
{
"version": 1,
"request_id": "kgr_x",
"issued_at": "2026-05-27T10:00:00Z",
"operation": "list_projects",
"method": "POST",
"path": "/v1/projects/list",
"token": "kagi_admin_v1_..."
}
```
Encrypted success data:
```json
{
"projects": [
{
"project_id": "kgp_x",
"revision": 12,
"created_at": "2026-05-27T10:00:00Z"
}
]
}
```
### POST /v1/projects/{project_id}/push
Encrypted plaintext request:
```json
{
"version": 1,
"request_id": "kgr_x",
"issued_at": "2026-05-27T10:00:00Z",
"operation": "push",
"method": "POST",
"path": "/v1/projects/kgp_x/push",
"project_id": "kgp_x",
"token": "kagi_proj_v1_...",
"base_revision": 12,
"state": {
"project_id": "kgp_x",
"revision": 12,
"kagi_json": "{...}",
"access_json": "{...}",
"files": []
},
"activate_token_ids": ["kgt_new_member"],
"revoke_token_ids": ["kgt_removed_member"],
"accepted_join_member_ids": ["kgm_new_member"]
}
```
Encrypted success data:
```json
{
"revision": 13,
"state_hash": "hex..."
}
```
Conflict error:
```json
{
"code": "conflict",
"message": "remote revision changed; run kagi pull first",
"details": {
"remote_revision": 13,
"base_revision": 12
}
}
```
### POST /v1/projects/{project_id}/pull
Encrypted plaintext request:
```json
{
"version": 1,
"request_id": "kgr_x",
"issued_at": "2026-05-27T10:00:00Z",
"operation": "pull",
"method": "POST",
"path": "/v1/projects/kgp_x/pull",
"project_id": "kgp_x",
"token": "kagi_proj_v1_...",
"known_revision": 12
}
```
Encrypted success data:
```json
{
"revision": 13,
"state": {
"project_id": "kgp_x",
"revision": 13,
"kagi_json": "{...}",
"access_json": "{...}",
"files": []
},
"join_requests": [
{
"member_id": "kgm_x",
"name": "alice-laptop",
"recipient": "age1...",
"created_at": "2026-05-27T10:00:00Z"
}
]
}
```
`join_requests` is returned only when the token has `push` or `rotate`.
Onboarding tokens should receive an empty list or no field.
### POST /v1/projects/{project_id}/status
Encrypted plaintext request:
```json
{
"version": 1,
"request_id": "kgr_x",
"issued_at": "2026-05-27T10:00:00Z",
"operation": "status",
"method": "POST",
"path": "/v1/projects/kgp_x/status",
"project_id": "kgp_x",
"token": "kagi_proj_v1_...",
"local_revision": 12
}
```
Encrypted success data:
```json
{
"remote_revision": 13,
"local_revision": 12,
"state": "behind",
"pending_join_count": 1
}
```
Allowed `state` values:
```text
equal
ahead
behind
diverged
unknown
```
### POST /v1/projects/{project_id}/join
Encrypted plaintext request:
```json
{
"version": 1,
"request_id": "kgr_x",
"issued_at": "2026-05-27T10:00:00Z",
"operation": "join",
"method": "POST",
"path": "/v1/projects/kgp_x/join",
"project_id": "kgp_x",
"token": "kagi_proj_v1_...",
"join_request": {
"member_id": "kgm_x",
"name": "alice-laptop",
"recipient": "age1..."
}
}
```
Encrypted success data:
```json
{
"member_id": "kgm_x",
"status": "pending"
}
```
### POST /v1/projects/{project_id}/tokens/issue
Used by `member invite` and `member approve`.
Encrypted plaintext request:
```json
{
"version": 1,
"request_id": "kgr_x",
"issued_at": "2026-05-27T10:00:00Z",
"operation": "token_issue",
"method": "POST",
"path": "/v1/projects/kgp_x/tokens/issue",
"project_id": "kgp_x",
"token": "kagi_proj_v1_...",
"member_id": "kgm_x",
"capabilities": ["pull", "join", "push", "rotate"],
"status": "pending_activation"
}
```
Encrypted success data:
```json
{
"token_id": "kgt_x",
"project_token": "kagi_proj_v1_...",
"status": "pending_activation"
}
```
For onboarding tokens, request:
```json
{
"member_id": null,
"capabilities": ["pull", "join"],
"status": "active"
}
```
### POST /v1/projects/{project_id}/tokens/revoke
Emergency token revocation.
Encrypted plaintext request:
```json
{
"version": 1,
"request_id": "kgr_x",
"issued_at": "2026-05-27T10:00:00Z",
"operation": "token_revoke",
"method": "POST",
"path": "/v1/projects/kgp_x/tokens/revoke",
"project_id": "kgp_x",
"token": "kagi_proj_v1_...",
"token_ids": ["kgt_x"]
}
```
Encrypted success data:
```json
{
"revoked_token_ids": ["kgt_x"]
}
```
### POST /v1/projects/{project_id}/delete
Delete a project. Allowed for server admins or project admins.
Encrypted plaintext request:
```json
{
"version": 1,
"request_id": "kgr_x",
"issued_at": "2026-05-27T10:00:00Z",
"operation": "delete_project",
"method": "POST",
"path": "/v1/projects/kgp_x/delete",
"project_id": "kgp_x",
"token": "kagi_proj_v1_..."
}
```
Encrypted success data:
```json
{
"deleted": true
}
```
## Join Flow
### Request Access
```bash
kagi pull <project-token>
kagi member join --name alice-laptop
```
The CLI creates a local age identity and member id, then sends `/join`.
Pending join state is local-only:
```json
{
"version": 1,
"project_id": "kgp_x",
"remote": "http://kagi.internal:8787",
"member_id": "kgm_x",
"name": "alice-laptop",
"recipient": "age1...",
"server_fingerprint": "kgs_x"
}
```
### Approve Access
```bash
kagi pull
kagi member approve kgm_x
kagi push
```
`member approve` should:
1. Validate active plus pending member name uniqueness locally.
2. Call `/tokens/issue` for a member token with `pending_activation`.
3. Wrap project key to the pending member recipient.
4. Wrap the issued project token to the pending member recipient.
5. Mark the member active in `access.json`.
6. Add the issued token id to local pending activation state.
The next `kagi push` sends `activate_token_ids` and
`accepted_join_member_ids` atomically with the updated `access.json`.
### Complete Join
```bash
kagi pull
```
If the CLI has local join state but no project key, `pull` checks
`access.json`. Once approved, it decrypts:
- wrapped project key
- wrapped project token
Then it stores both locally and performs a normal authenticated pull.
## Member Name Rules
`member_id` is the canonical id used by commands. `name` is a human identifier
and must be unique within active and pending members.
Normalize before uniqueness checks:
- Trim leading and trailing whitespace.
- Collapse repeated whitespace into one space.
- Compare case-insensitively.
Rules:
- Active plus pending member names must be unique.
- Removed member names may be reused.
- Duplicate names in pulled `access.json` cause validation failure.
- Duplicate pending names on the server return encrypted `409 conflict`.
- Error messages should suggest a unique name, such as `alice-laptop` or
`alice-zhang`.
## Error Codes
Use stable machine-readable codes:
```text
bad_request
bad_envelope
decrypt_failed
auth_failed
forbidden
not_found
conflict
payload_too_large
invalid_path
invalid_revision
invalid_token
invalid_project_state
server_key_mismatch
internal
```
CLI output should stay short and actionable. Do not print token values or env
secret values.
## Implementation Phases
1. Add domain structs for project token, envelope, ProjectState, remote config,
and API request/response payloads.
2. Add local storage for project token, admin token, pinned fingerprint, local
revision, and pending token activation/revocation state.
3. Add age transport envelope encryption.
4. Add Reqwest remote client.
5. Add SQLx migrations and SQLite repository.
6. Add Axum server routes, rate limiting, and `kagi serve`.
7. Add admin token generation on first startup.
8. Add `kagi project join`, `list`, `approve`, and `del`.
9. Add `push`, `pull`, and `status`.
10. Add server-mode `member join` and `member approve`.
11. Add token issue/revoke support.
12. Add member removal with project-key rotation plus token revocation.
13. Update README with Git mode and server mode examples.
## Tests
CLI integration tests:
- `init` creates `.kagi/`, `.env`, and `.env.*` entries in `.gitignore`.
- `project join --remote` sends a pending request and stores remote config.
- `push -> pull -> run` works between two temp directories.
- `pull <project-token>` works without a project id argument.
- A user with only onboarding token can pull encrypted state but cannot decrypt.
- `member join -> member approve -> push -> pull` completes a new member.
- Removing a member rotates project key and revokes that member token.
- Stale `push` returns encrypted `409 conflict`.
- Duplicate active or pending member names are rejected.
- Noninteractive first connection without pinned fingerprint fails.
- Changed server fingerprint fails.
Server tests:
- SQLite restart preserves state.
- Server key file is not stored in SQLite.
- Admin token is generated on first startup and not on restart.
- Project creation stores token hash only.
- Invalid paths are rejected.
- Push writes files, token activations, token revocations, and accepted joins in
one transaction.
- Pull returns join requests only for tokens with `push` or `rotate`.
- Wrong token returns `401`.
- Wrong capability returns `403`.
- Admin-only endpoints reject non-admin tokens.
- Rate limiting returns `429` after burst is exceeded.
- Oversized body returns `413`.
- Malformed JSON returns `400`.
Security tests:
- Captured HTTP request body does not contain project token plaintext.
- Captured HTTP response body does not contain project token plaintext.
- Captured HTTP response body is encrypted.
- Tampering with envelope `response_recipient` is rejected.
- Tampering with response ciphertext fails MAC verification.
- A response encrypted by someone other than the server is rejected before data
is trusted.
- Server DB does not contain env plaintext.
- Server DB does not contain project key.
- Server DB does not contain token plaintext.
- Server DB does not contain raw server private identity.
- Server DB does not contain token pepper.
## Deployment Packaging
### Docker
A `Dockerfile` is included in the repository. It builds a multi-stage image using Debian Bookworm as the base.
Build:
```bash
docker build -t kagi-server:latest .
```
Run with persistent volumes:
```bash
docker run -d \
--name kagi-server \
-p 127.0.0.1:8787:8787 \
-v kagi-data:/home/kagi/data \
-v kagi-server-key:/home/kagi/server \
kagi-server:latest
```
On first startup, the container prints the admin token to the logs. Retrieve it with:
```bash
docker logs kagi-server
```
### Docker Compose
A `docker-compose.yml` example is included with persistent volume mounts for the database and server key.
```bash
docker compose up -d
```
The compose file binds `127.0.0.1:8787` by default. Change the port mapping to expose the server publicly, and place a reverse proxy in front for TLS.
### systemd
A `kagi-server.service` unit file is included. Install it on a Debian/Ubuntu or RHEL-compatible system:
```bash
# Create user and directories
sudo useradd -r -s /bin/false -d /var/lib/kagi kagi
sudo mkdir -p /var/lib/kagi /etc/kagi
sudo chown kagi:kagi /var/lib/kagi
sudo chmod 700 /var/lib/kagi
# Install the binary
sudo cp target/release/kagi /usr/local/bin/kagi
sudo chmod +x /usr/local/bin/kagi
# Install the service file
sudo cp kagi-server.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable kagi-server
sudo systemctl start kagi-server
```
On first startup, the server creates the database and server key, then prints the admin token. Check the logs:
```bash
sudo journalctl -u kagi-server -n 50
```
### Environment Variables
| `KAGI_HOME` | platform-specific | Base directory for local data and server state |
| `KAGI_ALLOW_INSECURE_HTTP` | unset | Set to `1` to allow non-localhost `http://` remotes |
| `RUST_LOG` | unset | Standard `tracing` log filter, e.g. `info` or `debug` |
Server command-line flags:
| `--bind` | `127.0.0.1:8787` | Address and port to listen on |
| `--db` | `$KAGI_HOME/server/kagi.db` | SQLite database path |
| `--key-file` | `$KAGI_HOME/server/server.key.json` | Server key file path |
| `--max-body` | `10mb` | Maximum request body size |
### Reverse Proxy
For production, run the Kagi server behind a reverse proxy that handles TLS termination.
Recommended setup with Caddy:
```
kagi.example.com {
reverse_proxy 127.0.0.1:8787
}
```
Recommended setup with nginx:
```nginx
server {
listen 443 ssl http2;
server_name kagi.example.com;
ssl_certificate /etc/letsencrypt/live/kagi.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/kagi.example.com/privkey.pem;
client_max_body_size 20m;
access_log /var/log/nginx/kagi.access.log;
location / {
proxy_pass http://127.0.0.1:8787;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
Keep the Kagi server bound to `127.0.0.1` when a reverse proxy is present. The reverse proxy should pass the real client IP so the Kagi rate limiter applies per-client limits correctly.