Files
member-console/internal/server
cgalo5758 0b48297b0e Add a tier on the runner, under a holder limit, with one act
A tier add aligns every organization holding a live provision of the
product inside one transaction that holds the exclusive rendezvous, so
its hold grew with the product's holders, and it locked their pools in
the order their provisions were created, the one catalog path that did
not lock in pool id order (#194). It checked the product outside its
transaction, answered "Product not found." for any failed read, showed
the database's text when a pool already held a rung, and answered a
lock wait with "see server logs" (#216).

AddTier now runs on runPlanAct. Inside the exclusive transaction it:

- reads the product and refuses one that is missing ("Product not
  found.") or not published; any other failed read is a failed add;
- counts the holders and refuses above planActHolderCap, naming the
  count and the limit, and, when more than half hold the product through
  an org type's default on a ladder, the way through: a default change
  with Migrate;
- locks the holders' pools in ascending id, reads their provisions
  again, records the act (tier_added, with names read inside) and
  inserts the tier;
- reads ListTierAddCollisions for the locked pools and refuses the first
  pool, in pool id order, that already holds a rung on the ladder or
  holds two live provisions of the product, naming the organization and
  the product, before any alignment;
- aligns each pool and materializes it once, with a reason that names
  the ladder rather than its id.

planActHolderCap is 200. A timed run on PostgreSQL 18.1, one run per
holder count from 50 to 3,200, measured a removal with migrate at 2.2
to 2.6 ms per holder and an add at 0.7 to 1.0 ms. The removal, which
takes the same cap in a later change, binds: 200 holders hold the
rendezvous for about half a second. The timing test lifts the cap so it
can still measure past it.

CreatePlanLadderTier only parses, calls and renders. A lock wait or a
deadlock at any step answers "Another change collided with this one;
try again." A collision answers under the product field. Every other
failure, including a CF003 the collision read should have prevented,
answers "Failed to add tier. Reference: {request id}." and logs the step
and the driver text through the request's logger; the driver text is
never shown.

Goldens: add-aligns-a-live-provision and tier-change-during-conferral/
a-tier-add gain the act row, the ascending pool locks and the ladder's
name in the reason; add-begin-fails answers with the reference and logs
the step; add-collides-with-a-held-rung is refused by the collision read
with no alignment statement; contention's add answers the collision
sentence. Placeholders renumber in three of them because a request id
or a provision id moved. New: add-above-the-limit,
add-above-the-limit-default-sourced, add-product-unread and
add-product-held-twice. The full suite passes.
2026-10-19 15:29:21 -04: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