soul.demarkus.io/plans/universe-onboarding-pr5.md/v2 draft reader meta

Plan: Universe Onboarding — PR5 (/me/install)

Sub-plan for plan §PR5 of /plans/universe-onboarding.md. Picked up after PR4 (#139, broker refresh tokens + JWKS) merged 2026-05-15. Ready to start cold next session: this doc plus /plans/universe-onboarding.md + /journal/2026-05-15.md is sufficient context.

Goal

After PR5 lands, an authenticated client (the plugin, tools/demarkus-join, or curl with a bearer) can GET a per-user install bundle from the broker:

GET /me/install
Authorization: Bearer <id_token>

200 OK
Content-Type: application/json
Cache-Control: no-store
{
  "worlds": [
    {
      "name": "team-a",
      "publicURL": "mark://world-a.cluster.local:6309",
      "label": "usr_b23fbc20",
      "accessToken": "<raw-token>",
      "expiresAt": "2026-05-16T18:00:00Z"
    }
  ]
}

The bearer is accepted by PR4's compositeVerifier — broker-signed (refresh-renewed) or IdP-signed (device-code completion). Both work; both land in requireAuth → claimsFromCtx.

The install bundle:

  • Excludes worlds where WorldConfig.PublicURL == "" (operator marked them un-installable).
  • Mints a FRESH token per world on every call (raw tokens are never re-derivable from stored hashes; old tokens stay valid until expiry; Sweeper retires them).
  • Returns 200 + worlds: [] (not 403) when the user is authenticated but no worlds authorize them — the plugin surfaces that as "no worlds authorized for your identity" rather than an auth error.

PR6 (tools/demarkus-join) consumes the JSON. PR7's slash command (/soul-join) drives that binary. PR8 documents the surface.

Non-Negotiables Inherited

  • No protocol changes. Same as the parent plan.
  • No demarkus-server changes. All work in tools/demarkus-broker/.
  • No MVPs, no shortcuts. Auth correctness, partial-failure handling, no-store cache header, deterministic ordering of worlds in the response — all from day one.
  • Single broker. /me/install is broker-scoped; multi-broker is out of scope.

Out of Scope (explicit, for PR5 specifically)

  • Shell-script content negotiation. The parent plan mentions text/x-shellscript for curl … | sh users. Defer: ships PR6 (tools/demarkus-join) which obviates the shell-script path for the primary user persona (plugin). Re-evaluate when a real non-plugin terminal user asks. Embedding the bearer + minted tokens in a one-shot shell body has its own security/UX shape worth resolving separately.
  • Universe-level metadata ({universe: {name, brokerURL}} from the parent plan's response sketch). The broker has no universe.name config today; adding one is a separate PR. Plugin derives a display slug from the broker hostname (acmecorp from demarkus.acmecorp.com) per parent plan §"Per-world MCP entry naming collision."
  • POST /me/install (idempotency-token style). GET with the side effect of minting is the parent-plan-locked shape and matches OAuth /me-style endpoints. Document the side-effect explicitly in the handler doc comment; defer POST until a customer wants idempotency tokens.
  • Reuse of existing un-expired issuances. Raw tokens are never recoverable post-mint (we only store hashes), so "reuse" is mechanically impossible — every /me/install call mints fresh. Old tokens stay valid until ExpiresAt; Sweeper retires them.
  • /me/install for plugins that aren't yet wired (Cursor, Aider). Same JSON surface works for any agent; per-agent shell wrappers are separate plans.

Architecture

┌──────────────┐  GET /me/install + Bearer  ┌────────────┐
│  client      │ ─────────────────────────► │            │
│ (plugin,     │ ◄───────────────────────── │   broker   │
│  demarkus-   │   {worlds:[{name,publicURL,│            │
│  join, curl) │    label,accessToken,      │   requireAuth  → claimsFromCtx
│              │    expiresAt}]}            │   subjectRateLimit
│              │                            │   meInstall
└──────────────┘                            │      │
                                            │      └─► Issuer.MintFiltered(
                                            │           claims,
                                            │           func(w) bool { return w.PublicURL != "" })
                                            │              │
                                            │              ├─► world A Secret (token hash)
                                            │              ├─► world B Secret (token hash)
                                            │              └─► issuances Secret (per-world record)
                                            └────────────┘

Layer responsibilities

Component Owns Does NOT own
meInstall handler Translating verified claims into the install-bundle response. Filtering out PublicURL-less worlds in the response shape (the mint-side filter via MintFiltered keeps the issuances Secret clean). Partial-failure surfacing. Cache-Control header. Authentication (delegated to requireAuth). Rate limiting (delegated to subjectRateLimit). Mint mechanics.
Issuer.MintFiltered (extracted from existing Mint) Same shape as Mint but takes an optional keep func(*WorldConfig) bool predicate that runs after authorizedWorlds and before per-world mint. Knowing what /me/install means semantically.
Existing Issuer.Mint Now a thin wrapper: MintFiltered(ctx, claims, nil). Caller compatibility preserved for /auth/callback. New behavior — purely a back-compat shim.

Pre-Flight Tasks

None. PR4's compositeVerifier already accepts broker-signed bearers (the refresh-renewed token PR5 needs to verify), and requireAuth was wired in pre-PR4. PR5 builds directly on those surfaces.

Routes To Register / Modify

In server.Routes():

Method Path Middleware Notes
GET /me/install requireAuthsubjectRateLimit Same composition as the /tokens routes. Returns per-user install bundle.

No modifications to other routes.

Sub-Tasks (sequenced)

Step 1 — Issuer.MintFiltered (~40 lines + ~80 tests)

  • File: tools/demarkus-broker/internal/broker/issuer.go.
  • Extract the world-iteration body of Mint into a new method:
    // MintFiltered mints tokens for authorized worlds where keep
    // returns true. A nil keep is "accept all" — equivalent to Mint.
    // /me/install passes a PublicURL-based filter so the issuances
    // Secret is not polluted with worlds the install bundle would
    // then drop anyway.
    func (i *Issuer) MintFiltered(ctx context.Context, claims Claims, keep func(*WorldConfig) bool) ([]MintResult, error)
    
  • Refactor Mint to delegate: return i.MintFiltered(ctx, claims, nil). Existing call sites (/auth/callback) keep working unchanged.
  • Tests in issuer_test.go:
    • MintFiltered with nil predicate is equivalent to Mint (regression guard).
    • MintFiltered with predicate skips filtered worlds (no issuance Secret entry written for those).
    • MintFiltered returns ErrNotAuthorized when the predicate filters every authorized world to zero.
    • MintFiltered partial-failure path: predicate matches multiple worlds, one mint fails, returns partial + wrapped error.

Step 2 — meInstall handler (~80 lines + ~200 tests)

  • File: tools/demarkus-broker/internal/broker/install.go (new).
  • Response struct:
    type installResponse struct {
        Worlds         []installWorld `json:"worlds"`
        PartialFailure string         `json:"partialFailure,omitempty"`
    }
    type installWorld struct {
        Name        string    `json:"name"`
        PublicURL   string    `json:"publicURL"`
        Label       string    `json:"label"`
        AccessToken string    `json:"accessToken"`
        ExpiresAt   time.Time `json:"expiresAt"`
    }
    
  • Handler shape:
    • Read claims from ctx (set by requireAuth).
    • Call s.issuer.MintFiltered(ctx, claims, func(w *WorldConfig) bool { return w.PublicURL != "" }).
    • On ErrNotAuthorized: return 200 with worlds: [] (not 403 — the user IS authenticated; the absence of worlds is an authz-config story, not an auth-fail story). Log at INFO.
    • On ErrEmailUnverified: 403 (existing convention; the production Verifier rejects unverified before this branch, but the defense-in-depth shape stays).
    • On partial-mint (some succeeded, one failed): return 200 with the successful worlds + partialFailure field. Same shape as /auth/callback to keep the surface consistent across the two mint paths.
    • On hard mint failure (zero results): 500.
    • On success: 200 with the filtered worlds. Cache-Control: no-store + Pragma: no-cache (bearer tokens in the body).
  • Lookup of WorldConfig.PublicURL per result: walk s.cfg.Worlds and match by Name. O(N*M) is fine for the world counts in scope (~10s of worlds, called maybe once per session per user).
  • Tests in install_test.go:
    • Happy path: 1 authorized world with PublicURL → 200 + 1 world in response with all four fields populated.
    • Multi-world happy path: 2 authorized worlds → 200 + 2 worlds, deterministic ordering (Issuer.MintFiltered iterates cfg.Worlds in declaration order).
    • PublicURL-less world filtered out: 2 authorized worlds, one with no PublicURL → 200 + 1 world (the one with PublicURL), no issuance written for the filtered world (assert Secret state).
    • Unauthenticated request → 401 (via requireAuth — already covered upstream but a smoke test confirms wiring).
    • No-bearer / bad-bearer → 401.
    • User authenticated, zero authorized worlds → 200 + worlds: [] (NOT 403).
    • ErrEmailUnverified → 403.
    • Partial-mint failure → 200 + partialFailure field + the worlds that succeeded.
    • Hard mint failure (e.g., RBAC denied on the issuances Secret) → 500.
    • Response includes Cache-Control: no-store + Pragma: no-cache.
    • Bearer-signed by broker (PR4 refresh-renewed) is accepted (regression guard against PR4's compositeVerifier dispatch).
    • Bearer-signed by IdP is accepted (regression guard for the device-code-completion path).

Step 3 — Route registration (~5 lines)

  • File: tools/demarkus-broker/internal/broker/server.go.
  • Add to Routes():
    mux.Handle("GET /me/install", authedSubject(s.meInstall))
    
    where authedSubject is the existing requireAuth → subjectRateLimit composition used by the /tokens routes.
  • No new helpers needed.

Step 4 — Documentation

  • deploy/helm/demarkus-broker/README.md: add a section under "Endpoint surface" (or the equivalent existing section) describing /me/install. Schema, auth, no-store posture, the "PublicURL-less worlds are excluded" rule.
  • tools/demarkus-broker/main.go package doc: bump the "Current scope" comment to mention /me/install.

Scope Estimate

Step Code Tests
1. Issuer.MintFiltered 40 80
2. meInstall handler 80 200
3. Route registration 5 0
4. Documentation 30 0
Total ~155 ~280

Parent plan §PR5 estimated "~300 lines + tests, ~1 day PR." Revised down to ~155 LOC because compositeVerifier (PR4) already handles bearer-token verification end-to-end, including the broker-signed leg; PR5 is genuinely just a handler that wraps Issuer.MintFiltered. Tests are the bulk; ~280 lines for the full matrix.

Open Questions To Resolve Before/During PR5

  1. GET vs POST. Lean: GET (parent-plan-locked). Document the side-effect (mint) in the handler doc comment so a future reader doesn't expect REST-idempotent semantics. Re-confirm before starting if the side-effect bothers Fritz.
  2. Empty authorized worlds: 200-empty or 403. Lean: 200 with worlds: []. Rationale: a 403 confuses the plugin layer (can't distinguish auth failure from authz emptiness). Re-confirm.
  3. PublicURL filter in MintFiltered vs post-Mint. Lean: pre-Mint (MintFiltered). Post-Mint wastes one issuance per filtered world per call; over 90 days of refresh ticks this fills the 5000-record Secret cap unnecessarily. Confirmed worth the +40 LOC.
  4. label field in response. Lean: include it. The plugin doesn't strictly need it (the access token is enough for connection), but exposing it makes /tokens/{label}/rotate and DELETE /tokens/{label} actionable from the install bundle without a second call. ~0 LOC cost; defensive.
  5. Stable ordering of worlds in response. Lean: cfg.Worlds declaration order (what MintFiltered naturally produces). A client that wants alphabetical can sort client-side. Avoids surprise reordering across runs.
  6. Response Content-Type. Lean: always application/json. No content-negotiation in PR5; non-JSON consumers (shell-script) are deferred per §Out of Scope.
  7. Per-world health check before issuing. Should the broker verify each target world's Secret is writable before returning success? Lean: no — Issuer.Mint already performs the write and returns partial-failure on RBAC/Secret issues. Pre-flight check would double the latency for zero behavioral gain.
  8. Logging. Log at INFO on success (subject hash + world count + partial-failure flag), at WARN on partial, at ERROR on hard failure. Same posture as /auth/callback. Subject hash via existing hashSubject helper — no raw email or tokens in logs.

Risks Specific To PR5

  • Mint contention under refresh-storm. Every plugin session start may call /me/install. A multi-world broker with many concurrent users will hit mutateSecret's optimistic-concurrency retry on the issuances Secret. Mitigation: same Secret + same retry budget as PR4's refresh path; if a real customer hits the wall, the fix is sharded Secrets or a CRD-backed store (already documented as the phase-7 path in PR4's risks).
  • Issuance bloat at the 5000-record cap. A user with 5 authorized worlds hitting /me/install once per session at 100 sessions/day = 500 issuances/day. With 24h default token TTL, the Sweeper catches them within a day. With 90-day TTL... different story. PR4 already documented this cap; PR5 inherits it. Mitigation: shorter access-token TTLs for high-throughput deployments; chart value worlds[].defaultToken.expiresAfter is operator-tunable.
  • Bearer-token in response body. If the response is somehow logged or proxied to an untrusted intermediary, raw tokens leak. Mitigation: Cache-Control: no-store + Pragma: no-cache headers; document the no-log-the-body posture in the broker's deployment doc.
  • PublicURL filter coupling. PR5 introduces the convention that "world without PublicURL is un-installable." Any future feature that wants to operate on un-installable worlds (e.g., admin-only worlds) needs a different filter shape. Mitigation: MintFiltered's keep is parametric — future surfaces compose their own predicate without touching the install-side filter.
  • Same-user concurrent /me/install calls. Two simultaneous calls produce two sets of issuances, both valid. Acceptable per the broker's existing posture, but a flag-prone user could fill the issuances Secret with double-minted tokens fast. Mitigation: subjectRateLimit (10/min default, shared bucket with the /tokens routes) caps the burst.

Next-Session Resume Steps

  1. git fetch && git log --oneline -5 — confirm PR4 (#139) on main, no conflicts. If PR4 review CI fixup (fix-ci-broker-signing-key-comment) also landed, even better.
  2. mark_fetch /index.md + /patterns.md + /guidelines.md per project preflight.
  3. mark_fetch /plans/universe-onboarding-pr5.md (this doc).
  4. mark_fetch /journal/2026-05-15.md for PR4 review-lessons context (workflow-YAML ${{ }} quirk, parse-at-validate, RETURN-trap pattern, ephemeral test PEMs).
  5. Decide on Open Questions 1 (GET vs POST) + 2 (empty-worlds → 200 or 403) — both have a lean but worth one-sentence confirmation before starting.
  6. Cut a fresh branch (feat-tools-broker-me-install or similar). Start at Step 1 (Issuer.MintFiltered) as its own commit so the rest builds on a green refactor baseline.
  7. After Step 1: go test -race + bash pre-commit.sh green before touching Step 2. The refactor is small but Mint is load-bearing for /auth/callback; regression guard tests pay for themselves.

Touch Points With Later PRs

  • PR6 (tools/demarkus-join) consumes the JSON response. The binary's internal/install/ package decodes the shape PR5 emits and drives claude mcp add per world. PR5's stable field names (name, publicURL, label, accessToken, expiresAt) are the wire contract.
  • PR7 (plugin slash commands + kind Stage 5) invokes tools/demarkus-join which hits /me/install. The kind harness Stage 5 (if mock-oauth2-server device code support pans out — parent plan §Open Question 1) validates the full chain.
  • PR8 (docs) covers the operator-facing "what does /me/install look like, what's in it, what's the no-PublicURL rule" story.

Done When

  • PR5 opens with all four sub-steps' commits, each individually testable.
  • go test -race ./... green inside tools/demarkus-broker/.
  • helm unittest . green (no chart changes expected; smoke-test pass confirms PR5 didn't accidentally touch the chart).
  • pre-commit.sh green.
  • Manual end-to-end via curl: device-flow completes → curl /me/install with the resulting bearer → 200 + per-world bundle.
  • Journal entry on /journal/<date>.md with any design decisions that landed differently from this plan.

Implementation Status (2026-05-20)

Implemented; PR pending review. Branch feat-tools-broker-me-install on local working tree. Fritz handles the commit + PR open per /patterns.md.

  • ✅ Step 1: Issuer.MintFiltered extracted; Mint is now a back-compat wrapper. +6 tests (regression guard, predicate skip, reject-all, partial-failure, predicate-not-run-on-unauthorized, predicate-sees-authorized-worlds-only).
  • ✅ Step 2: meInstall handler in tools/demarkus-broker/internal/broker/install.go. +12 tests covering happy path, multi-world ordering, PublicURL filter, no-bearer / bad-bearer 401, empty-worlds 200+[], all-worlds-filtered 200+[], unverified 403, partial failure, hard failure, Cache-Control + Pragma headers, broker-signed bearer regression guard.
  • ✅ Step 3: Route registered as GET /me/install behind requireAuth → subjectRateLimit.
  • ✅ Step 4: deploy/helm/demarkus-broker/README.md gained a full "Endpoint surface" table + /me/install subsection; tools/demarkus-broker/main.go package-doc Current-scope comment updated.

Open Questions resolved at implementation time

  1. GET vs POST: GET. Side-effect documented in the handler doc comment.
  2. Empty authorized worlds → 200 vs 403: 200 + worlds: []. 3-8. All other open questions adopted the leans documented above with no deviation.

Verification at handoff

  • go test -race -count=1 ./internal/broker/ → green (+18 tests over PR4 baseline).
  • helm unittest . → 72/72 pass (unchanged from PR4 baseline; chart not touched).
  • bash pre-commit.sh → format / vet / lint clean across protocol/server/client/tools.

Final scope

Step Plan code Actual code Plan tests Actual tests
1. MintFiltered 40 42 80 209
2. meInstall + install.go 80 ~115 200 ~365
3. Route registration 5 8 0 0
4. Documentation 30 ~72 0 0
Total ~155 ~237 ~280 ~574

Code came in slightly over plan; tests came in ~2× plan. Test density is from the partial-failure + Cache-Control + composite-verifier-bearer matrix being denser than the plan accounted for, and from PR5 isolating its own fixtures (installTestConfig, installTestConfigTwoWorlds) rather than sharing issuer_test.go's testConfig. Worth it: every test pins a distinct invariant.

Session journal

See /journal/2026-05-20.md for the full session writeup including design decisions that landed differently from the plan (the predicate-runs-after-AllowConfig contract, the tolerant toInstallWorlds lookup, the reuse of fakeVerifier's existing verifyFn hook).

trail
  1. soul.demarkus.io v2