diff --git a/cmd/tapir/main.go b/cmd/tapir/main.go index 448e733..75eb3c0 100644 --- a/cmd/tapir/main.go +++ b/cmd/tapir/main.go @@ -144,10 +144,11 @@ func cmdRun(ctx context.Context, log *slog.Logger) error { return r.Loop(ctx, cfg.PollInterval) } -// cmdServe runs the Stage-0 web UI: the summary reader over the existing store -// (ADR-003 — a new transport, not new core). Auth is the StubAuth allow-all seam -// keyed to the configured user; the Conductor swaps in oidc.DexAuth at merge — -// the only line that changes is the `authn` assignment below. +// cmdServe runs the Stage-1 web UI: the summary reader over the existing store +// (ADR-003 — a new transport, not new core). Auth (web.Auth) gates access; the +// registration gate resolves the authenticated subject to a tapir user_id and +// scopes every store access by it (ADR-012). With Dex configured, real OIDC login +// is used; otherwise StubAuth (dev only). The store doubles as the Identity port. func cmdServe(ctx context.Context, log *slog.Logger) error { cfg, err := config.Load() if err != nil { @@ -185,7 +186,7 @@ func cmdServe(ctx context.Context, log *slog.Logger) error { log.Warn("web auth: STUB allow-all (no TAPIR_OIDC_ISSUER) — local dev only, do not expose") } - app := &web.App{Store: st, Auth: authn, UserID: cfg.UserID, Log: log} + app := &web.App{Store: st, Identity: st, Auth: authn, Log: log} srv := &http.Server{ Addr: cfg.HTTPAddr, Handler: app.Router(), diff --git a/internal/web/handlers.go b/internal/web/handlers.go index db1884f..6c43ef5 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -23,15 +23,16 @@ type Store interface { ClearAction(ctx context.Context, userID, videoID, action string) error } -// App is the Stage-0 web surface: handlers over the store, gated by an Auth -// implementation. UserID is the single configured tapir user every store -// operation runs as (ADR-011 — Auth only gates access; it does not select the -// store identity). +// App is the Stage-1 web surface: handlers over the store, gated by an Auth +// implementation (authentication) and a registration gate (which resolves the +// authenticated subject to its tapir user_id and stashes it per request). Every +// data handler scopes by that resolved id — CurrentUserID(r) — not by a single +// configured user (ADR-012, multi-user with enforced isolation). type App struct { - Store Store - Auth Auth - UserID string - Log *slog.Logger + Store Store + Identity Identity + Auth Auth + Log *slog.Logger } func (a *App) logger() *slog.Logger { @@ -54,8 +55,14 @@ func (a *App) Router() http.Handler { app.HandleFunc("GET /{$}", a.handleList) app.HandleFunc("GET /v/{videoId}", a.handleDetail) app.HandleFunc("POST /v/{videoId}/action", a.handleAction) + app.HandleFunc("GET /register", a.handleRegisterForm) + app.HandleFunc("POST /register", a.handleRegister) - root.Handle("/", a.Auth.Middleware(app)) + // Two layers: Auth.Middleware requires a Dex session (you must be logged in); + // registrationGate requires a tapir user (else → /register) and stashes the + // resolved user_id. /register lives inside the auth guard but is exempt from + // the registration gate (you must be able to reach it before you have a user). + root.Handle("/", a.Auth.Middleware(a.registrationGate(app))) return root } @@ -69,6 +76,10 @@ func (a *App) handleHealthz(w http.ResponseWriter, _ *http.Request) { // query string. An HTMX request gets only the table fragment so the filter form // can swap #summary-list in place; a plain request gets the full page. func (a *App) handleList(w http.ResponseWriter, r *http.Request) { + userID, ok := a.currentUserID(w, r) + if !ok { + return + } q := r.URL.Query() f := Filter{ Channel: q.Get("channel"), @@ -76,7 +87,7 @@ func (a *App) handleList(w http.ResponseWriter, r *http.Request) { To: q.Get("to"), } - rows, err := a.Store.ListSummaries(r.Context(), a.UserID, 0) + rows, err := a.Store.ListSummaries(r.Context(), userID, 0) if err != nil { a.serverError(w, r, "list summaries", err) return @@ -92,8 +103,12 @@ func (a *App) handleList(w http.ResponseWriter, r *http.Request) { // handleDetail renders one summary in full (highlights, takeaways, action group). func (a *App) handleDetail(w http.ResponseWriter, r *http.Request) { + userID, ok := a.currentUserID(w, r) + if !ok { + return + } videoID := r.PathValue("videoId") - row, err := a.Store.GetSummaryByVideo(r.Context(), a.UserID, videoID) + row, err := a.Store.GetSummaryByVideo(r.Context(), userID, videoID) if errors.Is(err, store.ErrNotFound) { http.NotFound(w, r) return @@ -110,6 +125,10 @@ func (a *App) handleDetail(w http.ResponseWriter, r *http.Request) { // the refreshed button-group fragment for HTMX; without JS it redirects back to // the detail page (POST→redirect→GET). func (a *App) handleAction(w http.ResponseWriter, r *http.Request) { + userID, ok := a.currentUserID(w, r) + if !ok { + return + } videoID := r.PathValue("videoId") action := r.FormValue("action") if !isActionVerb(action) { @@ -117,23 +136,23 @@ func (a *App) handleAction(w http.ResponseWriter, r *http.Request) { return } - current, err := a.Store.ActionsFor(r.Context(), a.UserID, []string{videoID}) + current, err := a.Store.ActionsFor(r.Context(), userID, []string{videoID}) if err != nil { a.serverError(w, r, "read actions", err) return } if actionSet(current[videoID])[action] { - err = a.Store.ClearAction(r.Context(), a.UserID, videoID, action) + err = a.Store.ClearAction(r.Context(), userID, videoID, action) } else { - err = a.Store.SetAction(r.Context(), a.UserID, videoID, action) + err = a.Store.SetAction(r.Context(), userID, videoID, action) } if err != nil { a.serverError(w, r, "toggle action", err) return } - updated, err := a.Store.ActionsFor(r.Context(), a.UserID, []string{videoID}) + updated, err := a.Store.ActionsFor(r.Context(), userID, []string{videoID}) if err != nil { a.serverError(w, r, "read actions", err) return @@ -146,10 +165,29 @@ func (a *App) handleAction(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/v/"+videoID, http.StatusSeeOther) } +// currentUserID returns the tapir user_id the registration gate resolved for this +// request. Behind the gate it is always present; a miss means a handler was +// reached without scoping (a wiring bug), so it answers 500 and reports false. +func (a *App) currentUserID(w http.ResponseWriter, r *http.Request) (string, bool) { + id, ok := CurrentUserID(r) + if !ok { + a.serverError(w, r, "current user", errNoCurrentUser) + } + return id, ok +} + // render writes a templ component as HTML. A render error is logged, not retried: // headers may already be flushed, so there is nothing useful to send the client. func (a *App) render(w http.ResponseWriter, r *http.Request, c templ.Component) { + a.renderStatus(w, r, http.StatusOK, c) +} + +// renderStatus writes a templ component as HTML with an explicit status code (the +// Content-Type must be set before WriteHeader, so this is the single place that +// orders them correctly). +func (a *App) renderStatus(w http.ResponseWriter, r *http.Request, status int, c templ.Component) { w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) if err := c.Render(r.Context(), w); err != nil { a.logger().Error("render", "path", r.URL.Path, "err", err) } diff --git a/internal/web/handlers_test.go b/internal/web/handlers_test.go index eff4a0a..211e3dc 100644 --- a/internal/web/handlers_test.go +++ b/internal/web/handlers_test.go @@ -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}}, } } diff --git a/internal/web/register_test.go b/internal/web/register_test.go new file mode 100644 index 0000000..e28f83e --- /dev/null +++ b/internal/web/register_test.go @@ -0,0 +1,86 @@ +package web_test + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUnregisteredSubjectRedirectedToRegister(t *testing.T) { + app := newAppAs(t, "unregistered-sub") + truncateAll(t, rawPool(t)) + + rec := do(t, app, httptest.NewRequest(http.MethodGet, "/", nil)) + require.Equal(t, http.StatusFound, rec.Code) + require.Equal(t, "/register", rec.Header().Get("Location")) +} + +func TestRegisterPageReachableWhenUnregistered(t *testing.T) { + app := newAppAs(t, "unregistered-sub") + truncateAll(t, rawPool(t)) + + rec := do(t, app, httptest.NewRequest(http.MethodGet, "/register", nil)) + require.Equal(t, http.StatusOK, rec.Code, "/register is exempt from the gate") + require.Contains(t, body(t, rec), "Complete your registration") +} + +func TestRegisteredSubjectPassesThrough(t *testing.T) { + app := newApp(t) // stubSubject + resetDB(t, rawPool(t)) + + rec := do(t, app, httptest.NewRequest(http.MethodGet, "/", nil)) + require.Equal(t, http.StatusOK, rec.Code) + require.Contains(t, body(t, rec), " +
Choose a display name to finish setting up your Tapir account.
+ if errMsg != "" { +{ errMsg }
+ } + + + } +} + // ActionButtons is the toggle group fragment returned by POST /v/{id}/action. // Each button submits its verb; HTMX swaps this element in place (outerHTML), // and without JS the form POSTs and the handler redirects back to the detail diff --git a/internal/web/views_templ.go b/internal/web/views_templ.go index cb5b4f1..7aec60c 100644 --- a/internal/web/views_templ.go +++ b/internal/web/views_templ.go @@ -595,11 +595,10 @@ func DetailPage(r store.SummaryRow) templ.Component { }) } -// ActionButtons is the toggle group fragment returned by POST /v/{id}/action. -// Each button submits its verb; HTMX swaps this element in place (outerHTML), -// and without JS the form POSTs and the handler redirects back to the detail -// page. active marks the verbs currently set for (user, video). -func ActionButtons(videoID string, active map[string]bool) templ.Component { +// RegisterPage is the explicit registration step (ADR-012): an authenticated Dex +// subject with no tapir user picks a display name and accepts the terms to create +// their account. errMsg, when set, reports a validation problem on the prior POST. +func RegisterPage(email, errMsg string) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -620,112 +619,209 @@ func ActionButtons(videoID string, active map[string]bool) templ.Component { templ_7745c5c3_Var26 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = Layout("Tapir — Register").Render(templ.WithChildren(ctx, templ_7745c5c3_Var27), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var27 templ.SafeURL - templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs(actionURL(videoID)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 165, Col: 29} + return nil + }) +} + +// ActionButtons is the toggle group fragment returned by POST /v/{id}/action. +// Each button submits its verb; HTMX swaps this element in place (outerHTML), +// and without JS the form POSTs and the handler redirects back to the detail +// page. active marks the verbs currently set for (user, video). +func ActionButtons(videoID string, active map[string]bool) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var30 := templ.GetChildren(ctx) + if templ_7745c5c3_Var30 == nil { + templ_7745c5c3_Var30 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err }