Skip to content

[FEAT] Give capsule-server a binary, configuration, operator commands and a serve task - #435

Open
justin13888 wants to merge 9 commits into
chore/freeze-capsule-core-api-399from
feat/server-binary-config-operator-commands-401
Open

[FEAT] Give capsule-server a binary, configuration, operator commands and a serve task#435
justin13888 wants to merge 9 commits into
chore/freeze-capsule-core-api-399from
feat/server-binary-config-operator-commands-401

Conversation

@justin13888

@justin13888 justin13888 commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Description

capsule-server had fifty-nine operations, a committed OpenAPI 3.2 document, seven hundred tests — and no way to run it. No main, no configuration loading, no compose file, no serve task, and GC / retention purge / integrity scrub existing only as library functions that filesystem/maintenance.md describes as operator commands. store/mod.rs has asserted since S-C29 that the server refuses to boot without VALKEY_URL; there was no boot path to refuse in.

This makes it runnable.

Summary

  • config — every setting an operator decides, read once. Precedence is command line → environment → default, and Config::load reports every fault in one message rather than failing on the first, because an operator otherwise restarts the process once per variable. What a subcommand requires is a parameter (Demands): gc/purge/scrub need a blob root and deliberately no key material, so a maintenance host never has to hold the production token-signing key. Environment names are the retired deployment's (SERVER_HOST, SERVER_PORT, SERVER_DOMAIN, JWT_ED25519_DER, DATABASE_URL, VALKEY_URL), with BLOB_ROOT replacing UPLOAD_DIR and still honouring it with a warning. SYNC_CURSOR_MAC_KEY and ATTESTATION_KEY_SEED are HKDF-SHA256-derived from the signing key under separate info strings when unset.
  • boot::assemble — the one composition root, and the only place adapters are chosen. Selection is a two-arm match on Backends. --memory (or CAPSULE_PROFILE=memory) takes every deterministic in-crate adapter over a real FilesystemBlobStore and a real SystemClock; anything else refuses. That makes two sentences store/mod.rs has carried since S-C29 true for the first time: no VALKEY_URL and no --memory is a configuration fault naming the variable, and VALKEY_URL set is BootError::AdapterUnavailable naming server: Valkey adapters for the auth-state and upload-session ports #403 and server: Postgres adapters and a conformance suite for every durable port #402. Neither ever silently becomes an in-memory server.
  • A real account adapter, not a stub. The four account ports and TotpStore had no adapter at all, so a development server would have answered register and login with their declared unavailable refusal. auth::credential is the Argon2id helper (PHC strings, a fresh salt per hash, the timing-equalized miss against a decoy computed once at startup) that server: Postgres adapters and a conformance suite for every durable port #402's Postgres adapter reuses; auth::accounts_memory is a real directory over it with a lockout a password change clears; auth::totp gains the deterministic store its port's three properties are all expressible over. None of these is the permissive credential double tests/support/mod.rs:1-19 warns must never be linkable by a server — a wrong password is refused, and a test asserts it.
  • One binary with subcommandsserve | gc | purge | scrub | gen-openapi, replacing the gen_openapi [[bin]]. Four executables would each carry their own configuration loader and composition root. Logs go to stderr so stdout stays a data channel.
  • serve binds, logs its bound address before accepting, drains on SIGINT/SIGTERM inside SHUTDOWN_TIMEOUT_SECONDS, and terminates TLS never — design/cryptography/failure-modes.md:68 puts that on the ingress.
  • gc / purge / scrub — dry run is the default for the two that write; --apply opts in. scrub exits 1 on a non-empty report and mutates nothing, which filesystem/maintenance.md requires of it.
  • Operationscapsule-server/compose.yaml (Postgres 18, Valkey 9.0.4, podman-first :Z,U labels), capsule-server/.env.example, mise run serve / serve-deps / serve-memory, release.yml packaging the server binary beside the CLI, and dependabot.yml re-pointed off the deleted /capsule-api.
  • Documentationlocal-development.md's "there is no local server today" is replaced with how to run one; the three stale serve-api citations are corrected.

