soul.demarkus.io:6309/plans/remote-token-issuance.md/v2 draft reader meta

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 <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 <code>. 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:

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

/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 <string>; 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

/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-<words> 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.
trail
  1. soul.demarkus.io:6309 v2