# Plan: Remote token issuance (invite flow) Status: draft, recorded 2026-07-16 (Fritz + Claude). Details the roadmap's "Remote Token Issuance: PLANNED" section; the decision between the two options below should land as an ADR before implementation. ## 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. ## The problem The goal is a low-barrier remote knowledge base. 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 Both 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 mark://host --invite `. 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 QUIC session doing the redeem. The options differ only in where invite creation runs. ## Option A: invite minted on the host (offline, zero core change) `demarkus-token invite` is a new subcommand beside `generate`: writes a pending-invite row (hashed code, scope, expiry) to tokens.toml; the server's existing file watcher hot-reloads it. Redemption is the only new server surface: a redeem operation that exchanges a valid code for a new token, appends the token hash, and marks the invite spent. - Pro: invite creation stays offline, matching the current trust model exactly (only someone with shell access to tokens.toml can create credentials). - Pro: smallest possible server delta (redeem only). - Con: the owner still SSHes per invite. The handoff is fixed (short-lived code instead of long-lived secret) but the barrier is only half-removed. For the person running one server for themselves plus a phone and a laptop, step 2 friction remains. ## Option B: invite minted over the protocol (capability delegation) Tokens gain an `invite` op. A token holding it can create invites over the wire, from any joined client, constrained by attenuation: an invite may only grant a subset of what the inviting token holds (paths within its globs, ops within its set, never `invite` itself unless explicitly re-granted). The owner never SSHes after install: 1. `install.sh` mints the owner token at install time and prints it once (the plugin's local-soul flow already does exactly this pattern). 2. Owner joins from their own machine with the owner token. 3. Owner runs `demarkus invite -paths "/team/*" -ops publish -ttl 15m mark://host`, gets a short code. 4. New user redeems via `/soul-join --invite`. This makes issuance itself a capability, which is the natural extension of the existing model: delegation with attenuation is the classic capability pattern, no accounts and no identity provider. - Pro: actually delivers low barrier; the whole lifecycle runs over the protocol. - Pro: composes with the plugin cleanly (a `/soul-invite` command needs nothing but the joined MCP server). - Con: breaks the standing constraint that the core server never writes its own tokens file. The server learns invite-create and invite-redeem operations and appends to tokens.toml (or a sibling invites file). Contained (append-only pending rows, existing audit log, existing hot-reload) but it is a real expansion of the write surface on a component deliberately kept out of the issuance business. - Con: incompatible with the read-only chroot deployment by construction. Acceptable: that mode is explicitly single-publisher, and invites there fall back to Option A's offline path or plain `generate`. - Con: a stolen owner token now mints credentials, not just content. Mitigations: attenuation cap, TTL ceiling on invites, `invite` op never granted by default, audit rows for every create/redeem, revocation unchanged (drop the label, reload). ## Target UX (added 2026-07-16) The picture the ADR should argue 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 - Moment 1 needs only the redeem endpoint (install.sh runs on the host, so invite creation is local). A degraded moment 1, where the join string carries a raw token instead of an invite code but keeps the fingerprint pinning, needs no server change at all and is the correct first ship. - Moments 2 and 4 are full Option B: invite op, redeem endpoint, plus a list/revoke surface (a further write-surface expansion the ADR must weigh explicitly). - Designing the join-string format and TOFU pinning once, now, makes the cheap version a strict prefix of the full version instead of throwaway work. ## What both options require regardless - Redeem surface on the server (the one unavoidable core change): a verb or well-known endpoint that runs pre-auth (the redeemer has no token yet), rate-limited like everything else, constant-time code compare, hashed codes at rest. - tokens.toml schema addition for pending invites (hashed code, scope, expiry, spent marker) that old servers ignore gracefully. - `/soul-join --invite` in the plugin, plus CLI parity (`demarkus join --invite` or equivalent). - Docs: the security page's threat model gains an invites section. ## Comparison | | A: host-minted | B: protocol-minted | |---|---|---| | Core server delta | redeem only | redeem + create + `invite` op | | Owner UX per invite | SSH | one command from anywhere | | Trust model change | none | issuance becomes a delegable capability | | Read-only chroot | works (offline create) | create unavailable, redeem N/A | | Fits "low barrier" goal | partially | fully | ## Recommendation B, scoped tightly: `invite` op, attenuation enforced, redeem pre-auth endpoint, append-only writes, audit everything. A is not a stepping stone to B (its offline-create path survives inside B unchanged, since `demarkus-token invite` on the host is just create with a local writer), so building A first costs nothing but delivers little. The deciding question for the ADR: is "the server never writes credentials" a load-bearing security property or an artifact of history? The read-only mode already answers how to keep the hard-line posture available: run that mode and issuance stays fully offline. Precedent framing for the ADR: verify-only servers (nginx, sshd, htpasswd-style offline tools) never issue; servers that own 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). The question is which family demarkus belongs to, and read-only mode keeps the verify-only posture available as a mode rather than an identity. Matrix's registration-token spec is the closest living design and worth reading for redeem-endpoint details (rate limits, token shape, expiry semantics). Sequencing note (2026-07-16): the convenience case alone does not justify B. Ship the no-core-change prefix first (install.sh join string + fingerprint pinning, join-string flag on demarkus-token), and let demonstrated shared-soul demand carry the ADR for the full invite flow. ## Open questions - Redeem transport: new protocol verb (spec change, CC0 protocol surface) vs a well-known reserved path handled outside the verb table. A verb is honest; a magic path is expedient. - Should redeemed tokens be able to carry `invite` at all (transitive delegation), or is one level of delegation the ceiling? - Join-string grammar: `host#inv-` as sketched in the target UX, where the fragment carries code + fingerprint; exact encoding TBD (word-code vs base32, fingerprint truncation length). - Broker relationship: does the broker's minting core eventually reuse this (invite as the broker's non-OIDC fallback), or do they stay parallel? - Rate limiting and lockout on redeem attempts (code brute force) beyond the existing per-IP limiter.