# 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: PLANNED" section. Decision 2026-07-17: the core server does not change; issuance is built around it (Option C). The A/B analysis is kept below for the record. ## 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. The full Target UX below lands without touching the server. - **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. Cleaner than B, where the capability existed but was disabled. - 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). The Phase 6.3 constraint ("zero changes to protocol or core server; packaging and lifecycle") is exactly satisfied. - 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). Firewall setup gains one rule; install.sh owns it. - 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. Acceptable coupling; they share a host anyway. ## 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 (added 2026-07-16) 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, code, and trust info; no flag assembly, no `--insecure`, no token ever displayed. ### Moment 1: stand up the server `install.sh` ends with an owner invite, not a raw token: ```text Join from anywhere (expires in 24h, single use): /soul-join kb.example.com#inv-tulip-mango-8842 [QR code] ``` Paste into Claude Code on a laptop or scan with a phone. The raw token is minted at redemption and 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 (alice-laptop), editable, not a required decision. ### Moment 3: trust without the --insecure wart The join string embeds the server's cert fingerprint. Redemption pins it, trust-on-first-use, like SSH known_hosts: self-signed servers get verified joins with zero CA setup, and the invite string becomes tamper-evident for free. This fixes a separate standing UX sore (even soul.demarkus.io needs `--insecure` today). ### 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. ### Failure UX An expired or spent code says "This invite has expired, ask the owner to run /soul-invite again", never a status code. ### Cost mapping (revised for Option C) - Every moment is sidecar + plugin + install.sh work; the server changes in none of them. - Moment 1 needs only the sidecar's redeem endpoint (invite creation at install time is local to the host). - Moments 2 and 4 need the sidecar's authenticated create/list/revoke endpoints plus `/soul-invite`, `/soul-members`, `/soul-revoke` in the plugin. - The degraded moment 1 (join string carries a raw token instead of an invite code, fingerprint pinning kept) needs no sidecar at all and remains the correct first ship; the join-string grammar and TOFU pinning are designed once and shared, so it is a strict prefix of the full version. ## 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 join string and QR. - Plugin: `/soul-join ` parsing (code + fingerprint fragment), `/soul-invite`, `/soul-members`, `/soul-revoke`. - CLI parity: `demarkus invite` / `demarkus join --invite` or equivalent. - 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. **Now, no sidecar**: install.sh emits a ready-to-paste join line (raw token form) with cert fingerprint; `demarkus-token generate --join ` emits the same for later devices; plugin learns the join-string grammar and TOFU pinning. Near-free, ships the solo path. 2. **Sidecar v1**: redeem only. Moment 1 becomes a true invite (no raw token at install). 3. **Sidecar v2**: authenticated create/list/revoke + plugin commands. Moments 2 and 4; full knowledge-system-grade onboarding on a plain VPS. 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? - Join-string grammar: `host#inv-`, fragment carries code + fingerprint; exact encoding TBD (word-code vs base32, fingerprint truncation length). Also: does the string need to name the sidecar port, or is it fixed/well-known? - Redeem brute-force handling beyond per-IP rate limits (lockout per invite code, code entropy floor).