Files
member-console/internal/embeds/static/error-handler.js
T
cgalo5758 d1151dd6fe Wait for the identity provider's discovery document without blocking start
Third section of #141. start no longer fetches the identity provider's
discovery document before it serves. Setup returns a Config whose two
discovery values are empty, and Discover runs beside the server from
before the listener opens: it fetches the document, publishes the
verifier and then the sign-in endpoints, logs "identity provider
discovery succeeded; sign-in is available", and returns. Until it has,
/login and /callback answer a styled 503 "Sign-in unavailable" and
/register "Registration unavailable", each with Retry-After: 5 and a
"Try again" action, and a sign-in request wakes the loop's wait. A
document whose issuer is not the configured one, or that names no
end-session endpoint (or one that does not parse as an absolute URL),
publishes nothing that sign-in reads and is retried at the cap.

The two values sit behind two atomic.Pointers in auth.Config. The
sign-in value holds a pointer to the verifier value it was published
with, and is stored only after the verifier, from the same document, so
a request never pairs endpoints from one document with a verifier from
another. Re-derivation of roles reads the verifier alone, so it runs
while sign-in still waits for an end-session endpoint; POST /logout
before discovery ends the console's session, lands on /, and revokes
nothing. A Config built by hand, as most tests build theirs, reads its
exported fields as it did.

internal/retryloop is the loop both discovery and, in a later section,
the Temporal connection run: waits from 0.5 seconds doubling to a
15-second cap, plus up to half again of jitter; a wake ends a wait no
sooner than a second after the last attempt began; a failure marked
AtCap makes only the next wait's base the cap, and a wake does not end
that wait. Its log decay writes the first failure, a changed error at
most once per 30 seconds, otherwise one line per 5 minutes, Warn until
the escalation time and Error from it, with the line at the escalation
time written at once; every line's attempts is the attempts so far.
The htmx 5xx toast reads "Server error. Try again later."

Corrections to the change text, made while building: the wait after
an AtCap failure is the cap plus the jitter (the design's bound of
about 23 seconds after an outage already counted the jitter), and every
log line's attempts is the number of attempts so far (one meaning on
every line, matching elapsed), where the design said the cap alone and
the attempts since the last line.

internal/server/testdata/routes-not-ready.golden is new: it differs from
routes.golden in the four sign-in rows only. routes.golden and
route_pages.golden do not change.

Full suite on reset databases: 4422 pass, 15 skip, none failed. The console booted on the
test stack and logged the discovery success. go test -race -p 1 over
internal/retryloop, internal/auth and internal/server after
reset-test-db.sh: every package passes, no data race. make lint passes. Breaks, each caught:
- LoginHandler's gate after RenewToken
- the gate without the wake
- the gate answers through http.Error
- the gate answers 500 in place of 503
- the gate without Retry-After
- /register without the gate
- the sign-in value published from a document without an end-session endpoint
- the sign-in value stored outside the issuer check
- the verifier published only together with the sign-in value
- the verifier published from a document with another issuer
- an unusable document returned as a plain error
- the attempt on http.DefaultClient
- LogoutHandler before publication answers 500
- LogoutHandler before publication redirects without destroying the session
- LogoutHandler with the verifier and no end-session endpoint revokes nothing
- refreshIdentity ends the session when no verifier is published
- Run without the cap
- Run without the jitter
- a wake that ends an AtCap wait
- the wait ignores ctx.Done()
- the discovery holder is a plain field in place of an atomic.Pointer
- a Failure's level ignored
- the decay without its five-minute rule
- the escalation never reached
- the changed-text rule without its thirty-second floor
- a wake that ignores the one-second gap
- answer() without the Retry-After line
- re-derivation reads the exported fields in place of the holder
- a level Error line written at once whatever the last line
- an AtCap failure ratchets the doubling to the cap
- the discovery log lines without the issuer attribute
- the failure lines with a fixed attempts attribute
- an end-session endpoint that does not parse or is not absolute is accepted
- Run attempts once on a context that has ended
- a Failure returned by pointer drops its message and level
2026-10-23 17:15:45 -04:00

93 lines
4.0 KiB
JavaScript

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
// Error handling for HTMX requests
// Shows toast notifications for failed requests and preserves user input.
//
// htmx 4 swaps every response except 204/304 by default. Swap suppression is
// declarative, not scripted: the statuses whose bodies are not designed as
// swap content (403, 5xx) sit in the "noSwap" list of each page's
// htmx-config meta tag. 422 swaps natively — its body is the re-rendered
// form with inline field errors (docs/operator-ux-conventions.md §6), so no
// script steers that swap anymore. This script only reports:
// htmx:response:error HTTP >= 400 — toast by status; 422 stays silent
// htmx:error network failure or timeout — toast
// Signal that a request's swap was suppressed or never landed (CSRF expiry,
// 5xx, network error). sortable-reorder.js listens for this to recover a
// frozen drag-to-reorder: SortableJS has already moved the DOM row and
// disabled the instance, so a suppressed swap would leave the table showing
// the new order with stale rank badges and drag permanently dead. The
// listener re-enables the instance and re-fetches server order (audit
// finding #55). It is a no-op unless a reorder was actually in flight, so
// dispatching broadly is safe.
function signalReorderRecover() {
document.body.dispatchEvent(new CustomEvent('operator:reorder-recover'));
}
// Show error toast with a message
function showErrorToast(message) {
const toastBody = document.getElementById('errorToastBody');
const toastEl = document.getElementById('errorToast');
if (!toastBody || !toastEl) {
console.error('Error toast elements not found');
alert(message); // Fallback to alert
return;
}
toastBody.textContent = message;
const toast = new bootstrap.Toast(toastEl, { delay: 8000 });
toast.show();
}
// HTTP error responses (status >= 400). Whether the body swaps is decided by
// the noSwap config, not here.
document.body.addEventListener('htmx:response:error', function(evt) {
const ctx = evt.detail && evt.detail.ctx;
const status = ctx && ctx.response ? ctx.response.status : 0;
// 422 Unprocessable Entity is the server-side validation contract: the
// body carries the form re-rendered with `is-invalid` + `.invalid-feedback`
// on the offending field(s) and swaps inline by default behavior. The
// inline message is strictly more useful than a generic toast, so stay
// silent.
if (status === 422) {
return;
}
// 403 Forbidden (in noSwap: the swap is suppressed, user input preserved).
if (status === 403) {
const text = (ctx && ctx.text) || '';
if (text.includes('CSRF') || text.includes('csrf')) {
// The server's origin check refused the request as cross-origin
// (internal/middleware/csrf.go): no token is involved, so this is a
// request whose Sec-Fetch-Site or Origin did not match the site, or
// a deployment whose base-url is not the origin it is served from.
// The session itself is fine; a session that has ended arrives as a
// 401 with HX-Redirect and never reaches here.
showErrorToast('Refused: the request did not come from this site.');
} else {
showErrorToast('Access denied. Please refresh the page and try again.');
}
signalReorderRecover();
return;
}
// 5xx server errors (in noSwap: the swap is suppressed).
if (status >= 500) {
showErrorToast('Server error. Try again later.');
signalReorderRecover();
return;
}
// Other 4xx swap by default — the server may return helpful error HTML —
// but show a toast as backup.
showErrorToast('Request failed. Please check your input and try again.');
});
// Request-level failures: network errors, timeouts (htmx 4 defaults to 60s).
document.body.addEventListener('htmx:error', function(evt) {
showErrorToast('Network error. Please check your connection and try again.');
// No swap ever lands, so a drag-to-reorder started here stays frozen too.
signalReorderRecover();
});