2026-05-15 — Universe Onboarding PR1
Resumed /plans/universe-onboarding.md at PR1 (Broker config foundations: WorldConfig.PublicURL).
Changes
tools/demarkus-broker/internal/broker/config.go— addedWorldConfig.PublicURL stringwith a doc comment explaining the field belongs on the broker (single source of truth for the client-facing address), is optional, and that a blank value means "skip in /me/install."deploy/helm/demarkus-broker/values.yaml— documentedpublicURLin the commented worlds[] example with the rationale for setting it.deploy/helm/demarkus-broker/templates/secret-config.yaml— renderpublicURL: "<value-or-empty>"for every world. Always emitted so the YAML shape is stable across set/unset, and operators can grep for the field.tools/demarkus-broker/internal/broker/config_test.go— two new subtests: empty default round-trip + explicitmark://value round-trip.deploy/helm/demarkus-broker/tests/secret-config_test.yaml— two new cases: blank renders aspublicURL: "", operator-supplied value renders verbatim.
Verification
go test ./internal/broker/ -run TestLoadConfig→ 29/29 pass (2 new).helm unittest -f tests/secret-config_test.yaml .→ 13/13 pass (2 new).bash pre-commit.sh→ format, vet, lint clean across protocol/server/client/tools.
Scope discipline
No validation in PR1 — plan calls for "optional; validation only requires it when used." Shape validation (mark:// URL, port present, etc.) lives with the /me/install consumer in PR5.
KnownFields(true) means older broker binaries would reject the new chart's rendered config because of the unknown publicURL line. Chart appVersion gates broker upgrade as usual; not new for this PR.
Next
PR2 — broker discovery doc (GET /.well-known/openid-configuration proxying the IdP discovery with device-endpoint overrides). Resume at tools/demarkus-broker/internal/broker/discovery.go (new file) + route registration in server.go.
Pending review: should PR1 ship as a standalone PR or be folded into PR2? Plan calls for independent reviewability per PR, so default is standalone.
PR2 — Broker discovery doc
PR1 (#135) merged. Continuing through plan PR2: broker-hosted /.well-known/openid-configuration that proxies the IdP's discovery doc with broker-hosted endpoint overrides.
Changes
tools/demarkus-broker/internal/broker/discovery.go(new) —Discoverytype. Fetches<idpIssuer>/.well-known/openid-configurationonce atNewDiscovery, parses intomap[string]anyso unknown fields proxy through unchanged, rewritesissuer+device_authorization_endpoint+token_endpointto broker URLs, caches the rendered body with a 5-minute TTL. Lazy refresh on TTL expiry, stale-while-error on refresh failure. Constructor blocks on initial fetch so a misconfigured/unreachable IdP fails the broker at boot rather than at first/soul-join.tools/demarkus-broker/internal/broker/discovery_test.go(new) — 13 test cases via httptest fake-IdP: constructor validation (broker URL + issuer required), upstream-failure failure paths, override correctness, proxy ofjwks_uri+userinfo_endpoint+authorization_endpoint+ arrays, response headers, cache hit count within TTL, refresh after TTL, stale-while-error, recovery from transient failure, trailing-slash trim, malformed JSON rejection.tools/demarkus-broker/internal/broker/server.go—Servergains adiscovery *Discoveryfield andNewServertakes it as a constructor arg.Routes()registersGET /.well-known/openid-configurationonly whendiscovery != nil, so existing tests passniland skip the route cleanly.tools/demarkus-broker/internal/broker/server_test.go—TestWellKnownDiscoveryRouteRegistered(wires real Discovery into NewServer, asserts override applied through the mounted route) +TestWellKnownDiscoveryRouteSkippedWhenNil(404 when constructed nil).tools/demarkus-broker/main.go—NewDiscoverycalled betweenNewVerifierandNewServer; passescfg.Server.PublicURL+cfg.OIDC.Issuer. Failure is fatal at startup, same shape asNewVerifier.tools/demarkus-broker/internal/broker/config.go— newServerConfig.PublicURLfield, required at load, trailing slash stripped once at load so every downstream consumer sees the canonical form. Doc comment notes it intentionally diverges fromOIDC.RedirectURL(which is purpose-specific to the IdP redirect).tools/demarkus-broker/internal/broker/config_test.go— 2 new subtests: required-rejection + trailing-slash trim.deploy/helm/demarkus-broker/values.yaml—server.publicURL: ""with full operator-facing comment.deploy/helm/demarkus-broker/templates/secret-config.yaml— renderspublicURL: {{ required ... }}so chart-render fails clearly when omitted (same shape asoidc.redirectURL).deploy/helm/demarkus-broker/tests/secret-config_test.yaml— 2 new cases (renders value, fail-render when blank) + addedserver.publicURLto the suite-wideset:block.deploy/helm/demarkus-broker/tests/deployment_test.yaml— same suite-wideset:addition (this suite also templates secret-config).deploy/kind/values-broker.yaml,deploy/kind/values-broker-argo.yaml— setserver.publicURL: "http://localhost:8080"to mirror the existing redirectURL shape. Stage 2/4 harnesses don't exercise the well-known route but the broker'sLoadConfignow requires the field.
Verification
go test ./internal/broker/ -count=1→ all green (4.3s). 13 new Discovery tests + 2 new server route tests + 2 new config tests.helm unittest .→ 55/55 pass across all 7 suites (was 52/52 before; 3 new).bash pre-commit.sh→ format + vet + lint clean.
Design notes (worth remembering)
- Override shape is intentionally lossy at the seam. Override
issuerto broker URL but leavejwks_uripointed at the IdP. ID tokens during PR2/PR3 are still IdP-signed and carry IdP'siss. Strict client validation against this discovery doc would fail; the plugin's device-flow client (PR6) only consumesdevice_authorization_endpoint+token_endpointfrom this doc so the mismatch is invisible to it. PR4 closes the gap by minting broker-signed id_tokens with a broker-hostedjwks_uri. Doc comment indiscovery.gocalls this out explicitly so a future reader doesn't trip over it. map[string]anyproxy beats a typed struct. Tried a typedidpDiscoverystruct first; killed it because real IdPs ship 20+ fields each (Google'scode_challenge_methods_supported, Auth0'smfa_challenge_endpoint, Okta'srequest_object_signing_alg_values_supported, etc.) and the broker has no business knowing or filtering them. Decode → mutate three keys → re-encode is the right shape; tests assert both the overridden fields AND a couple proxy fields so regressions surface.- Lazy refresh, not background goroutine. Plan §Risks talks about "5-minute TTL on the discovery cache." Two implementation shapes: background ticker, or in-handler lazy. Picked lazy because (a) one fewer goroutine lifecycle to wire into the broker shutdown path, (b) the load is human-scale — even with N replicas behind a Service, the per-instance refresh rate caps at 1/5min, (c) thundering herd on simultaneous expiry is negligible at this rate (two replicas might both refresh once; the IdP doesn't notice). Stale-while-error keeps the well-known endpoint available during transient upstream blips.
- Constructor signature pattern.
NewDiscoverytakes aDiscoveryConfigstruct, not a long positional arg list — per /guidelines.md "more than 4 parameters is a smell." Same pattern as the existingIssuer/Verifierconstructors take typed configs. - Test isolation gotcha. Existing
oidc_test.goalready hadfakeIdP+newFakeIdP. Renamed mine tofakeDiscoveryIdP+newFakeDiscoveryIdPrather than touch the existing symbol — both are test-package-scope so the rename is purely local. Worth keeping in mind if a third test file ever needs a fake IdP: extract to a sharedtesthelpers_test.gohelper instead of accumulating per-suite copies. http.NoBody, notnil, on GET requests. gocritic flagged thenilbody onhttp.NewRequestWithContext; switched tohttp.NoBody(the idiomatic singleton for empty request bodies). Same fix in tests usinghttptest.NewRequest.
Scope discipline
PR2 spec called for ~150 lines + tests. Final tally: ~210 lines of production code (discovery.go) + ~310 lines of tests + ~70 lines of config/chart plumbing + ~50 lines of dev-values updates. Over the 150-line estimate but every piece is load-bearing for PR3 (verification_uri uses brokerURL) and PR4 (broker-signed tokens use brokerURL as issuer).
Next
PR3 — broker device flow (RFC 8628). New file device.go with /device/authorize + /device (HTML form + /auth/login redirect glue) + /device/token polling. ~600 lines + tests, ~2 days. Will exercise the route registration path that PR2 sets up; will also be the first time the broker holds short-lived per-flow state in-memory (device_code map with janitor goroutine).
Open question to resolve before PR3
mock-oauth2-server device-code support — plan §Open Questions item 1. Need to check navikt/mock-oauth2-server v2.x source for /device_authorization endpoint before designing the kind Stage 5 harness in PR7. Not blocking PR3 itself (broker-side tests use a fake verifier), but if mock-oauth2-server doesn't support it, the kind end-to-end story diverges.
End of session — PR2 merged, PR3 plan written
- PR2 (#136, broker discovery doc) merged after two rounds of CodeRabbit feedback. Final state included
Server.PublicURLconfig field (required, trimmed, absolute-URL validated),Discoverytype with 5-min TTL caching + refresh-mutex stampede protection + hard fetch deadline, route registration on/.well-known/openid-configuration, 16 broker tests + 5 helm-unittest cases. - Coderabbit lessons worth saving:
t.Fatalfinside spawned goroutines panics that goroutine without releasing pending channel sends. Concurrency tests need an error-returning helper that surfaces failures through the channel; the main goroutine asserts after<-. WroteserveAndDecodeErras the canonical pattern for this kind of test.- When a constructor accepts an injected
*http.Clientvia config struct, that client may not have aTimeoutset. Wrap the fetch context withcontext.WithTimeoutinside the operation so the deadline holds regardless of what the caller injected. Belt-and-suspenders on top of the default client's own timeout. helm installregression tests in CI (test-upgrade-wipe.shinvocation) need explicit--setfor every newly-required chart value. Adding a required field tovalues.yamlwithout updating CI passes helm-unittest but fails the actual install regression. Worth grepping.github/workflows/forhelm installcalls when adding a required field to any chart.- README install examples count as a test surface — coderabbit flagged the missing
--set server.publicURLin the dev-install README block. Update README install commands alongside the chart change.
- PR3 plan published to
/plans/universe-onboarding-pr3.mdwith full architecture, sub-task breakdown, open design questions, and tomorrow-morning resume steps. Index updated to surface it. - Two design questions deferred to tomorrow's session:
Verifier.Exchangesignature change (lean: change it, three call sites, clean refactor) vs. parallelExchangeWithTokensmethod. Latter is messier and we'd refactor anyway in PR4.- RFC 8628
slow_downinterval-increment policy. Lean: not for PR3 (RFC says MAY, not MUST); revisit if a customer reports IdP abuse.
- Tomorrow's first move:
mark_fetch /plans/universe-onboarding-pr3.md, confirm the two open questions, branch from main, start at Step 1 (Verifier refactor) as its own commit.
PR3 implementation — broker device flow (RFC 8628)
Picked up PR3 cold from /plans/universe-onboarding-pr3.md. Both deferred design questions confirmed:
Verifier.Exchangesignature changed to returnExchangeResult(Claims + RawIDToken + AccessToken + Expiry) — three call sites touched.slow_downpolicy is "enforce configured interval, no incremental bump" — pure minimum-interval check indeviceStore.Poll.
Changes
tools/demarkus-broker/internal/broker/oidc.go— addedExchangeResultstruct;Verifier.Exchangereturns it.tools/demarkus-broker/internal/broker/oidc_test.go—fakeVerifiergainedrawIDToken/accessToken/expiryfields and the new return type.tools/demarkus-broker/internal/broker/server.go—ServergaineddeviceStore.Routes()registersPOST /device/authorize,GET /device,POST /device,POST /device/token. The first and last sit behindipRateLimit;/deviceform has no rate-limit middleware.authCallbackgained a device-cookie early-return dispatch.tools/demarkus-broker/internal/broker/device.go(new) — four handlers +deviceCallback(the device branch of/auth/callback) +RunDeviceJanitorlifecycle +clearDeviceCookiehelper. HTML templates embedded viaembed.FS. ~280 lines.tools/demarkus-broker/internal/broker/device_store.go(new) —deviceStorestate machine. Methods:Authorize,LookupByUserCode,LookupByDeviceCode,Bind,Deny,Poll,Sweep. User-code alphabet 30 chars (excludes0 1 I L O U); 8-char codes formatted with a hyphen for display, canonicalized to no-hyphen-uppercase at lookup. ~290 lines.tools/demarkus-broker/internal/broker/templates/device_form.html,device_done.html(new) — server-rendered, embedded into the binary.tools/demarkus-broker/internal/broker/config.go—ServerConfig.DeviceCodeTTL+DevicePollIntervalwith defaults (10m / 5s). ExtractedServerConfig.applyDeviceFlowDefaultsto keepConfig.validateunder the gocyclo budget.tools/demarkus-broker/main.go— callssrv.RunDeviceJanitor(sweepCtx)in the existing shutdown WaitGroup so the goroutine tears down with the sweeper.tools/demarkus-broker/internal/broker/device_store_test.go(new) — Authorize, Lookup, Bind, Deny, Poll (incl. slow_down + expiry), Sweep, canonicalizer round-trip, alphabet invariants. ~330 lines.tools/demarkus-broker/internal/broker/device_test.go(new) — handler tests (authorize, form GET/POST, token states),TestAuthCallbackUnchangedWithoutDeviceCookieregression guard, full end-to-end happy-path integration test, IdP-deny integration test, janitor cancel test. ~510 lines.
Verification
go test -race ./internal/broker/→ green (~6s) across all 30 existing + new tests.helm unittest .(broker chart) → 55/55 still green; no chart changes in PR3.bash pre-commit.sh→ format + vet + lint clean. Initial run flagged 5 lint issues (gocritic hugeParam onBind, gocyclo onvalidate, twominmax, onerangeint) — all addressed:Bindtakes*ExchangeResult, validate extracted helper,max()replacements,for range Nloop.
Design decisions that landed differently from the plan
deviceStore.Bindtakes*ExchangeResult(not value). Plan showed it as value. gocritic'shugeParamflagged the 120-byte value at every call site. Pointer keeps the API as cheap as value semantics without changing ownership (*resultis copied intodeviceCodeState.Result). All call sites pass&exchange/&ExchangeResult{...}.- User-code alphabet is 30 characters, not 32. Plan said "32-char alphabet (no I, L, O, 0, 1, U)" but 36 − 6 = 30. Used 30; collision space at TTL-aligned issue rates is still negligible (30^8 ≈ 6.6×10^11). Crockford base32's standard alphabet preserves
0and1; the plan's exclusion list was the load-bearing constraint, not the alphabet size. - Cookie clearing must happen BEFORE
WriteHeader. First pass useddefer s.clearDeviceCookie(w). Integration test caught it:WriteHeaderflushes headers, so the deferred SetCookie is silently dropped. Restructured to explicitclearDeviceCookie(w)calls at each early-return + before the render. - State cookie is also cleared on the device branch. Plan mentioned device cookie only. Mirrored the browser path's state-cookie clear (Path=
/auth/callback, MaxAge=-1) so a replay can't reuse the nonce. - Janitor is a plain
time.NewTickerloop, no leader election. Per-replica in-memory state means each replica sweeps its own store; no shared resource to elect on. Lifecycle sharessweepCtxwith the Kubernetes-side Sweeper for a single shutdown signal. - Open Question 1 (user-denial branch): YES, translate.
/auth/callback?error=...is detected before the Exchange call.deviceStore.Deny(deviceCode)is invoked and the done page renders. Polling client seesaccess_deniedon next poll instead of waiting forexpired_token. ~10 lines. - Open Question 7 (client_id requirement on /device/authorize): accept-but-ignore.
POST /device/authorizereadsclient_idfrom the form (RFC-compliant client), logs at debug, but does not gate on it. Broker is the only relying party in this flow.
Risks specific to PR3 that did NOT bite
- Cross-cookie path confusion (state cookie at
/auth/callback, device cookie at/) — guarded byTestAuthCallbackUnchangedWithoutDeviceCookie, regression-safe. - Verifier interface refactor breakage —
rgconfirmed only three call sites; refactor was a single commit's worth of mechanical changes. - In-memory store loss on broker restart — documented in
deviceStoredoc comment; acceptable for 10-minute TTL.
Next
PR3 ready to open. No commits made — Fritz handles those. Branch is still main; he'll cut a feat-tools-broker-device-flow (or similar) branch before committing. PR4 (broker-signed id_tokens + refresh tokens via ExchangeResult plumbing) is the natural next sub-plan.