soul.demarkus.io:6309/plans/remote-token-issuance.md/v6 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" section. Decisions: the core server does not change; issuance is built around it (Option C). Step 1 (join URLs) merged 2026-07-17 as PR #261 (commit 4c5f90d). Cert pinning was built, then cut before merge (see the pinning decision). Latest revision: the sidecar is not a new binary; it is the broker in single-host mode, driven by the parity goal below.

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, and with the same library + broker experience at every deployment tier.

The problem

The goal is a low-barrier remote knowledge base with knowledge-system-grade onboarding (invite people in) without requiring k8s or OIDC-heavy setup. The onboarding chain before step 1:

  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 has only ever been packaged for k8s. The gap is the middle tier: one owner, a few collaborators or devices, no cluster.

Goal (2026-07-17): tier parity

Requirement set by Fritz: personal, team, and non-k8s deployments should have parity with the k8s universe, including the library and the ability to invite people. The full experience (/knowledge-join, library SSO, in-browser editing when the cataloging desk lands, invites) must not be k8s-only.

What actually binds the broker to k8s today, and the non-k8s equivalent:

Broker dependency k8s form non-k8s equivalent
Token writer hashes into k8s Secrets append to tokens.toml; the server already file-watches and hot-reloads
Reload signaling SIGHUP pods unnecessary; file watch covers it
World dial addresses cluster DNS in worlds list localhost:port or any host; already just config
Identity OIDC OIDC unchanged; a team points it at Google/GitHub with one OAuth app registration
Deployment Helm + ApplicationSet systemd units or docker-compose via install.sh

The library needs nothing: it only knows a broker URL and is already location-agnostic.

Caveat to verify first: audit the broker for quiet k8s assumptions beyond the writer (Secret reads at startup, in-cluster config, probe endpoints). Expected shallow; expected is not verified.

Decision (2026-07-17): the sidecar is the broker in single-host mode

The earlier revision planned a new sidecar binary in tools/ with the broker convergence as a final step. The parity goal inverts that: teach the existing broker a file-writer backend and package it for a VPS, and the "sidecar" falls out as the broker running beside the server. Convergence moves from last step to first. One issuance codebase from day one; two writer backends (Secrets, tokens.toml); the same binary at every tier.

Properties preserved from the original Option C decision:

  • Zero core-server change is structural: the server binary stays byte-identical; it reads tokens.toml and hot-reloads, never writes credentials.
  • Read-only chroot posture intact: do not run the broker on that host.
  • Precedent framing unchanged: the server stays in the verify-only family (nginx + htpasswd, sshd + authorized_keys); the broker is where Postgres-family semantics (grants, delegation, invites) live.
  • Hardening obligations unchanged: the broker-on-VPS is a credential-writing service; systemd lockdown with write access only to tokens.toml, rate-limited redeem, constant-time compares, hashed codes at rest, audit to stdout.
  • A second process and port on the host; if the broker is down, invites and SSO fail but the server is untouched.

The identity gap (the one real design question)

OIDC-via-GitHub/Google is fine for teams. The zero-external-dependency personal tier (no Google/GitHub in the loop) needs one of:

  • A minimal built-in identity in the broker (the broker grows a small IdP; design care so it stays optional and does not bloat the org path), or
  • Capability-token auth as the broker's second front door for that tier (the original sidecar sketch: the broker verifies a presented token's hash against tokens.toml and enforces an invite op with attenuation; no accounts at all).

Leaning: capability front door first (no new identity surface; pure reuse of the auth model), built-in IdP only if the library's SSO UX demands cookies-and-login for personal deployments (the library needs a session; a capability token pasted into the library once could bootstrap one - to be designed with the cataloging desk).

Decision (2026-07-17): cert pinning deferred

Step 1 was first built with TOFU cert-fingerprint pinning (fp= in the join URL, per-host pin verification in the client, pin storage in tokens.toml and the souls catalog). It was cut before merge and the branch shipped token-only. Reasoning:

  • Pinning introduced a third trust state (CA / pinned / insecure) with precedence rules, a rotation-to-re-invite operational coupling, migration edge cases (one bug found and fixed before merge: a stale pin survived a pinless CLI re-join), mixed-version sharp edges, and hand-rolled VerifyPeerCertificate in the security-critical path. Most of the branch's complexity and all of its follow-up questions were pinning questions.
  • HPKP precedent: browsers added cert pinning and removed it in 2018 because operators bricking their own users on rotation outweighed the MITM protection. SSH-style TOFU survives only where no CA alternative exists.
  • The constituency is narrow: hosts with a domain get Let's Encrypt from install.sh; pinning only serves IP-only/LAN/tailnet servers.
  • The cut is clean and reversible: fp= was an optional fragment key. Token-only Parse rejects unknown keys, so a future fp-bearing URL fails loudly rather than silently dropping trust material. If pinning returns, it belongs inside the redeem handshake (the redeem response can carry the cert fingerprint out of band), not as user-visible trust state.

