feat(web): registration gate + per-request user-id seam (ADR-012)
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:
+53
-15
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user