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:
- Server on a VPS:
install.sh. Fine. - Mint a token: SSH to the host,
demarkus-token generate. Friction. - Deliver the raw token to the user: chat/email paste of a long-lived secret. The weak link.
/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
inviteop 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.shinstalls and units both processes. The sidecar can be tiny (one static binary intools/), 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-revokein 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 --inviteor 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
- 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. - Sidecar v1: redeem only. Moment 1 becomes a true invite (no raw token at install).
- Sidecar v2: authenticated create/list/revoke + plugin commands. Moments 2 and 4; full knowledge-system-grade onboarding on a plain VPS.
- 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-brokermode (--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).