Not here, and tracked: the Postgres adapters (#402) and the Valkey adapter (#403). Both fill the one Backends::Durable arm.

Validation

Every command run inside the worktree at /var/mnt/scratch/golem/dev/Capsulsaurus/Capsule.worktrees/Capsule-feat-server-binary-config-operator-commands-401, on the merged head.

Command Outcome
cargo nextest run -p capsule-server 741 passed, 0 skipped
cargo nextest run -p capsule-server --test binary 21 passed, 0 skipped
mise run openapi-check-kynos pass; openapi.json unmodified in the diff — the proof the bin port is byte-preserving
mise run lint-check-rust pass (cargo clippy --workspace with the repo's $CLIPPY_FLAGS)
mise run check-docs-truth pass (473 cross-links, 84 endpoint citations, 118 module paths)
mise run lint-check-md pass (0 issues in 168 files)
mise run lint-check-rust (re-run after the template fix) pass
mise run build-docs (re-run after the template fix) pass (59 pages)
mise run build-docs pass (59 pages, all internal links valid)
mise run build-rust pass
podman compose -f capsule-server/compose.yaml config exit 0
mise run check-rust pass — all fifteen steps: format-check-rust, lint-check-rust, doc-check-rust, i18n-check, i18n-guard, openapi-check-kynos, architecture-check, license-check, translate-readme-check, build-rust, build-check-wasm, build-ffi, lint-check-ffi, gen-bindings, verify-examples. Run step by step in the foreground rather than through the aggregate, because two attempts at the aggregate were killed by a signal on this shared machine mid-clippy with no diagnostic emitted; the commands and their order are the task's own
mise run test-rust pass — cargo nextest run --workspace 1798 passed / 0 skipped, -p capsule-core --features ffi 729 passed, -p capsule-sdk --features ffi 160 passed

The three gates named for the template fix, re-run on 459e8af3:

     Summary [   1.317s] 21 tests run: 21 passed, 0 skipped
Summary: 0 issues in 0 files
cross-links: 473 link(s) checked, all resolve.
endpoint-census: 84 citation(s) checked, all resolve.
module-paths: 118 path(s) checked, all resolve.

The two new cases were confirmed to fail against the reintroduced placeholder before being kept, so they are a regression test rather than a restatement.

Failures classified:

  • Build Android app / "Build Capsule.apk + :core JVM smoke" — pre-existing. It fails on the base branch head this branched from (718bc820) and on the two commits before it, and every error is Kotlin: capsule-core-kotlin/src/test/.../SoftwareSignerSmokeTest.kt ("No value passed for parameter 'client'") and capsule-android/src/androidMain/.../CapsuleApp.kt (unresolved di, ListViewModel, DetailViewModel, initKoin). This branch touches no Kotlin or Android file. The job is not in the required check.
  • cargo clippy -p capsule-server --all-targets — pre-existing. It reports findings in src/counter/tests.rs, src/gc/tests.rs, tests/support/mod.rs, tests/ops.rs, tests/sync.rs and others. The repo gate is cargo clippy --workspace without --all-targets, so test targets are outside it; every finding predates this branch and none is in a file this touches. Run with --all-targets restricted to this branch's own files, there are none.
  • The two Apple CI jobs ("Build iOS app", "Swift") — still running at the time of writing, so unclassified. Both were SUCCESS on the previous head with the same non-Swift diff, and neither this branch's commits nor the merged base commit touches a Swift file; build-ffi and gen-bindings, which are what the Swift build consumes from Rust, pass locally. Every other check is SUCCESS except the Android job above.
  • VALKEY_EXTRA_FLAGS inertness — refuted, not unverified. A live container probe is not possible on this host (libc.so.6: Permission denied under the rootless runtime), so the image's own entrypoint was read instead: valkey/valkey:9.0.4's /usr/local/bin/docker-entrypoint.sh ends with exec "$@" $VALKEY_EXTRA_FLAGS, unquoted. The flags reach valkey-server.

Risks and rollout

Four lanes (#402, #403, #404, #407) branch from this head, which is why it was opened at its first coherent commit.

  • Two independent secrets, not one. A durable deployment must set ATTESTATION_KEY_SEED as well as JWT_ED25519_DER; the first pass derived the former from the latter and that is fixed here (decision 10). Anyone who took the first pass's .env and only set the token key will find serve refuses and names the missing variable, which is the intended outcome rather than a regression.
  • The riskiest thing here is the account adapter, because it is the one place this branch writes credential-handling code. It is mitigated the only way that counts: auth::credential is the single implementation, the routes were already written against the port, and the adapter's own tests assert the refusing direction (wrong password refused, unknown address indistinguishable from a wrong one, lockout reachable, lockout cleared by a password change). If it is wrong, the reversal is one line in boot::assemble — swap InMemoryAccounts for a fail-closed stub — and the routes already map that to their declared unavailable response, so the OpenAPI document is unaffected either way.
  • openapi.json is untouched, and openapi-check-kynos is the gate that proves it. Nothing under capsule-server/src/routes/ changed, so no client regenerates.
  • The --memory profile keeps blobs and loses everything else on a restart, because the filesystem blob store is the one durable adapter written. That is stated in boot's module docs, in capsule-server/README.md and in local-development.md rather than left for an operator to discover. It also means the collector can only ever mark in that profile: gc marks on one pass and sweeps on a later one once the grace window has passed, and the mark store does not survive the process. Sweeping needs the durable mark store server: Postgres adapters and a conformance suite for every durable port #402 brings.
  • A lockout expires. Ten consecutive failures inside fifteen minutes lock an account, and the lock lifts fifteen minutes after the last counted failure. That is a policy this change invents — design/authentication.md names no figure — and both numbers are settings (LOCKOUT_MAX_ATTEMPTS, LOCKOUT_WINDOW_SECONDS) so a deployment can move them without a release.
  • Rollback is git revert of the series. The only externally visible change to an existing workflow is cargo run -p capsule-server --bin gen_openapi becoming cargo run -p capsule-server -- gen-openapi, and both mise tasks were re-pointed in the same commit.

Related Issues

Closes #401
Refs #402, #403

Decisions taken

The record below is carried verbatim from the lane's decision record, with decision 1 as amended by the orchestrator. Decisions 5 onward were taken inside the manifest while implementing, and are recorded in the same shape.

Issue 401 - server: give capsule-server a binary, configuration, operator commands and a serve task
Plan:     v1 (planned against f433d918; executed on the head of lane #399's branch), with decision 1 amended by the orchestrator (below)
Branch:   feat/server-binary-config-operator-commands-401
Base:     the head of lane #399's branch (chore/freeze-capsule-core-api-399), stacked; the PR targets that branch until it merges
Worktree: /var/mnt/scratch/golem/dev/Capsulsaurus/Capsule.worktrees/Capsule-feat-server-binary-config-operator-commands-401
Cause:    -
Touches:  capsule-server/src/{main.rs,cli.rs,config.rs,boot.rs,unprovisioned.rs,auth/accounts_memory.rs (new: real in-memory account adapter),auth/totp.rs (append InMemoryTotp),lib.rs (module lines)}, capsule-server/src/bin/gen_openapi.rs (deleted), capsule-server/tests/binary.rs (new), capsule-server/{compose.yaml,.env.example,README.md,Cargo.toml}, mise.toml (openapi-kynos, openapi-check-kynos, serve, serve-deps, serve-memory), .github/workflows/release.yml, .github/dependabot.yml, capsule-cli/src/remote.rs (comment), capsule-web/README.md (one sentence), capsule-swift/Project.swift (one comment), capsule-docs/src/content/docs/development/local-development.md, Cargo.lock
Will not: write the Postgres (#402) or Valkey (#403) adapters; terminate TLS; edit SLICES.md or ROADMAP.md; make the body-size limit configurable; touch capsule-server/src/routes/**, openapi.json, tests/support/mod.rs, capsule-core/**
Lane:     serialised behind #399 (import paths); #402/#403 later fill one match arm in boot.rs::assemble
Settled:  Barrel imports only from capsule-core, no capsule-wire (#399, #400). Base branch = head of PR #418 -> stacks on #399's head.

1. Deliverable boundary - how much of "boots on in-memory" ships now. (Orchestrator amendment: the planner's rejection evidence was falsified.)

  • Taken: Every subcommand plus a serve --memory that boots on in-crate in-memory adapters INCLUDING a real in-memory account adapter (register / authenticate with Argon2id / profile / password change) so capsule auth register and login work against mise run serve-memory; WriteAuthority uses the crate's existing ProvisionedAuthority if it implements the trait, else a fail-closed unprovisioned adapter; TotpStore gets a real InMemoryTotp. Fail-closed unprovisioned stubs remain only for ports with no deterministic in-memory semantics.
  • Rejected: The planner's narrower option (fail-closed stubs for all four account ports) on the ground that a password-hashing crate would be a new dependency needing a dependencies.md row - falsified: argon2 = "0.5.3" is a workspace dependency (Cargo.toml:41) consumed by capsule-core (capsule-core/Cargo.toml:104) and pinned under the Cryptography primitives doc's Argon2id row; the credential-verification helper this lane writes (auth::credential) is the one server: Postgres adapters and a conformance suite for every durable port #402's Postgres adapter reuses, so nothing is written twice.
  • Rejected: A permissive credential double in the binary - tests/support/mod.rs:1-19 says why.
  • Reverses: replace the in-memory account adapter in boot.rs::assemble's Backends::Memory arm with the unprovisioned stub.
  • Filed: the remainder is already server: Postgres adapters and a conformance suite for every durable port #402 and server: Valkey adapters for the auth-state and upload-session ports #403; link both from the PR.

2. Config file format - environment-only for this lane; --config PATH reserved and rejected with "config files are not supported yet".

  • Taken: No new crate, no dependencies.md row; precedence CLI -> env -> default.
  • Rejected: TOML via the toml crate - transitive-only in Cargo.lock (0.8.23 and 0.9.12), so a new direct dependency in a new domain with no dependencies.md row; the legacy server was env-only plus dotenvy.
  • Reverses: add toml to capsule-server/Cargo.toml plus a dependencies.md row and a Config::from_file layer beneath the env layer.

3. One binary with subcommands, not separate [[bin]]s.

  • Taken: One capsule-server binary: serve | gc | purge | scrub | gen-openapi; mise run openapi-kynos becomes cargo run -q -p capsule-server -- gen-openapi; src/bin/ removed.
  • Rejected: Separate [[bin]]s as the Salvo tree had - maintenance.md:41 calls scrub "an operator-invoked command, schedulable as a job", not a distinct executable; four binaries would each carry a config loader; capsule-cli sets the one-binary precedent.
  • Reverses: move each subcommand body into src/bin/.rs with [[bin]] stanzas; boot.rs/config.rs unchanged.

4. TLS termination - reverse-proxy only; Kynos tls feature stays off.

  • Taken: serve binds plain HTTP; local-development.md says HTTPS is the ingress's job.
  • Rejected: Native rustls termination - failure-modes.md:68 states application servers do not terminate TLS; line 72 scopes in-code TLS to the SDK client, LAN peering and egress.
  • Reverses: enable kynos features = ["tls"], add TLS_CERT_PEM/TLS_KEY_PEM/TLS_CLIENT_CA_PEM config fields and a .tls(TlsConfig::from_pem(..)) call.

5. capsule-server/src/unprovisioned.rs is not written, and the manifest slot for it is unused.

  • Taken: No fail-closed stub adapter ships. Decision 1 admits them "only for ports with no deterministic in-memory semantics", and once the tree was read there are none: WriteAuthority already has the production album::authority::ProvisionedAuthority (S-C19/S-C20), which reads the album's own protocol pin and the account's published device directory and works unchanged over InMemoryAlbums + InMemoryDeviceDirectory; ReadAuthority has serve::owned_assets(); the account ports and TotpStore get the real adapters decision 1 requires. A stub with no port to fill would be dead code in a server binary.
  • Rejected: Writing UnprovisionedAuthority anyway to keep the manifest literal. It would be an unreachable Arc<dyn WriteAuthority> that refuses, which is strictly worse than the production one the crate already has: ProvisionedAuthority refuses too — an unprovisioned album is Denied and an account with no published directory has no invariant-7 floor — and it refuses for the right reason.
  • Reverses: add src/unprovisioned.rs and swap the Backends::Memory arm's authority for it. Nothing else moves.

6. The new modules attach through auth/mod.rs, not only lib.rs.

  • Taken: auth::credential and auth::accounts_memory are submodules of auth, so the two pub mod lines and three pub use re-exports are in capsule-server/src/auth/mod.rs. The record's Touches line says "lib.rs (module lines)"; this reads that as "the module-declaration lines wherever the new modules attach", because a credential helper that hung off the crate root rather than off the module whose three ports oblige it would be in the wrong place for the reason S-C29 gives about cohesion. auth/mod.rs's own "Adapters this slice does not write" paragraph is corrected in the same edit, since it is now false.
  • Rejected: crate::credential / crate::accounts_memory at the crate root, to keep the manifest literal. It would put the account ports' adapter outside the module that declares them and leave auth/mod.rs's adapter paragraph stale.
  • Reverses: move both files to capsule-server/src/{credential,accounts_memory}.rs and their pub mod lines to lib.rs.

7. boot::assemble takes one argument, not two.

  • Taken: assemble(&Config) reads config.backends, and Backends is declared in config because which adapter family to run on is a thing an operator decides. The plan wrote assemble(config, backends).
  • Rejected: The two-argument form. It admits a caller passing a Backends the configuration did not select, which is exactly the "it silently came up in-memory" failure the whole seam exists to make impossible.
  • Reverses: move Backends to boot and re-add the parameter; the match is unchanged.

8. A configuration refusal exits 2; a non-empty read-only report exits 1.

  • Taken: cli::EXIT_MISCONFIGURED = 2 (which is also clap's usage-error code, so the two kinds of "the invocation was wrong" agree) and cli::EXIT_FINDINGS = 1. The ConfigError report goes straight to stderr rather than through tracing or color_eyre, because it is already the full multi-line list of faults and it has to be visible whatever RUST_LOG says.
  • Rejected: One non-zero code for both. A wrapper script has to be able to tell "you configured this wrongly" from "the store is not clean", and a misconfigured cron job would otherwise look like a corrupted store.
  • Reverses: return Err(...) for the configuration path and let color-eyre exit 1.

9. The failed-attempt ceiling is a constant, and email addresses are compared verbatim.

  • Taken: accounts_memory::MAX_FAILED_ATTEMPTS = 10, cleared by a success and by a password change. Addresses are byte-compared, exactly as tests/support/mod.rs's double compares them.
  • Rejected: Case-folding or otherwise normalizing the address. It is an identity policy the port does not describe, and one invented here is one server: Postgres adapters and a conformance suite for every durable port #402's adapter would have to guess at — Foo@example.test and foo@example.test are two accounts until a slice says otherwise.
  • Rejected: No lockout at all. AccountDirectory is explicit that Authentication::Locked is account state the adapter owns and that rate limiting is a counter with no port in this crate; an adapter that never returned Locked would leave a declared response unreachable on a development server.
  • Reverses: delete the failures field and the Locked arm; normalize with to_lowercase at the map key if a slice decides identity is case-insensitive.

Decisions 10-15 were taken during the review-repair pass and 16 during the confirming round; 1-9 above are unchanged.

10. The attestation seed is required for serve, and derived only in the memory profile.

  • Taken: ATTESTATION_KEY_SEED is a required setting on the durable path. Backends::Memory keeps the HKDF derivation from JWT_ED25519_DER, so serve --memory still comes up on one variable, and a development server's whole state is discarded on exit anyway.
  • Rejected: Keeping the derivation everywhere, as the first pass shipped it. It collapses two key identities the attestation contract keeps distinct: attestation/mod.rs requires the receipt-signing key to be separate from the operational key precisely so that holding the operational key cannot manufacture custody evidence, and a different HKDF label over the same input is not a separation — anyone with JWT_ED25519_DER recomputes the seed. The first pass also carried a comment asserting the separation was structural, which made the defect harder to see rather than easier.
  • Reverses: restore the derivation under Demands::Serve — one match arm in config::Config::load.

11. DEFAULT_PURGE_LIMIT = 1000 and DEFAULT_SCRUB_BUDGET = 1 GiB stay defaults rather than required flags.

  • Taken: both have a default, and --limit / --budget override them.
  • Rejected: making both required. A pass with no flag is the common operator case — a cron entry that runs the job — and a required flag turns every such entry into a number somebody had to invent. The budget's truncation is reported, so a default that was too small announces itself rather than passing a store it did not finish looking at.
  • Reverses: required = true on both clap arguments.

12. The memory profile runs QuotaLimits::unlimited().

  • Taken: no quota ceiling in the development profile.
  • Rejected: a finite development ceiling. The quota refusal path is already exercised by the in-process fixture tests, which can set any threshold they like; a finite ceiling in the dev binary would only surprise somebody uploading test data.
  • Reverses: a --quota-bytes flag with a finite default, passed to QuotaContext::new.

13. The docker dependabot ecosystem entry is deleted rather than retargeted.

  • Taken: removed. It watched /capsule-api/Containerfile, and no Containerfile exists anywhere in the active tree; an ecosystem pointed at an absent file is a permanent dashboard error rather than an update.
  • Rejected: retargeting it at /capsule-server, which has no Containerfile either.
  • Reverses: restore the entry in the change that adds the Containerfile it would watch.

14. The lockout is two settings, and an attempt made during one does not extend it.

  • Taken: LOCKOUT_WINDOW_SECONDS (default 900) and LOCKOUT_MAX_ATTEMPTS (default 10, zero refused). The window runs from the last counted failure; an attempt made while an account is locked is refused without being counted, so the deadline does not move.
  • Rejected: a permanent lockout, which is what the first pass shipped. Nothing in this server can clear one — login, reauthenticate and password all ask the directory first and refuse on Locked before verifying anything, there is no unlock operation on any surface, and no operator command reaches the state — so ten failures were a permanently lost account rather than a throttle.
  • Rejected: extending the window on every attempt, so a live guessing run stays locked out. It would hand anybody who can reach the endpoint a way to hold somebody else's account shut indefinitely, which is a denial of service on an account rather than a defence of it. What it would have bought is bounded: the accepted rate is ten guesses per window either way.
  • Rejected: the threshold staying a constant. Driving the ten-failure default through the login route costs ten Argon2id verifications, and under parallel load the gaps between them can exceed a short window — a test whose setup races its own subject, which is how the first version of that test failed. A lockout is two numbers in every server that has one; making both settings is not a testability hack.
  • Reverses: drop both settings, restore MAX_FAILED_ATTEMPTS as the only figure, and delete Account::last_failure_at.

15. VALKEY_EXTRA_FLAGS stays in environment: rather than moving to command:.

  • Taken: the flags stay where the retired deployment had them, with the evidence recorded in the file.
  • Rejected: moving them into command: on the belief that the variable is a Bitnami-only convention the official image ignores. Checked rather than assumed: valkey/valkey:9.0.4's own /usr/local/bin/docker-entrypoint.sh ends with exec "$@" $VALKEY_EXTRA_FLAGS, unquoted, so the variable word-splits into arguments of valkey-server. The convention is shared with Bitnami's images, not exclusive to them.
  • Reverses: move the flag string into command: if a future image drops the entrypoint line.

16. ATTESTATION_KEY_SEED ships commented out, with a placeholder carrying no shell metacharacters.

  • Taken: the line is commented exactly as JWT_ED25519_DER is, and the placeholder is plain text (replace-with-your-own-base64-seed). local-development.md names the two commented keys instead of counting them, and records that nothing in the template is a shell expression. Two tests/binary.rs cases parse the shipped template: one asserts no uncommented value carries $ or a backtick, the other runs scrub under exactly the settings the template ships uncommented and asserts it exits 0 without naming ATTESTATION_KEY_SEED.
  • Rejected: keeping ATTESTATION_KEY_SEED=$(CHANGE_ME) uncommented and fixing only the doc's count. The placeholder is not inert. This file is read by more than a shell — podman --env-file, compose's env_file:, systemd's EnvironmentFile=, a Kubernetes ConfigMap — and every one of them takes the line literally, so the characters $(CHANGE_ME) become the value. That is present but malformed, not absent, so decode_seed raises ConfigFault::Invalid and stops gc/purge/scrub — the three commands decision 1 promises need no key material, and which no Demands arm can excuse it for. Sourced by bash it fails the other way, printing CHANGE_ME: command not found. A doc-only fix would have left both.
  • Rejected: leaving the value uncommented but empty (ATTESTATION_KEY_SEED=). It works — Environment::var already treats empty as absent — but it says nothing about what belongs there, and the commented form both carries the example and matches the key beside it.
  • Reverses: uncomment the line and put a value back; the two template cases fail immediately if the value is a shell expression, so the guard has to be removed deliberately rather than drifted past.

Unresolved review notes

  • capsule-server/src/store/mod.rs and src/store/memory.rs describe the in-memory adapters as "a test double, never a deployment profile". After this branch they are also the --memory development profile, which is not a deployment and so does not falsify either sentence — but "test double" now understates them. Both files are outside this lane's manifest and src/store/** is server: Valkey adapters for the auth-state and upload-session ports #403's, so the wording is left for that lane rather than widened here.
  • The --memory profile cannot demonstrate a gc sweep end to end, because the collector marks on one pass and sweeps on a later one and InMemoryCollection does not survive the process. tests/binary.rs therefore asserts what is true — the dry run reports and mutates nothing, --apply reports the same and the blob is still there — and the sweep is proved by gc's own in-process unit tests. A durable mark store (server: Postgres adapters and a conformance suite for every durable port #402) is what makes the cross-invocation path assertable.
  • gc and purge report nothing when a pass fails part-way. gc::collect and gc::purge_expired return Result<Report, StoreError>, so the work done before a store failure is not recoverable by the caller: the operator gets the error on stderr, a non-zero exit, and the collector's own INFO lines as the only record of how far it got. Closing it means returning the partial report alongside the error, and capsule-server/src/gc/mod.rs is outside this lane's manifest. The state left behind is safe either way — a mark is reversible and a sweep only ever removed a blob confirmed unreferenced twice — so re-running the pass is the correct response.
  • auth::totp::InMemoryTotp and tests/support/mod.rs's double implement the same TotpStore body twice. The suite's copy adds an availability switch the coverage walk needs and is otherwise identical. Collapsing it means the double delegating to the real adapter, the way SwitchableSessions already delegates to InMemoryAuthState — but tests/support/mod.rs is outside this lane's manifest and belongs to server: the protocol headers the design puts on every route are on four operations #404, so it is recorded here rather than edited.
  • No load or timing test backs the Argon2id parameter choice (Argon2::default(), m=19456 KiB / t=2 / p=1). The parameters ride inside every PHC string, so raising them is not a flag day, but the number itself is an unmeasured default on this hardware.

Contributor Checklist

  • I agree to the Contributor License Agreement for this and future contributions.
  • My code follows the project's style guidelines according to CONTRIBUTING.md.
  • Tests pass
  • No sensitive info / secrets
  • Docs updated if needed

`capsule-server` has never been assembled outside its own test fixture, so
"every port has an adapter" and "the router builds from real ones" were
claims rather than assertions.

`config` reads the operator's settings once — command line over environment
over default — and reports **every** fault in one message, because an
operator otherwise restarts the process once per variable. What a subcommand
requires is a parameter: `gc`/`purge`/`scrub` demand a blob root and
deliberately no key material, so a maintenance host never needs the
production token-signing key. `--config PATH` is accepted and refused, which
keeps a configuration-file crate out of a domain the dependencies doc has no
row for while leaving the precedence slot named.

`boot::assemble` is the one composition root and the only place adapters are
chosen. Selection is a two-arm match on `Backends`: `--memory` takes every
deterministic in-crate adapter over a real filesystem blob store, and
anything else refuses. That makes two sentences `store/mod.rs` has carried
since `S-C29` true for the first time — Valkey is required, and the
in-memory adapters are not a deployment profile — because until now there
was no boot path to enforce either.

The account ports and the second factor had no adapter at all, which would
have left `register` and `login` answering their declared refusal on a
development server. `auth::credential` is the Argon2id helper the Postgres
adapter (#402) reuses; `auth::accounts_memory` is a real directory over it —
PHC strings, the timing-equalized miss, a lockout that a password change
clears — and `auth::totp` gains the deterministic store its port's three
properties are all expressible over. None is the permissive credential double
`tests/support/mod.rs` warns must never be linkable by a server.

Refs #401, #402, #403
…binary

`gen_openapi` was this crate's only executable, and the Salvo tree it
replaces shipped four — `capsule-gc`, `capsule-scrub`, `capsule-keygen` and
its own document dump. Four executables would each carry their own copy of
the configuration loader and the composition root, which is the duplication
`boot` exists to prevent, so this is one `capsule-server` binary with
subcommands, as `capsule-cli` already is.

`main.rs` installs error reporting and dispatches; the parsing, the log
stream and every subcommand body live in `cli`, in the library, so a test
asserts against the same code the binary runs.

Logs go to stderr. `gen-openapi` writes a path to stdout and the operator
commands will write a report there, and a subscriber sharing that stream is
how a pipeline ends up parsing a log line — the failure
`capsule-cli/tests/cull_round_trip.rs` works around with `RUST_LOG=off`.

`mise run openapi-kynos` and `openapi-check-kynos` re-point at the
subcommand. The committed `openapi.json` is untouched and
`openapi-check-kynos` passes against it, which is the strongest available
evidence the port preserved the document byte for byte.

BREAKING CHANGE: `cargo run -p capsule-server --bin gen_openapi` is now
`cargo run -p capsule-server -- gen-openapi`.

Refs #401
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 2, 2026

Copy link
Copy Markdown

Deploying capsule with  Cloudflare Pages  Cloudflare Pages

Latest commit: 459e8af
Status: ✅  Deploy successful!
Preview URL: https://659ae409.capsule-22k.pages.dev
Branch Preview URL: https://feat-server-binary-config-op.capsule-22k.pages.dev

View logs

`capsule-server serve` binds, says where it landed, and drains on a
termination signal.

The bound address is logged at INFO and also written to stdout as one
`listening on <url>` line. That is not a duplicate: `--listen 127.0.0.1:0`
is a request for the operating system to choose a port, and a caller that
asked for that has no other way to learn which one it got — making them
parse a log format `LOG_FORMAT` can change under them would be a contract
nobody wrote down.

`Shutdown::signals()` covers SIGINT and SIGTERM with a second one forcing,
and the drain deadline defaults to Kynos's own 25 seconds, under the usual
30-second orchestrator window. TLS stays off: `failure-modes.md` is explicit
that application servers do not terminate it, so Kynos's `tls` feature is not
enabled and a certificate cannot be configured by accident.

`tests/binary.rs` drives the process, which is the only way to assert what a
binary does. It proves the four properties an in-process client cannot: the
server binds and reports its port; `capsule-sdk`'s **generated** client
reaches it over TCP and reads a `server-info` record whose published signing
key is the one derived from the configured private key; an account registers
and signs in while a wrong password is refused; and SIGTERM drains to exit 0.
The three refusal cases assert the non-zero code and the message — no
`VALKEY_URL` without `--memory` names the variable, `VALKEY_URL` set names

Refs #401
`filesystem/maintenance.md` has described these as operator-invoked
commands, schedulable as jobs, since before there was a binary to invoke
them from. They existed only as library functions with no entry point.

Dry run is the default for the two that write, because the first thing an
operator does with a collector is find out what it thinks; `--apply` opts
in and the report says which posture produced it. `scrub` exits 1 on a
non-empty report and mutates nothing, which is what makes it usable as a
monitoring probe — and a truncated deep pass is reported as truncated,
because a clean report from a pass that stopped early is the one answer a
scrub must never give.

Reports name what they found rather than counting it. `CollectionReport`'s
own docs are why: "a count tells an operator that something happened without
telling them what to look at." A scrub's findings print through their own
`Debug`, so a variant added later renders as itself instead of as nothing.

`boot` splits into `assemble` and `assemble_maintenance`. That is not
tidiness: `config` claims `gc`/`purge`/`scrub` need no key material, and the
way to make that true is for the assembly they use to have none in scope,
not for it to build a token signer it then ignores. A maintenance host that
had to hold the production signing key to sweep a directory would be a
reason to put the key on a maintenance host. Both entry points build the
same `Stores`, so the application and the workers never disagree about what
is in the index.

`tests/binary.rs` covers all three against a seeded blob root: the dry run
names the unreferenced blob and leaves it, `--apply` marks it (the sweep is
a later pass, and this profile's mark store does not outlive the process),
the scrub exits 1 with the store byte-identical afterwards, `--deep` finds
the byte mismatch a structural pass cannot see, and none of them is given a
signing key.

Refs #401
… target

`mise run serve-api` and the compose stack behind it went with the Salvo
tree in `S-C59`, so there has been no way to bring a server's services up and
nothing to point a client at.

`capsule-server/compose.yaml` is Postgres 18 and Valkey 9.0.4 — the versions
dependabot is already tracking — with the retired deployment's Valkey flags
carried over verbatim, because those are the flags the session and
upload-session stores were sized against. Both services carry a healthcheck:
`serve-deps` returns as soon as compose has started the containers, so a
developer who runs `serve` immediately afterwards would otherwise race the
database's own startup. There is still no object store: the filesystem
`BLOB_ROOT` is the blob backend.

`serve-deps` and `serve` are separate tasks, because a task that silently
starts containers is a task that leaks them. `serve` supplies no environment
on purpose — it refuses and names what is missing. `serve-memory` is the one
that just works, and its fallback signing key is the published example: every
token it mints is forgeable by anyone who has read this repository, which is
exactly why that task is not `serve`.

`.env.example` documents every setting, its default, and why the default is
what it is. It ships in the release archive, because a release without it is
a binary that refuses to start and an operator reading GitHub to find out
which variables it wanted.

`release.yml` builds `capsule-server` beside `capsule` and puts both in the
one per-target archive: an operator wants the server and the CLI that talks
to it at the same version, and two downloads is two chances to mix versions.
Unix only — Windows is already best-effort for the CLI, and adding a server
build to a job allowed to fail would make "did the Windows CLI ship" harder
to answer.

`dependabot.yml`'s three `/capsule-api` entries were watching a directory
that has not existed since `S-C59`. The cargo one moves to the workspace
root, the docker-compose one to `/capsule-server`, and the `docker` one goes:
no Containerfile exists anywhere in the active tree, and an ecosystem pointed
at an absent file is a permanent dashboard error rather than an update.

Refs #401, #402, #403
…n one

`local-development.md` said that as a known gap, and it was true. It now
documents the binary, both profiles, the operator commands, where logs go and
where TLS is terminated.

It is deliberately explicit about what the development profile is *not*: the
blob store is real and everything else lives in the process, so a restart
leaves every blob an orphan the scrub will report, and `gc` can only ever
mark because the collector's two-pass design needs a mark store that outlives
the process. Both are consequences a developer would otherwise meet as a
surprise.

It is also explicit that `mise run serve` does not work yet and refuses
rather than pretending, and that `serve-memory`'s fallback signing key is
published — every token it mints is forgeable by anyone who has read this
repository.

`capsule-server/README.md`'s "no binary, no configuration loading" section
becomes "Running it", and the flat "every adapter is in-memory" claim gains
the qualifier it now needs: two of them live beside their ports rather than
in `tests/support/`, and the distinction the port docs were drawing is
between a double and an implementation.

Three stale `serve-api` citations follow: `capsule-web/README.md` asserted
the Kynos server "has no binary yet", and the CLI's default-endpoint comment
and the Swift project's local-networking comment both named a task that
retired in `S-C59`. `SLICES.md`'s three remain, and are not this change's.

Refs #401
Review repairs on the binary, configuration and operator commands.

**The attestation seed is no longer derived from the token-signing key.**
`attestation/mod.rs` requires the attestation key to be distinct from the
operational one, so that holding the operational key does not let anything
manufacture custody evidence. Deriving the seed by HKDF from
`JWT_ED25519_DER` collapsed exactly that: anyone with the token key
recomputes it and signs receipts. Worse, a comment claimed the separation was
structural. `ATTESTATION_KEY_SEED` is now required on the durable path, the
derivation survives only under `--memory` — where the whole state is
discarded on exit, so a development server still comes up on one variable —
and the comment says what is actually true.

**A lockout now decays, because nothing else could clear it.** `login`,
`reauthenticate` and `password` each ask the directory first and refuse on
`Locked` before verifying anything; there is no unlock operation on any
surface and no operator command reaches the state. Ten failures were
therefore a permanently lost account rather than a throttle. The window runs
from the last counted failure and defaults to fifteen minutes, which
`design/authentication.md` does not name, so it is written down here.
Attempts made *during* a lockout are refused without extending it: extending
would hand anyone who can reach the endpoint a way to hold somebody else's
account shut. The threshold joins the window as a setting — a lockout is two
numbers, and driving the ten-failure default through the login route would
cost ten Argon2id verifications whose spacing under load can exceed a short
window, which is a test racing its own subject.

**A maintenance command is told what it actually needs.** `gc`, `purge` and
`scrub` never demand `VALKEY_URL`, so naming it sent an operator to configure
something that would not have helped; they need `--memory`, because they
compare the index against the blob store and the in-memory index is the only
one written. The README and the docs stop showing `--memory` as optional
there.

Also: `serve-memory` binds loopback rather than every interface, since the key
it falls back to is published; the compose stack publishes Postgres and Valkey
to loopback, which is what makes `--protected-mode no` a concession rather
than an exposure; `.env.example` ships `JWT_ED25519_DER` commented out, so
`cp .env.example .env` cannot silently produce a forgeable deployment; and the
`gc`/`purge` doc comment no longer claims a partial report is printed on
failure, which the library's signature does not permit — it says what the
operator does see instead.

`VALKEY_EXTRA_FLAGS` was checked rather than assumed: `valkey/valkey:9.0.4`'s
own entrypoint ends with `exec "$@" $VALKEY_EXTRA_FLAGS`, so the flags reach
the server. The compose file now records that, with the evidence.

Refs #401, #402
…9' into feat/server-binary-config-operator-commands-401
…late

The repair commit added `ATTESTATION_KEY_SEED` to `.env.example` uncommented,
with `$(CHANGE_ME)` as the placeholder, while `JWT_ED25519_DER` beside it was
commented out. Both halves of that are wrong.

This file is read by more than a shell. `podman --env-file`, compose's
`env_file:`, systemd's `EnvironmentFile=` and a Kubernetes ConfigMap all take
a line literally — no expansion, no command substitution — so the characters
`$(CHANGE_ME)` become the *value*. That is present-but-malformed rather than
absent, so `decode_seed` raises `ConfigFault::Invalid` and stops
`capsule-server gc|purge|scrub`, which are built to need no key material at
all and which no `Demands` arm can excuse it for. Sourced by bash it fails
the other way, printing `CHANGE_ME: command not found`.

So the line is commented out exactly as the token-signing key is, and the
placeholder is plain text. `local-development.md` said "two keys are commented
out", which was false when it was written and is now true — it says which two
and why, and records that nothing in the template is a shell expression.

Two cases in `tests/binary.rs` parse the shipped template rather than
restating it: one asserts no uncommented value carries `$` or a backtick,
which is the defect's general form and catches it anywhere in the file; the
other runs `scrub` under exactly the settings the template ships uncommented
and asserts it exits 0 without naming `ATTESTATION_KEY_SEED`. Both were
confirmed to fail against the reintroduced placeholder before being kept.

Refs #401
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant