Skip to content
Open
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
18 changes: 12 additions & 6 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,22 @@ updates:
directory: "/capsule-web"
schedule:
interval: "weekly"
# The workspace root: `Cargo.toml` and `Cargo.lock` live here and cover every member. This
# pointed at `/capsule-api` until the Salvo tree moved to `legacy-review/` in `S-C59`, so it
# had been watching a directory that no longer exists — and therefore watching nothing.
- package-ecosystem: "cargo"
directory: "/capsule-api"
schedule:
interval: "weekly"
- package-ecosystem: "docker"
directory: "/capsule-api"
directory: "/"
schedule:
interval: "weekly"
# The server's local service images (Postgres, Valkey). Same story: `/capsule-api/compose.yaml`
# went with the Salvo tree, and `capsule-server/compose.yaml` is the live file (issue #401).
#
# There is no `docker` entry any more. It watched `/capsule-api/Containerfile`, and no
# Containerfile exists anywhere in the active tree — an OCI image for the rebuilt server is
# not written yet, and an ecosystem pointed at an absent file is a permanent dashboard error
# rather than a dependency update. Add it back in the change that adds the Containerfile.
- package-ecosystem: "docker-compose"
directory: "/capsule-api"
directory: "/capsule-server"
schedule:
interval: "weekly"
- package-ecosystem: "github-actions"
Expand Down
25 changes: 20 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
name: Release

# Fires when a release commit (`chore(release): vX.Y.Z`, produced by prepare-release.yml
# and merged via its PR) lands on master. It builds the `capsule` CLI for each target and
# publishes a GitHub Release. `gh release create` also creates the tag, so the whole
# build+publish happens in this one run — no PAT or tag-push re-trigger needed.
# and merged via its PR) lands on master. It builds the `capsule` CLI and the `capsule-server`
# binary for each target and publishes a GitHub Release. `gh release create` also creates the
# tag, so the whole build+publish happens in this one run — no PAT or tag-push re-trigger
# needed.
#
# Both binaries ride in the one per-target archive rather than two: an operator running a
# self-hosted deployment wants the server and the CLI that talks to it at the same version, and
# two downloads is two chances to mix versions. The server is Unix-only here — Windows is
# already best-effort for the CLI, and adding a server build to a job that is allowed to fail
# would make "did the Windows CLI ship" harder to answer, not easier.
on:
push:
branches: [master]
Expand Down Expand Up @@ -41,7 +48,7 @@ jobs:
fi

build:
name: Build capsule (${{ matrix.target }})
name: Build binaries (${{ matrix.target }})
needs: detect
if: ${{ needs.detect.outputs.release == 'true' }}
runs-on: ${{ matrix.os }}
Expand Down Expand Up @@ -74,8 +81,11 @@ jobs:
uses: Swatinem/rust-cache@v2
with:
key: release-${{ matrix.target }}
- name: Build release binary
- name: Build the CLI
run: cargo build -p capsule-cli --release --target ${{ matrix.target }}
- name: Build the server
if: runner.os != 'Windows'
run: cargo build -p capsule-server --release --target ${{ matrix.target }}
- name: Package (unix)
if: runner.os != 'Windows'
shell: bash
Expand All @@ -84,6 +94,11 @@ jobs:
dist="capsule-v${{ needs.detect.outputs.version }}-${{ matrix.target }}"
mkdir -p "$dist"
cp "target/${{ matrix.target }}/release/capsule" "$dist/"
cp "target/${{ matrix.target }}/release/capsule-server" "$dist/"
# The operator's starting point: every setting the server reads, with what it defaults
# to and why. A release without it is a binary that refuses to start and an operator
# reading GitHub to find out which variables it wanted.
cp capsule-server/.env.example "$dist/"
cp README.md LICENSE NOTICE CHANGELOG.md "$dist/"
tar -czf "${dist}.tar.gz" "$dist"
echo "ASSET=${dist}.tar.gz" >> "$GITHUB_ENV"
Expand Down
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion capsule-cli/src/remote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ pub struct RemoteConfig {
pub protocol_version: String,
}

/// The default server origin — one host, one port, matching `mise run serve-api`.
/// The default server origin — one host, one port, matching `mise run serve-memory`.
pub const DEFAULT_ENDPOINT: &str = "http://127.0.0.1:3000";

