feat(web): registration gate + per-request user-id seam (ADR-012)
CI / Lint / Test / Vet (push) Successful in 9s
CI / Build & Import (push) Successful in 10s
CI / Mirror to GitHub (push) Failing after 3s

Multi-user web surface. Two layered middlewares: Auth.Middleware (Dex
session required) wraps registrationGate, which resolves the authenticated
subject -> tapir user_id once per request via the new web.Identity port and
stashes it. A subject with no tapir user is redirected to GET /register
(display name + accept-terms); POST /register calls RegisterUser then
redirects to /. /register is inside the auth guard but exempt from the gate
(/auth/* and /healthz too).

Current-user seam: CurrentUserID(r) (string, bool) returns the resolved id
from the request context. The list/detail/action handlers now scope by it,
replacing the single configured App.UserID (removed). App gains an Identity
field; *store.Store satisfies both Store and Identity. cmd/tapir wires
Identity: st and drops UserID.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 16:00:30 +02:00
co-authored by Claude Opus 4.8
parent e62df0027d
commit 7b4960e417
7 changed files with 484 additions and 72 deletions
+36 -6
View File
@@ -45,6 +45,9 @@ const (
userID = "11111111-1111-1111-1111-111111111111"
videoX = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
videoY = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
// stubSubject is the StubAuth Dex subject the registration gate resolves to
// the fixed userID (mapping seeded by resetDB).
stubSubject = "stub-subject-xyz"
)
func newStore(t *testing.T) *store.Store {
@@ -63,21 +66,48 @@ func rawPool(t *testing.T) *pgxpool.Pool {
return p
}
func resetDB(t *testing.T, p *pgxpool.Pool) {
// truncateAll wipes every table to a pristine state (user_identities is cleared
// via the ON DELETE CASCADE from users). Registration tests use this directly so
// no subject is pre-registered.
func truncateAll(t *testing.T, p *pgxpool.Pool) {
t.Helper()
_, err := p.Exec(context.Background(),
`TRUNCATE summary_actions, sink_deliveries, summaries, transcripts, videos, users CASCADE`)
require.NoError(t, err)
}
// newApp builds the App under test: the real store, StubAuth (allow-all) keyed to
// the configured user. This is exactly cmd/tapir's serve wiring minus Dex.
// resetDB truncates, then seeds the StubAuth identity (stubSubject → userID) so
// the registration gate resolves the stub user and the existing handler tests can
// keep seeding and scoping by the fixed userID.
func resetDB(t *testing.T, p *pgxpool.Pool) {
t.Helper()
truncateAll(t, p)
ctx := context.Background()
_, err := p.Exec(ctx, `INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`, userID)
require.NoError(t, err)
_, err = p.Exec(ctx,
`INSERT INTO user_identities (dex_subject, user_id) VALUES ($1, $2)
ON CONFLICT (dex_subject) DO NOTHING`, stubSubject, userID)
require.NoError(t, err)
}
// newApp builds the App under test as the registered stub user (subject
// stubSubject, resolved to userID by resetDB). This is cmd/tapir's serve wiring
// minus Dex: the store is both the Store and the Identity port.
func newApp(t *testing.T) *web.App {
t.Helper()
return newAppAs(t, stubSubject)
}
// newAppAs builds the App under test with a specific StubAuth Dex subject, so
// registration-gate tests can drive registered vs unregistered subjects.
func newAppAs(t *testing.T, subject string) *web.App {
t.Helper()
s := newStore(t)
return &web.App{
Store: newStore(t),
Auth: web.StubAuth{U: web.User{Subject: userID}},
UserID: userID,
Store: s,
Identity: s,
Auth: web.StubAuth{U: web.User{Subject: subject}},
}
}