From 2fac735837baf2081e790511e01c4e69e2014462 Mon Sep 17 00:00:00 2001 From: Mathias Date: Wed, 3 Jun 2026 23:43:49 +0200 Subject: [PATCH] feat(web): stamp login_events on every gated request The registration gate, once it resolves the authenticated subject to a tapir user_id, calls StampLogin (store-throttled to one row per user per day). Best- effort: a stamp failure is logged and swallowed so it never breaks the request. This is what makes the read-side Stage-0 usage signal actually accrue. Tests cover the happy-path stamp, the same-day throttle, and that an unregistered subject (redirected to /register) is never stamped. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/web/handlers.go | 5 ++++ internal/web/register_test.go | 43 +++++++++++++++++++++++++++++++++++ internal/web/registration.go | 7 ++++++ 3 files changed, 55 insertions(+) diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 624d515..d3ea6ae 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -34,6 +34,11 @@ type Store interface { DeleteConnection(ctx context.Context, userID, provider string) error DeleteUser(ctx context.Context, userID string) error DisplayName(ctx context.Context, userID string) (string, error) + + // StampLogin records (throttled, one row per user per day) that the resolved + // user was active on this request — the read-side Stage-0 usage signal the + // registration gate stamps for every authenticated request. + StampLogin(ctx context.Context, userID string) error } // SecretRemover deletes secret material by its opaque ref. *secrets.FileStore diff --git a/internal/web/register_test.go b/internal/web/register_test.go index e28f83e..eeae201 100644 --- a/internal/web/register_test.go +++ b/internal/web/register_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + "github.com/jackc/pgx/v5/pgxpool" "github.com/stretchr/testify/require" ) @@ -74,6 +75,48 @@ func TestRegisterCreatesExactlyOneUserAndIdentity(t *testing.T) { require.Equal(t, 1, totalIdents) } +// loginEventCount counts login_events for the stub user via the raw pool. +func loginEventCount(t *testing.T, p *pgxpool.Pool) int { + t.Helper() + var n int + require.NoError(t, p.QueryRow(context.Background(), + `SELECT count(*) FROM login_events WHERE user_id = $1`, userID).Scan(&n)) + return n +} + +// TestGateStampsLoginEventThrottled: a gated request for a registered user stamps +// exactly one login event, and a same-day repeat is throttled to no new row — the +// read-side Stage-0 signal flowing from the registration gate. +func TestGateStampsLoginEventThrottled(t *testing.T) { + app := newApp(t) // stubSubject → userID + p := rawPool(t) + resetDB(t, p) + require.Equal(t, 0, loginEventCount(t, p)) + + rec := do(t, app, httptest.NewRequest(http.MethodGet, "/", nil)) + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, 1, loginEventCount(t, p), "a gated request must stamp one login event") + + do(t, app, httptest.NewRequest(http.MethodGet, "/", nil)) + require.Equal(t, 1, loginEventCount(t, p), "a same-day repeat must not stamp again") +} + +// TestUnregisteredSubjectIsNotStamped: a subject with no tapir user is redirected +// to /register and never reaches the stamp (no user_id to attribute it to). +func TestUnregisteredSubjectIsNotStamped(t *testing.T) { + app := newAppAs(t, "unregistered-sub") + p := rawPool(t) + truncateAll(t, p) + + rec := do(t, app, httptest.NewRequest(http.MethodGet, "/", nil)) + require.Equal(t, http.StatusFound, rec.Code) + + var n int + require.NoError(t, p.QueryRow(context.Background(), + `SELECT count(*) FROM login_events`).Scan(&n)) + require.Equal(t, 0, n, "an unregistered subject must not stamp a login event") +} + func TestRegisterRejectsMissingFields(t *testing.T) { app := newAppAs(t, "incomplete-subject") truncateAll(t, rawPool(t)) diff --git a/internal/web/registration.go b/internal/web/registration.go index 5979419..a2b2b8a 100644 --- a/internal/web/registration.go +++ b/internal/web/registration.go @@ -64,6 +64,13 @@ func (a *App) registrationGate(h http.Handler) http.Handler { http.Redirect(w, r, registerPath, http.StatusFound) return } + // Stamp the read-side usage signal (Stage-0 gate, ADR-016): the store + // throttles this to one row per user per day, so a stamp on every gated + // request is cheap. Best-effort — a stamp failure must never break the + // request the user actually came for, so it is logged and swallowed. + if err := a.Store.StampLogin(r.Context(), userID); err != nil { + a.logger().Warn("stamp login event", "user", userID, "err", err) + } h.ServeHTTP(w, r.WithContext(withUserID(r.Context(), userID))) }) }