feat(hub): Direction-2 immediate-sync wait channel (v0.58.0)

GET /api/v1/wait long-poll: the box holds an authed hanging GET; the hub
completes it the instant any operator intent bumps that customer's in-memory
generation, then the box fires its ordinary report and the ACK delivers
everything through the unchanged machinery. 240s hold with a 25s heartbeat
newline defeats the nginx 60s proxy_read_timeout with no ingress annotation;
WriteTimeout lifted per-connection via ResponseController.

- internal/intent: per-customer generation counter + waiter registry
  (Bump/Wait/Close), coalescing to latest, race-closer, in-memory by design.
  Red-proofs: counter-vs-queue + race-closer (run-fail-reverted).
- api/wait.go: the endpoint (per-customer only; global key 400; A cannot see B).
- web bumps after every intent write (fire-after-commit): config CRUD, claim
  resend, offsite re-issue/freeze, password regen, block/unblock, floors
  (global bumps all config-managed), controller log-tail + log-bundle.
- main.go: one intent hub shared by web+api; Close() before server.Shutdown.

Pairs with controller v0.140.0 (the long-poll client). Grounding:
documentation/audits/SPIKE-immediate-sync-transport-2026-07-16.md.
This commit is contained in:
2026-07-16 20:44:22 +02:00
parent 10e07f5747
commit 60244727ad
11 changed files with 790 additions and 0 deletions
+16
View File
@@ -17,6 +17,7 @@ import (
"gitea.dooplex.hu/admin/felhom-hub/internal/assets"
"gitea.dooplex.hu/admin/felhom-hub/internal/claim"
"gitea.dooplex.hu/admin/felhom-hub/internal/configgen"
"gitea.dooplex.hu/admin/felhom-hub/internal/intent"
"gitea.dooplex.hu/admin/felhom-hub/internal/mailrelay"
"gitea.dooplex.hu/admin/felhom-hub/internal/notify"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
@@ -72,6 +73,11 @@ type Handler struct {
// as the manual "Re-issue offsite credentials" button, so escrow invalidation + events ride
// along). nil = no auto re-issue; a no-op when offsite isn't provisioned/enabled for the customer.
offsiteReissuer func(ctx context.Context, customerID string) error
// intentHub (v0.58.0, Direction-2 immediate-sync) is the in-memory per-customer generation
// notifier that GET /api/v1/wait long-polls against. nil = wait endpoint returns 503 (the box
// falls back to the 15-min cycle). Shared with the web server, whose intent handlers Bump it.
intentHub *intent.Hub
}
// SetClaimEngine wires the customer-claim code engine (nil-safe everywhere it is used).
@@ -95,6 +101,12 @@ func (h *Handler) SetLatestVersionProvider(p LatestVersionProvider) {
h.latestVersion = p
}
// SetIntentHub wires the operator-intent notifier for GET /api/v1/wait (v0.58.0; nil-safe — an
// unset hub makes the wait endpoint return 503).
func (h *Handler) SetIntentHub(hub *intent.Hub) {
h.intentHub = hub
}
// New creates a new API handler.
func New(store *store.Store, apiKey, resendAPIKey, fromEmail string, templateProvider ConfigTemplateProvider, logger *log.Logger) *Handler {
return &Handler{
@@ -180,6 +192,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && path == "/report":
h.handleReport(w, r)
// Direction-2 immediate-sync (v0.58.0): the box long-polls here; the hub completes it on any
// operator-intent bump for the box's customer, then the box fires its ordinary report.
case r.Method == http.MethodGet && path == "/wait":
h.handleWait(w, r)
case r.Method == http.MethodPost && path == "/host-report":
h.handleHostReport(w, r)
case r.Method == http.MethodPost && path == "/host-enroll":