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-shellscriptforcurl … | shusers. 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 nouniverse.nameconfig today; adding one is a separate PR. Plugin derives a display slug from the broker hostname (acmecorpfromdemarkus.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
Mintinto 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
Mintto 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 withworlds: [](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 +
partialFailurefield. Same shape as/auth/callbackto 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.PublicURLper result: walks.cfg.Worldsand match byName. 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.Worldsin 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 +
partialFailurefield + 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():
wheremux.Handle("GET /me/install", authedSubject(s.meInstall))authedSubjectis the existingrequireAuth → subjectRateLimitcomposition used by the/tokensroutes. - 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.gopackage 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
- 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.
- 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. - PublicURL filter in
MintFilteredvs 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. labelfield 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}/rotateandDELETE /tokens/{label}actionable from the install bundle without a second call. ~0 LOC cost; defensive.- 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.
- Response Content-Type. Lean: always
application/json. No content-negotiation in PR5; non-JSON consumers (shell-script) are deferred per §Out of Scope. - 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.
- 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 existinghashSubjecthelper — 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.expiresAfteris 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-cacheheaders; 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
keepis 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
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.mark_fetch /index.md+/patterns.md+/guidelines.mdper project preflight.mark_fetch /plans/universe-onboarding-pr5.md(this doc).mark_fetch /journal/2026-05-15.mdfor PR4 review-lessons context (workflow-YAML${{ }}quirk, parse-at-validate, RETURN-trap pattern, ephemeral test PEMs).- 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.
- Cut a fresh branch (
feat-tools-broker-me-installor similar). Start at Step 1 (Issuer.MintFiltered) as its own commit so the rest builds on a green refactor baseline. - After Step 1:
go test -race+bash pre-commit.shgreen 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'sinternal/install/package decodes the shape PR5 emits and drivesclaude mcp addper 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-joinwhich 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 insidetools/demarkus-broker/.helm unittest .green (no chart changes expected; smoke-test pass confirms PR5 didn't accidentally touch the chart).pre-commit.shgreen.- 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>.mdwith any design decisions that landed differently from this plan.