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) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 23:46:03 +02:00
co-authored by Claude Opus 4.8
parent de54cd33b2
commit 2fac735837
3 changed files with 55 additions and 0 deletions
+5
View File
@@ -34,6 +34,11 @@ type Store interface {
DeleteConnection(ctx context.Context, userID, provider string) error DeleteConnection(ctx context.Context, userID, provider string) error
DeleteUser(ctx context.Context, userID string) error DeleteUser(ctx context.Context, userID string) error
DisplayName(ctx context.Context, userID string) (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 // SecretRemover deletes secret material by its opaque ref. *secrets.FileStore
+43
View File
@@ -7,6 +7,7 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -74,6 +75,48 @@ func TestRegisterCreatesExactlyOneUserAndIdentity(t *testing.T) {
require.Equal(t, 1, totalIdents) 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) { func TestRegisterRejectsMissingFields(t *testing.T) {
app := newAppAs(t, "incomplete-subject") app := newAppAs(t, "incomplete-subject")
truncateAll(t, rawPool(t)) truncateAll(t, rawPool(t))
+7
View File
@@ -64,6 +64,13 @@ func (a *App) registrationGate(h http.Handler) http.Handler {
http.Redirect(w, r, registerPath, http.StatusFound) http.Redirect(w, r, registerPath, http.StatusFound)
return 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))) h.ServeHTTP(w, r.WithContext(withUserID(r.Context(), userID)))
}) })
} }