# 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 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": "", "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` | `requireAuth` → `subjectRateLimit` | 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: ```go // 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: ```go 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()`: ```go 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/.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).