Files
cgalo5758 dec031e6dc Name every ladder a grant leaves empty when it supersedes a bundle
#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
2026-10-29 22:34:15 -05:00
..
2026-09-06 02:29:42 -05:00
2026-09-06 02:29:42 -05:00

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 registration
  • member_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 features
  • price_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