Consequence: self-signed servers keep --insecure (status quo). The dev self-signed cert is ephemeral (regenerated in memory per server start), so it was never pinnable anyway; Let's Encrypt leaves rotate roughly every 60 days and must never be pinned.

Shared shape: one-shot invites

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.
  • Attenuation: an invite may only grant a subset of the inviting credential's paths/ops; invite itself is never granted by default.

Options A and B (superseded, for the record)

  • A: invite minted on the host, redeem in the server. demarkus-token invite writes a pending row over SSH; the server gains a redeem op. Rejected: core-server surface, and the owner still SSHes per invite.
  • B: invite minted over the protocol. Tokens gain an invite op enforced by the server. Best-integrated UX but the server learns to write its own credentials file. Rejected by the build-around constraint. Its delegation model carries into the broker unchanged.

Target UX

Governing principle: one string is the whole ceremony. Every handoff is a single copy-paste (or QR) carrying host and credential.

Moment 1: stand up the server

install.sh ends with a join line. Today (shipped in PR #261, token form):

Join this server from another machine (paste one line):
  Claude Code (demarkus-memory plugin):  /soul-join mark://kb.example.com#token=...
  CLI:                                   demarkus join 'mark://kb.example.com#token=...'

With invites, the fragment becomes a burnable code and the raw token is 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, created from inside a joined session, never on the server. Requires broker create.

Moment 3: transport trust

Deferred with pinning. Self-signed servers use --insecure; hosts with domains use Let's Encrypt via install.sh. A verified-join revival for domainless hosts rides the redeem handshake.

Moment 4: see and undo

/soul-members      lists labels, scopes, last write (from the audit log)
/soul-revoke phone drops the label, hot-reload

Requires broker list/revoke.

Failure UX

An expired or spent code says "This invite has expired, ask the owner to run /soul-invite again", never a status code.

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). Matrix's registration-token spec remains the closest living design for redeem-endpoint details (rate limits, token shape, expiry semantics).

Sequencing (revised for parity)

  1. DONE: join URLs, token-only (merged 2026-07-17, PR #261, commit 4c5f90d): join URL mark://host#token=...; demarkus-token join; demarkus join; install.sh ready-to-paste line; /soul-join (claude-code 0.13.0 + pi-memory 0.13.0) accepts join URLs. Four review rounds hardened joinurl into allowlist validation. Pinning built then cut.
  2. Broker single-host mode: k8s-assumption audit, then the tokens.toml writer backend, then non-k8s packaging (systemd units / docker-compose, install.sh --with-broker --with-library). Exit criteria: /knowledge-join and library SSO work against a VPS deployment with a Google/GitHub OAuth app.
  3. Invites in the broker minting core: create/redeem with attenuation, TTL, audit; both writer backends; inv= join-URL fragment; /soul-invite + invite-aware /soul-join and demarkus join; install.sh moment-1 emits an invite instead of a raw token.
  4. Membership: list/revoke endpoints + /soul-members, /soul-revoke.
  5. Zero-dependency personal identity: capability-token front door on the broker (and only if the library UX demands it, a minimal built-in IdP).

Open questions

  • Broker k8s-audit findings (blocking step 2 scope): Secret reads, in-cluster config, probes.
  • Broker repo split timing: roadmap has the broker splitting to latebit-io/demarkus-broker after Phase 6.4; single-host packaging in install.sh interacts with where the binary lives and releases from.
  • Transitive delegation: can a redeemed token carry invite, or is one level the ceiling?
  • Invite fragment grammar: inv= beside token=; word-code vs base32; whether the string names the broker port or it is well-known.
  • Redeem brute-force handling beyond per-IP rate limits (lockout per invite code, code entropy floor).
  • Whether the redeem response carries the server cert fingerprint out of band (contained revival of verified joins for domainless hosts).
  • Library session bootstrap for the capability front door tier (interacts with the cataloging desk design).
trail
  1. soul.demarkus.io:6309 v6