Files
member-console/internal/server
cgalo5758 e52fdb37c8 Build every advisory key from its family, and take locks only through the helpers
Second of two changes for #186; closes it. Each kind of object the
console serializes on now has a family, and every advisory key is built
one way: hashtextextended('<family>:<id>', 0), with a UUID written in
canonical form. db.Lock and db.LockShared (internal/db/locks.go) build
the key and take the lock on a *sql.Tx only, and each family's
statement begins with a name line, so pg_stat_activity and the test
traces show which family a backend waits on.

Families, in the order a transaction takes them: materialization,
domains-registry, fedwiki-workspace, lifecycle, stripe-subscription,
stripe-invoice, stripe-product-sync, desired-state. The lifecycle and
desired-state keys are unchanged byte for byte; the other six change.
Migration 00040 replaces core.assert_rendezvous to look for the new
materialization key (owner, grants, SECURITY DEFINER and search_path
kept; Down restores migration 18's body), and both seeds take the new
key right after BEGIN.

docs/database-locks.md lists the families in a table and writes one
order every path keeps: the materialization lock first, then the
families in table order, then rows. The project lint gains
advisory-lock-site (an advisory lock function named in a Go string or
in SQL or shell code outside the helpers, the test kit's probe, the
migrations that name the rendezvous and the rule's own tests; a seed
taking any other key, or the rendezvous anywhere but first after
BEGIN), pool-lock-site (a core.resource_pools row lock outside
LockResourcePool and LockGrantPool, or a call or method value of those
outside entitlements/tx.go) and lock-families (the doc's table against
the code). The walk covers cmd/, scripts/, internal/, test/ and the
root's Go files, and skips testdata and directories whose name begins
with a dot, as the go tool does. Tests take a family's lock through the
same helpers and probe one with dbtest.TryLock.

Recording a lifecycle request comes in two forms:
RecordLifecycleRequest opens its own transaction on a database or a
pinned connection, and RecordLifecycleRequestInTx runs in the caller's
*sql.Tx, so a connection is never used outside a transaction (which
released the instance lock at once) and does not compile in the second.
The same holds for RecordFirstLifecycleRequest.

docs/production-deployment.md now states the upgrade order for every
upgrade: stop every process of the running release, run migrate up if
migrations run separately, then start the new release; two releases
running at once can build different keys for one lock and share none.
docs/go-conventions.md, docs/testing.md and
docs/building-an-integration.md follow the code. The lint command's
help text names the files the lock rules read. 202 golden lines in 109
files change their statement text.

Deploy: stop every process of the previous release first. Rollback:
stop this release; as the migration user, in one transaction, run
00040's Down section and DELETE FROM goose_db_version_core WHERE
version_id = 40; start the previous release (migrate down rolls back
the last integration stream first). A deployment using console_reader
re-runs scripts/db-reader-role.sql.

Break list: 35 records, 35 CAUGHT. Named without a record: the *sql.Tx
parameters of the helpers and the -InTx forms (a break that passes a
connection does not compile, and the build is the check), and the
comments. Full suite on reset databases: 5727 passed, 16 skipped, none
failed.
2026-10-31 04:03:32 -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