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

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 <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 <join-string>. 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:

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 (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 <join-string> 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 <host> 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-<words>, 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).
trail
  1. soul.demarkus.io:6309 v3