# Plan: Remote token issuance (invite flow) Status: draft, recorded 2026-07-16, revised 2026-07-17 (Fritz + Claude). Details the roadmap's "Remote Token Issuance" section. Decisions: the core server does not change; issuance is built around it (Option C, the sidecar). Step 1 (join URLs) shipped token-only, merged 2026-07-17 as PR #261 (commit 4c5f90d); cert pinning was built, then cut before merge (see the pinning decision below). ## One-line Let a server owner onboard a new person or device to a remote soul or standalone world without SSH and without hand-carrying a long-lived raw token, with zero core-server change. ## The problem The goal is a low-barrier remote knowledge base with knowledge-system-grade onboarding (invite people in) without requiring k8s or OIDC. The onboarding chain today: 1. Server on a VPS: `install.sh`. Fine. 2. Mint a token: SSH to the host, `demarkus-token generate`. Friction. 3. Deliver the raw token to the user: chat/email paste of a long-lived secret. The weak link. 4. `/soul-join mark://host --token `. Fine. `/soul-join` consumes a token but cannot mint one (the server's tokens.toml is not local to the plugin). The broker solves issuance for k8s universes (Phase 6.3: OIDC, mints scoped tokens, writes hashes to Secrets) but is far too heavy for a personal or small-team standalone server. The gap is the middle tier: one owner, a few collaborators or devices, no k8s, no OIDC. ## Shared shape: one-shot invites All options use the same primitive. An invite is a short-lived, single-use code that redeems for a freshly minted scoped capability token: - Owner creates an invite with a label, path globs, ops, and a TTL (default minutes, not days). - New user redeems it once: `/soul-join `. The join flow receives the raw token, writes it to the 0600 token file as today, and the invite burns. - Expired or already-redeemed codes fail closed. Every create and redeem is audit-logged with labels. - Nothing secret in transit outlives the TTL; the raw token is generated at redemption and travels once, over the session doing the redeem. The options differ in where invite creation and redemption run. ## Decision (2026-07-17): Option C, the sidecar Constraint set by Fritz: knowledge-system-level invite ability on plain VPS deployments, but the core server does not change; build around it. That eliminates A and B (both put the redeem surface inside the server) and selects the sidecar shape: **A small issuance service runs beside demarkus-server on the same host.** The server keeps its exact current contract: read tokens.toml, hot-reload on file change, never write credentials. The sidecar is the one process that writes tokens.toml. - **Redeem**: the sidecar exposes the redeem endpoint. On a valid code it mints a token, appends the hash to tokens.toml (the server's file watcher picks it up), marks the invite spent, returns the raw token once. - **Invite create**: authorized over the wire by capability. The sidecar can enforce the `invite` op itself: it reads tokens.toml, so it can verify a presented token's hash and check its ops and path scopes locally. Attenuation (invite grants a subset of the inviting token's paths/ops) is sidecar logic. No server involvement. - **List/revoke**: same authorization; revoke drops the label from tokens.toml, hot-reload does the rest. - **Install**: `install.sh` installs and units both processes. The sidecar can be tiny (one static binary in `tools/`), and deployments that do not want it simply omit it. Why this wins: - Zero core change is structural, not a promise: the server binary is byte-identical. The "server never writes credentials" property survives as fact rather than as a mode. - Read-only chroot posture is fully preserved: do not install the sidecar. - It is the broker's minting core with a file writer instead of a k8s Secret writer, which answers the standing broker-relationship question: they converge. One issuance codebase, two writer backends, two auth front doors (OIDC for orgs, capability tokens for standalone). - Precedent: this is the verify-only-server family (nginx + htpasswd, sshd + authorized_keys) with the issuing agent promoted from an offline CLI to a co-located service. The server stays nginx-family; the sidecar is where Postgres-family semantics (grants, delegation) live. Costs, stated honestly: - A second process and a second listening port on the host (the sidecar cannot share UDP 6309 without a server change, which is off the table). - The sidecar is a credential-writing service and must be hardened like one: its own systemd lockdown (write access only to tokens.toml), rate-limited redeem, constant-time compares, hashed codes at rest, audit to stdout. - Availability: if the sidecar is down, invites fail but the server is untouched. ## Decision (2026-07-17): cert pinning deferred Step 1 was first built with TOFU cert-fingerprint pinning (`fp=` in the join URL, per-host pin verification in the client, pin storage in tokens.toml and the souls catalog). It was cut before merge and the branch shipped token-only. Reasoning: - Pinning introduced a third trust state (CA / pinned / insecure) with precedence rules, a rotation-to-re-invite operational coupling, migration edge cases (one bug found and fixed before merge: a stale pin survived a pinless CLI re-join), mixed-version sharp edges, and hand-rolled `VerifyPeerCertificate` in the security-critical path. Most of the branch's complexity and all of its follow-up questions were pinning questions. - HPKP precedent: browsers added cert pinning and removed it in 2018 because operators bricking their own users on rotation outweighed the MITM protection. SSH-style TOFU survives only where no CA alternative exists. - The constituency is narrow: hosts with a domain get Let's Encrypt from install.sh; pinning only serves IP-only/LAN/tailnet servers. Even soul.demarkus.io has a domain and runs `--insecure` for other reasons; fixing that is likely the better investment. - The cut is clean and reversible: `fp=` was an optional fragment key. Token-only Parse rejects unknown keys, so a future fp-bearing URL fails loudly rather than silently dropping trust material. If pinning returns, it belongs inside the sidecar's redeem handshake (the redeem response can carry the cert fingerprint out of band), not as user-visible trust state. Consequence: self-signed servers keep `--insecure` (status quo). The dev self-signed cert is ephemeral (regenerated in memory per server start), so it was never pinnable anyway; Let's Encrypt leaves rotate roughly every 60 days and must never be pinned. Both facts are recorded here so a future pinning revival prices them in. ## Option A (superseded): invite minted on the host, redeem in the server `demarkus-token invite` writes a pending-invite row to tokens.toml over SSH; the server gains a redeem operation. Rejected: still requires a core-server redeem surface, and the owner still SSHes per invite. ## Option B (superseded): invite minted over the protocol Tokens gain an `invite` op enforced by the server; create and redeem are server operations. Full capability delegation with attenuation, best-integrated UX, but the server learns to write its own credentials file. Rejected 2026-07-17 by the build-around constraint. Its delegation model (attenuation, `invite` op semantics) carries over into the sidecar unchanged; only the enforcing process moved. ## Target UX The picture to build toward, designed backwards from the user's fingers. Governing principle: **one string is the whole ceremony.** Every handoff is a single copy-paste (or QR) carrying host and credential; no flag assembly, no token ever displayed once invites exist. ### Moment 1: stand up the server `install.sh` ends with a join line. Today (shipped in PR #261, token form): ```text Join this server from another machine (paste one line): Claude Code (demarkus-memory plugin): /soul-join mark://kb.example.com#token=... CLI: demarkus join 'mark://kb.example.com#token=...' ``` With the sidecar, the fragment becomes a burnable invite code and the raw token is never shown. ### Moment 2: add a device or person, from where you already sit ```text /soul-invite phone # device, full scope, 15m TTL /soul-invite alice --paths /team/** --ttl 1d ``` Emits a join string plus QR. Invites are created from inside a joined session, never on the server. The string is worthless after TTL and dies on first use. The redeemer runs the same `/soul-join `; the label is auto-derived, editable, not a required decision. Requires sidecar create. ### Moment 3: transport trust Deferred with pinning (see the pinning decision). Self-signed servers use `--insecure`; hosts with domains use Let's Encrypt via install.sh. If a verified-join story for domainless hosts returns, it rides the sidecar redeem handshake rather than the join string. ### Moment 4: see and undo ```text /soul-members lists labels, scopes, last write (from the audit log) /soul-revoke phone drops the label, hot-reload ``` Symmetric with invite: the full membership lifecycle from the client, never touching the host after install. Requires sidecar list/revoke. ### Failure UX An expired or spent code says "This invite has expired, ask the owner to run /soul-invite again", never a status code. ## What Option C requires - The sidecar binary (`tools/`, working name open: demarkus-concierge / demarkus-invites): redeem (pre-auth, rate-limited, constant-time compare, hashed codes at rest), create/list/revoke (capability-token auth verified against tokens.toml, attenuation enforced), audit log to stdout, systemd unit with write access only to tokens.toml. - tokens.toml schema addition for pending invites (hashed code, scope, expiry, spent marker). The server ignores unknown sections; verify this holds and pin it with a test so old servers stay compatible. - install.sh: install + unit the sidecar, open its port, emit the moment-1 invite join string and QR. - Plugin: `/soul-join` invite redemption (`inv=` fragment key), `/soul-invite`, `/soul-members`, `/soul-revoke`. - CLI parity: `demarkus invite` / invite-aware `demarkus join`. - Docs: the security page's threat model gains an invites/sidecar section. ## Comparison (historical) | | A: host-minted | B: in-server | C: sidecar (chosen) | |---|---|---|---| | Core server delta | redeem op | redeem + create + `invite` op | none | | Owner UX per invite | SSH | one command from anywhere | one command from anywhere | | Trust model change | none | server issues credentials | co-located service issues credentials | | Read-only chroot | works | capability present but disabled | posture intact (omit sidecar) | | Extra process/port | no | no | yes | | Broker convergence | no | parallel implementations | same minting core, second backend | | Fits "low barrier" goal | partially | fully | fully | ## Precedent notes (for the record) Verify-only servers (nginx, sshd, htpasswd-style offline tools) never issue; servers owning multi-user state issue in-band (Postgres CREATE ROLE with GRANT OPTION is delegation-with-attenuation; Matrix registration tokens are one-shot invites in production for years; Tailscale pre-auth keys likewise). Option C keeps demarkus-server in the verify-only family and gives the issuing agent its own process. Matrix's registration-token spec remains the closest living design for redeem-endpoint details (rate limits, token shape, expiry semantics). ## Sequencing 1. **DONE: join URLs, token-only (merged 2026-07-17, PR #261, commit 4c5f90d)**: join URL `mark://host#token=...`; `demarkus-token join` (pipes from generate); `demarkus join` CLI subcommand; install.sh emits the ready-to-paste line; `/soul-join` (claude-code 0.13.0 + pi-memory 0.13.0) accepts join URLs. Four CodeRabbit review rounds hardened joinurl into allowlist validation (DNS-charset hostnames, port range, no userinfo/query/path/IPv6-literal/duplicate keys; all non-HTTPS SoulJoin input routed through Parse). Pinning built then cut (see decision above). 2. **Sidecar v1+v2 together**: redeem plus authenticated create. Redeem-only was considered and demoted: it does not change the owner's SSH life; authenticated create is the sidecar's real MVP. 3. **Sidecar v3**: list/revoke + plugin commands (`/soul-members`, `/soul-revoke`). 4. **Convergence**: fold the sidecar's minting core and the broker's into one package with two writer backends. ## Open questions - Sidecar transport and port: HTTPS on a TCP port (simplest for firewalls, reuses the server's cert) vs QUIC on a second UDP port (uniform stack). Leaning HTTPS. - Sidecar packaging: new binary vs a `demarkus-broker` mode (`--backend file --auth capability`). Convergence argues for the latter eventually; a small standalone binary may ship faster. - Transitive delegation: can a redeemed token carry `invite`, or is one level the ceiling? - Invite fragment grammar: `inv=` key beside `token=`; word-code vs base32; whether the string names the sidecar port or it is well-known. - Redeem brute-force handling beyond per-IP rate limits (lockout per invite code, code entropy floor). - Whether the sidecar redeem response should carry the server cert fingerprint out of band (a contained revival of verified joins for domainless hosts).