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

Plan: Remote token issuance (invite flow)

Status: draft, recorded 2026-07-16, revised through 2026-07-17. Steps 1 (join URLs, PR #261) and 2 (broker single-host mode, PR #262 storage interface + PR #263 packaging) are shipped. Decision 2026-07-17: invites are deprecated as redundant with the broker (see below); the remaining open item is the no-IdP capability front door, and only if demand appears. Earlier invite design is retained 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, and with the same library + broker experience at every deployment tier.

Decision (2026-07-17): invites are redundant with the broker

Question raised by Fritz: with the broker now running in single-host mode, do we still need invites? No.

The broker is OIDC-fronted by construction: it cannot start without an issuer, client id/secret, and signing key (all required by config validation). So "running the broker" already means "having OIDC onboarding," and OIDC is strictly better than an invite code on every axis that matters:

invite code broker OIDC
onboard a person paste a redeemable code /knowledge-join <url> + existing Google/GitHub login
identity anonymous bearer real account
revoke delete the token disable the account, or edit the world's allow list
secret in transit yes (the code) no
add a collaborator mint + send IdP invite + allowlist entry

Adding a capability-invite minting flow to the broker would duplicate OIDC with a weaker mechanism. The plan's earlier step 3 ("invites in the broker minting core") therefore contradicts the broker's reason to exist. It is dropped.

Where invites had a real home was the brokerless direct-QUIC tier (a personal or small soul with no OIDC, joined via /soul-join mark://host --token). There, step 1's join URL already covers roughly 80% of the problem. The only gap a full invite flow would close is the "the paste carries a long-lived raw token" weakness. That is a narrow, brokerless polish, so building it "in the broker" was the wrong home regardless. It is not worth building now.

Resulting tier map (both tiers already shipped)

  • Have an IdP (personal or team): run the broker (single-host or k8s). OIDC onboarding via /knowledge-join; library SSO; per-world allow gates. Done.
  • No IdP, single or few users: direct-QUIC soul + the join URL (/soul-join, demarkus join). Done.

The only genuinely open case is multi-user without any IdP (someone who wants several writers but refuses to run Google/GitHub/Okta). That is the capability front door below, demand-gated.

The one remaining question: capability front door (no-IdP multi-user)

For the operator who wants multiple writers but no external IdP, the broker could accept a demarkus capability token as a second front door alongside OIDC: verify a presented token's hash against tokens.toml, enforce an invite-style op with attenuation, no accounts. This is the only place the earlier invite/delegation design still applies, and it is the broker's second auth surface rather than a parallel issuance path.

Do not build it speculatively. Build it when a real deployment needs multi-writer-without-IdP; until then the two shipped tiers cover the field. If it is built, the attenuation and one-shot-code shape recorded under "Shared shape" and "Options A and B" below is the starting design, and the open questions there (transitive delegation, code grammar, brute-force) become live.

The problem (original framing, for the record)

The goal was a low-barrier remote knowledge base with knowledge-system-grade onboarding 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.

Step 1 (join URLs) collapsed 2 to 4 into one paste. The broker (step 2) removed the whole chain for anyone with an IdP. What remained, and is now deprecated, was making the brokerless paste itself expiring and single-use.

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. Achieved by step 2: the broker runs on a plain VPS in file-backend mode, so /knowledge-join, library SSO, and per-world authorization all work without a cluster. The "invite people" half of the parity goal is satisfied by OIDC onboarding, not by a separate invite mechanism (see the deprecation decision).

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

The earlier revision planned a new sidecar binary in tools/. The parity goal inverted that: teach the existing broker a file-writer backend and package it for a VPS, and the "sidecar" is just the broker running beside the server. Shipped as PR #262 (SecretStore interface + file backend) and PR #263 (install.sh --with-broker --with-library, hardened systemd units, docs). Properties preserved: zero core-server change (the server binary is byte-identical, reads tokens.toml, hot-reloads, never writes credentials); read-only chroot posture intact (omit the broker); the server stays in the verify-only family while the broker owns credential writes.

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, pin storage in tokens.toml and the souls catalog). Cut before merge; the branch shipped token-only. Reasoning: pinning introduced a third trust state (CA / pinned / insecure) with rotation-to-re-invite coupling, migration edge cases, mixed-version sharp edges, and hand-rolled VerifyPeerCertificate; HPKP precedent (browsers added then removed pinning in 2018 over operators bricking their own users on rotation); narrow constituency (domain hosts get Let's Encrypt, so only IP/LAN/tailnet servers benefit). The cut is clean: fp= was an optional fragment key and token-only Parse rejects unknown keys, so a future fp-bearing URL fails loudly. If pinning returns it belongs inside a redeem handshake, not as user-visible trust state. Consequence: self-signed servers keep --insecure; the dev cert is ephemeral (regenerated per start) so was never pinnable; Let's Encrypt leaves rotate ~60 days and must never be pinned.

Shared shape: one-shot invites (deprecated, retained for the capability-front-door case)

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

This shape is only relevant if the capability front door is built. For any broker deployment, OIDC replaces it.

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. Rejected by the build-around constraint. Its delegation model would carry into the capability front door unchanged if that is ever built.

Target UX (deprecated with invites; retained for the record)

The "one string is the whole ceremony" ceremony. Moment 1 (install.sh join line) shipped in PR #261 as a token URL. Moments 2 and 4 (/soul-invite, /soul-members, /soul-revoke) required the broker invite/membership surface that is now dropped; for a broker deployment the equivalents are the IdP invite plus the world's allow list, and /knowledge-join for the joiner. Moment 3 (transport trust) was deferred with pinning.

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; Matrix registration tokens; Tailscale pre-auth keys). The broker sits in the second family already, via OIDC. A capability front door would be the same family with a token instead of an account.

Sequencing (current)

  1. DONE: join URLs, token-only (PR #261, commit 4c5f90d). mark://host#token=...; demarkus-token join; demarkus join; install.sh join line; /soul-join in claude-code 0.13.0 + pi-memory 0.13.0. Allowlist-hardened over four review rounds. Pinning built then cut.
  2. DONE: broker single-host mode (PR #262 storage interface + file backend; PR #263 install.sh --with-broker --with-library, hardened units, docs/site/deployment/single-host.md). Exit criteria met: the broker runs on a plain VPS with a Google/GitHub OAuth app; library serves read-only quic mode.
  3. Invites in the broker minting core DROPPED (redundant with broker OIDC; see the deprecation decision).
  4. Membership (list/revoke, /soul-invite, /soul-members, /soul-revoke) DROPPED (OIDC + allow list cover it).
  5. DEMAND-GATED: capability front door for no-IdP multi-user. Build only when a real deployment needs multiple writers without any external IdP. Design starting point: the "Shared shape" and "Options A/B" sections above.

Open questions (only live if the capability front door is built)

  • Transitive delegation: can a redeemed token carry invite, or is one level the ceiling?
  • Code grammar: an inv= fragment 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 (per-code lockout, code entropy floor).
  • Library session bootstrap for the capability front door tier (interacts with the cataloging desk design).

Broker k8s audit (2026-07-17)

Read-only audit of tools/demarkus-broker for single-host mode. Verdict: cheap. All k8s API usage funnels through one function and touches only Secrets and a leader-election Lease.

Must abstract (the whole k8s surface):

  • mutateSecret (authz.go:190-256) is the single Secret read-modify-write primitive; refresh-token persistence (refresh.go), write-token raw storage and world tokens.toml hash writes (world_write_tokens.go:120,183) all route through it. One SecretStore-style interface with a k8s and a file implementation covers everything. Estimate: one interface, ~5 files (authz, refresh, world_write_tokens, sweeper, server) plus main.go wiring.
  • Sweeper leader election (sweeper.go:65-88, Coordination Lease): disable in single-host mode (sweeper.disabled already exists).
  • main.go always builds a k8s client (in-cluster or -kubeconfig; main.go:269-284); needs a no-k8s mode gate.

Config-only:

  • World dial addresses default to <name>.<ns>.svc.cluster.local (world_pool.go:93-98) but WorldConfig.InternalAddress already overrides per world.
  • BrokerNamespace, world Namespace/TokensSecret, RefreshTokensSecret are required by validation (config.go:523,805-808); relax in single-host mode.

Already portable, no change: OIDC (creds via plain env vars OIDC_CLIENT_SECRET / BROKER_SIGNING_KEY, config.go:507-514; discovery is pure HTTP), MCP gateway native TLS (main.go:159-168), healthz/readyz (no k8s probing), device/auth-code stores (in-memory; restart just drops in-flight logins), POD_NAME already falls back to hostname.

Surprises (three, all material):

  1. No SIGHUP exists anywhere in the broker. The documented "SIGHUPs on revocation" (Phase 6.3 text) is not implemented; k8s reload is kubelet Secret-mount re-projection, absorbed by a FirstMint retry loop. On a VPS this self-heals: the file backend writes the same tokens.toml the server already file-watches, so single-host propagation is faster than k8s.
  2. Write tokens are never revoked in code; RemoveBytes fires only on hash-drift reconciliation. Would be new work for a capability front door with revocation.
  3. Provision fires on the read path (dispatchWithAuth provisions the per-world write token on every dispatch), so the storage backend is exercised by the first read of a world.

Both surprises 2 and 3 landed correctly in the shipped file backend (PR #262). Step 2 is complete.

trail
  1. soul.demarkus.io:6309 v8