#271. A grant on one ladder of a product that is a tier of several ladders keeps ending the subscription's tier on all of them, and the ladders the grant is not on stay empty until it ends; what changes is that every place an operator or a member looks now says so. A new test pins the console's behaviour the strings rest on: the subscription holds nothing anywhere, the other ladder holds no position, the grant delivers, and a reconcile of the subscription is queued, which pauses its billing when it pays for nothing else. The Issue grant form: an option now carries its own hint (forms.Option.Hint, rendered as data-hint, and hint-on-choice.js shows the chosen option's hint or the declared one). A product that would supersede a tier of several ladders names them: "This grant would supersede a tier a subscription pays for on Duo and Wiki; its billing pauses until the grant ends." A product superseding a tier of one ladder keeps the existing warning. An option's hint is not held to the registry's 100-character cap, because the ladder names are the fact. The Not delivering badge on the organization page's Billing card and in the subscriptions list: the ladder's name rides the conferral shapes (ListConferralShapes selects it, Rung.LadderName), and the title adds "; nothing is delivered on {ladders}" for the ladders of the tier on which no live position exists when the page renders: "Billing paused while a grant holds Wiki; nothing is delivered on Duo." A ladder something else holds is named in neither clause. The organization page reads the default pool's positions it already holds; the list reads them with one statement per page (ListLiveLadderIDsBySubscriptionIDs), and a failed read shows "Failed to load deliveries." as the other reads do. Whether a sentence says paused or continues is decided from the deciding displacements only, as the reconcile decides the pause, so a ladder a later subscription holds cannot turn a recorded pause into "Billing continues"; when a named holder is not a grant the sentence reads "Billing paused while other plans hold Duo and Wiki." The member's Products page: on each ladder the grant does not hold, the paid tier's card reads "Paused while Pro is granted on Wiki", or "Billing continues while Pro is granted on Wiki" until Stripe confirms the pause, and draws no control; the controls stay on the card of the ladder the grant holds. docs/models/plan-ladders-transitions.md, docs/plan-management.md and docs/stripe.md say the same. Tests: TestReconcile_AGrantOnOneLadderOfABundlePausesIt, TestSupersededLadders, TestLadderNamesNeedsEveryName, TestRenderHintOnChoiceShowsTheChosenOptionsOwnHint, TestSubscriptionsListNamesEveryLadderOfABundle, TestOrgEnrollmentTwoLadderGolden (two states on their own World), TestGrantHeldLadder, TestPaidPlanCardOnAVacatedLadder, and new rows in TestDisplacedOnPage, TestDisplacementTitle, TestOrgEnrollmentIssueGrantSupersedeHint, TestOrgEnrollmentReadFailures and TestSubscriptionsListDisplacementReadFailures. TestMemberPlansGolden: the seven paid-plus-under-granted-pro states draw Duo's Plus card with the ladder line; the member and organization .json goldens gain the new fields. The grant-hint walkthrough's own-hint step is a subtest that skips, naming its precondition, because no demo state holds a subscription to a product of two ladders. Full suite on reset databases: 5143 passed, 15 skipped, none failed. make screens: no page changed; no demo state holds such a subscription. Hand captures cover the grant form, the badge and the member's cards. Unseen by any test: hint-on-choice.js not setting the line's text; only the browser walkthrough reads it, and its step skips. The hand captures show it. Breaks, all CAUGHT (44): - DisplacedProducts passes over a product of several rungs - displacedOnPage adds no emptied ladder (page rows) - displacedOnPage adds no emptied ladder (golden) - displacedOnPage names only the deciding ladder as held - groupDisplaced keeps an emptied ladder the group names as held - continuingSentence one subscription without the clause - displacedOnPage ignores live (page rows) - displacedOnPage ignores live (list) - pausedSentence uses pausedHolder whatever the holders - displacedOnPage takes grantsOnly over every holder the walk adds - organization page drops the positions read error from the badge - subscriptionDisplacementTitles ignores the live ladder read error - ListLiveLadderIDsBySubscriptionIDs answers no row - ListConferralShapes selects no name (list) - ListConferralShapes selects no name (golden) - RungsByProduct leaves LadderName empty - supersededLadders answers for a product whose superseded provisions each hold one ladder - supersededLadders keeps only the ladders the product is a tier of - ladderNames answers the names it read when one is missing - issuanceProductOptions leaves Option.Hint empty (page) - issuanceProductOptions leaves Option.Hint empty (golden) - issueGrantSupersedeHintOn answers for no ladders - selectOptions does not copy Hint - selectOptions copies Hint without ShowsHint - choiceHint answers the declared hint for a chosen option with its own - HintDefault left empty - HintDefault set when no option carries its own hint - ui_form_field.html without data-hint on the option - ui_form_field.html without data-hint-default on the line - grantHolds without its DefaultSourced test - grantHeldLadder walks rows in place of ladders - grantHeldLadder without its subscription test - setPaidUnderGrant draws the controls on a card with grantLadder - template plain paused branch without the ladder - template payment-due paused branch without the ladder - template moves-to paused branch without the ladder - template without the Billing continues branch - buildLadder without the empty-ladder case - buildLadder takes the heading's cancellation date from a card with grantLadder - pausedSentence one subscription without the clause - pausedSentence several subscriptions without the clause - continuingSentence a count of the account without the clause - continuingSentence several subscriptions without the clause - groupDisplaced leaves the emptied ladders unsorted
Server Package
This package implements the HTTP server for the member-console application, including HTMX partial handlers.
Overview
The server uses Go's standard net/http package with:
- Bootstrap 5 for styling
- HTMX 2.0 for dynamic content updates
- gorilla/csrf for CSRF protection
- scs (
alexedwards/scs) for server-side session management (Valkey/Redis-backed)
Architecture
The HTTP server handles several types of requests:
- Static Files (
/static/*) - CSS, JS, images served from embedded assets - Page Handlers (
/) - Full HTML page rendering - Partial Handlers (
/partials/*) - HTMX partial HTML responses - Auth Handlers (
/login,/logout,/callback) - OAuth2/OIDC authentication
Partial handlers interact with Temporal workflows for async operations like site provisioning.
Files
server.go- Main server setup, middleware chain, route registrationmember_plans.go,member_plan_moves.go,member_entitlements.go,member_addons.go,member_invoices.go,workspace_partials.go,operator*.go- HTMX partial handlers for this package's own featuresprice_text.go- price text shared by the member and operator pages
Integration-owned handlers (e.g. FedWiki's member-facing API and partials)
live in their own tree under internal/integrations/<key>/web instead of
here — see docs/building-an-integration.md and
internal/integrations/fedwiki/web for the worked example. The patterns
below apply equally to handlers in either location.
HTMX Integration
Partial Handler Pattern
Partial handlers return HTML fragments (not full pages) that HTMX swaps into the DOM:
// Handler returns HTML fragment
func (h *Handler) GetItems(w http.ResponseWriter, r *http.Request) {
// ... fetch data ...
h.Templates.Render(w, "items.html", data)
}
<!-- Template returns partial HTML -->
<div class="table-responsive">
<table class="table">
{{ range .Sites }}
<tr>...</tr>
{{ end }}
</table>
</div>
HTMX Triggers
Use HX-Trigger header to trigger events that refresh other parts of the page:
// After successful operation, trigger refresh
w.Header().Set("HX-Trigger", `{"refreshSites": true}`)
<!-- Element listens for the trigger -->
<div hx-get="/partials/items"
hx-trigger="load, refreshItems from:body">
</div>
Cross-origin protection with HTMX
There is no CSRF token. internal/middleware/csrf.go wraps net/http's
CrossOriginProtection, which refuses a non-safe request whose
Sec-Fetch-Site (or, failing that, Origin) says it came from another
site. An htmx request from one of the console's own pages is same-origin
and passes with nothing added; a refusal is a 403 whose body names the
CSRF check, which error-handler.js reads to keep the person's input.
Workflow Execution
Handlers that start a Temporal workflow for a member's request answer when the start is confirmed, not when the workflow finishes. An integration records the request first (FedWiki's site lifecycle records a row in core.lifecycle_requests), starts the workflow under the identifier stored on that row, and returns a fragment or an HX-Trigger refresh; the workflow records its own outcome, and the list that shows the request re-renders until the outcome lands. A handler never waits for a workflow to finish: one that reads a result (the Stripe environment check) waits on WorkflowRun.Get under a context of a few seconds and answers that the check started when it runs out, because a workflow can outlast the server's write timeout. FedWiki starts a recorded request's workflow detached from the member's request, so a closed connection cannot leave the request with no workflow.
Route Organization
Routes are organized by feature using handler structs:
// ItemPartialsHandler groups related routes
type ItemPartialsHandler struct { ... }
func (h *ItemPartialsHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /partials/items", h.GetItems)
mux.HandleFunc("POST /partials/items", h.CreateItem)
mux.HandleFunc("DELETE /partials/items/{id}", h.DeleteItem)
// ...
}
Error Rendering
Errors are rendered back into the same partial/modal that initiated the request:
func (h *Handler) renderCreateFormError(w http.ResponseWriter, userID int64, errMsg string) {
// Re-fetch form data
data := h.getCreateFormData(userID)
data.Error = errMsg
// Re-render the form partial with error displayed
h.Templates.Render(w, "create_form.html", data)
}
This keeps the user in context - they see the error in the same modal/form they were using.
Middleware Stack
handler = middleware.Logging(logger)(handler) // Request logging
handler = middleware.Recover(logger)(handler) // Panic recovery
handler = middleware.Compress()(handler) // Gzip compression
handler = middleware.SecurityHeaders()(handler) // Security headers
handler = middleware.RequestID()(handler) // Request ID generation
handler = csrf.Protect(key, opts...)(handler) // CSRF protection