impl RemoteConfig {
Expand Down
139 changes: 133 additions & 6 deletions capsule-docs/src/content/docs/development/local-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,27 +52,154 @@ mise run hooks-install # installs the git hooks (hk)

## Running a server locally

**There is no local server today, and that is a known gap rather than a missing instruction.**
`capsule-server` is one binary with subcommands:

```text
capsule-server [--config PATH] <SUBCOMMAND>
serve [--listen HOST:PORT] [--memory] [--blob-root PATH]
gc [--apply] [--grace-window-hours N] --memory --blob-root PATH
purge [--apply] [--limit N] --memory --blob-root PATH
scrub [--deep] [--budget BYTES] --memory --blob-root PATH
gen-openapi [FILE] [--check]

`--memory` is written as required on the three operator commands because today it is: they
compare the index against the blob store, and the only index adapter written is the in-memory
one. Without it they refuse and say so. It becomes optional when #402 lands.
```

### The development profile

```bash
mise run serve-memory
```

That is a server you can point a client at: it binds, prints the address it bound, and answers
every operation. An account registers and signs in — the credential is checked with Argon2id
against a real in-memory account directory, so a wrong password is refused rather than accepted.

What it is missing is durability. The blob store is a **real** filesystem store under
`target/capsule-server-blobs`; everything else — the index, sessions, albums, the device
directory, quota, the collector's marks — lives in the process and is gone when it exits. That
is not a gap to route around, it is the shape of a profile whose durable half is exactly the one
adapter that has been written. Two consequences worth knowing before they surprise you:

- After a restart, `capsule-server scrub` will honestly report every blob still on disk as an
orphan, because the index that referenced them is gone.
- `capsule-server gc` can only ever **mark** in this profile. Collection is two passes by
design — a blob that reaches zero references is marked, and swept on a later pass once the
grace window has passed — and the mark store does not outlive the process.

The signing key `serve-memory` falls back to is the published example in
`capsule-server/.env.example` — commented out there, so a `cp .env.example .env` cannot silently
produce a forgeable deployment. Every token the task mints under it is forgeable by anyone who has
read this repository, which is why it is `serve-memory` and not `serve`, and why it binds
`127.0.0.1` rather than every interface. Set `JWT_ED25519_DER` yourself and it is used instead:

```bash
JWT_ED25519_DER="$(openssl genpkey -algorithm ed25519 -outform DER | base64 | tr -d '\n')" \
mise run serve-memory
```

### A configured server

```bash
cp capsule-server/.env.example capsule-server/.env # then edit it
mise run serve-deps # Postgres 18 + Valkey 9, on loopback
mise run serve
```

The template ships with **both secrets commented out** — `JWT_ED25519_DER` and
`ATTESTATION_KEY_SEED` — so a copy you have not finished editing produces a server that refuses
and names what it wants, rather than one that starts under a published key. Uncomment each and
put your own value in. Every other setting is either a working default or optional.

Nothing in the template is a shell expression, deliberately: the file is read by more than a
shell — `podman --env-file`, compose's `env_file:`, systemd's `EnvironmentFile=` — and those take
a line literally, so a placeholder shaped like `$(...)` would be stored as the value rather than
replaced.

`serve-deps` and `serve` are separate tasks on purpose: a task that silently starts containers is
a task that leaks them. Bring them down with
`podman compose -f capsule-server/compose.yaml down` (`docker compose` accepts the same file).

**`mise run serve` does not work yet, and refuses rather than pretending.** The Postgres and
Valkey adapters are not written. Without `VALKEY_URL` and without `--memory` it exits 2 naming
the variable — the refusal `capsule-server/src/store/mod.rs` has documented since `S-C29` and
nothing could enforce until there was a boot path; with `VALKEY_URL` set it exits non-zero naming
the issue that will honour it. Neither ever silently falls back to the in-memory adapters, which
is the whole point: a deployment that forgot a variable must fail closed.

A configured server also has to supply `ATTESTATION_KEY_SEED`. It is **not** derived from
`JWT_ED25519_DER`, and that is deliberate: the attestation key signs custody receipts and has to
be distinct from the key that signs session tokens, or anyone holding the operational key could
manufacture custody evidence — see
[Cryptography — Failure Modes](/design/cryptography/failure-modes/). A different HKDF label over
the same input is not a separation. `serve --memory` derives it, because a development server's
whole state is discarded when it exits.

Every configuration fault is reported in **one** message with exit code 2, so bringing a
deployment up is one read of one log line rather than one restart per variable.

`capsule-server/.env.example` is the full list of settings. The precedence is command-line flag,
then the environment, then the built-in default; there is no configuration file, and `--config
PATH` is accepted and refused with a sentence saying so.

### TLS

The server does not terminate it. HTTPS is the ingress or reverse proxy's job — see
[Cryptography — Failure Modes](/design/cryptography/failure-modes/) — so there is no certificate
setting and Kynos's `tls` feature is off.

### Logs and reports

Every log line goes to **stderr**; stdout is a data channel. `serve` writes one
`listening on <url>` line there (which is how a `--listen 127.0.0.1:0` caller learns its port),
`gen-openapi` writes the path it wrote, and the operator commands write their report. `LOG_FORMAT`
is `pretty` in a debug build and `json` in a release one; `RUST_LOG` is the usual filter.

### The operator commands

`gc`, `purge` and `scrub` are the three jobs
[Filesystem — Maintenance](/design/filesystem/maintenance/) describes. They need a blob root and
deliberately **no key material**: a maintenance host that had to hold the production
token-signing key to sweep a directory would be a reason to put the key on a maintenance host.

They do need `--memory` today, and they say so rather than naming a variable that would not have
helped: all three compare the index against the blob store, and the in-memory one is the only
index adapter written.

Dry run is the default for the two that write; `--apply` opts in, and the report says which
posture produced it. `scrub` mutates nothing at all and exits non-zero on a non-empty report,
which is what makes it usable as a monitoring probe — and a `--deep` pass that ran out of budget
says so, because a clean report from a pass that stopped early is not a clean store.

`mise run serve-api` and the compose stack behind it went with the Salvo tree in slice `S-C59`. The Kynos server that replaces it is complete as a *surface* — fifty-nine operations, a committed OpenAPI 3.2 document, and a test suite that drives the real router — and it has **no binary, no configuration loading and no Postgres or Valkey adapter**. Nothing reads `JWT_ED25519_DER`, `SYNC_CURSOR_MAC_KEY` or `ATTESTATION_KEY_SEED` yet.
### Without running anything

That ordering is deliberate: every port in `capsule-server` has a deterministic in-memory adapter and a conformance suite, because the suite is what a real adapter is written *against*, and a port with two implementations before it has one suite is a port whose implementations will disagree. Until those adapters land, the way to exercise the server is the way its own tests do — in process, with no container:
To exercise the server the way its own tests do — in process, no socket, no container:

```bash
cargo nextest run -p capsule-server
```

`kynos::test::TestClient` drives a built `Service` directly: no socket, no port, no runtime flavour. One test (`tests/sdk_client.rs`) does bind an ephemeral port, because the property it proves — that the **generated** SDK client round-trips the real router over TCP — is the one an in-process client cannot.
`kynos::test::TestClient` drives a built `Service` directly. Two test files do use a socket:
`capsule-server/tests/sdk_client.rs`, because the property it proves is that the **generated**
SDK client round-trips the real router over TCP, and `capsule-server/tests/binary.rs`, because
the properties it proves — that the binary binds, reports its port, and drains to exit 0 on
SIGTERM — belong to a process rather than to a router.

To read the served contract without running anything:

```bash
mise run openapi-kynos # regenerate capsule-server/openapi.json
```

### Nothing here needs a container any more
### Nothing here needs a container

The testcontainers section this page used to carry is gone with the crate that needed it. No test in the workspace starts a container, so `mise run test-rust` has no podman prerequisite and cannot leak one. (The `containers` nextest group is kept, empty, for the first real adapter — the one-thread rule it encodes was learned by watching CI flake, and that is the expensive way to learn it.)
No test in the workspace starts a container, so `mise run test-rust` has no podman prerequisite
and cannot leak one. `mise run serve-deps` is the only task that starts anything, and it is never
a dependency of another task. (The `containers` nextest group is kept, empty, for the first real
adapter — the one-thread rule it encodes was learned by watching CI flake, and that is the
expensive way to learn it.)

## Git hooks

Expand Down
Loading
Loading