Plans cover TABLO-01..06 via MVP vertical slices: foundation (migration + sqlc + test scaffold + button CSS), list+create (dashboard, inline form, OOB swap), and detail+edit+delete (ownership 404, inline edit fragments, inline confirm delete). Includes Nyquist VALIDATION.md and PATTERNS.md with real analog excerpts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
29 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | tags | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 03-tablos-crud | 03 | execute | 3 |
|
|
false |
|
|
|
Purpose: Complete the Tablos CRUD vertical so the user story shipped in Plan 02 (create + list) extends to read, update, and delete — all HTMX-driven with degradation to plain POST.
Output:
- Detail page handler + ownership-enforced 404 path
- Inline-edit GET/POST handlers for title and description (with discard-changes path)
- Delete confirmation + delete handlers + cancel handler
- All routes wired into the existing RequireAuth chi group in correct static-before-parametric order
<execution_context> @/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md @/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/STATE.md @.planning/phases/03-tablos-crud/03-CONTEXT.md @.planning/phases/03-tablos-crud/03-RESEARCH.md @.planning/phases/03-tablos-crud/03-PATTERNS.md @.planning/phases/03-tablos-crud/03-UI-SPEC.md @.planning/phases/03-tablos-crud/03-01-SUMMARY.md @.planning/phases/03-tablos-crud/03-02-SUMMARY.md @backend/internal/web/handlers_tablos.go @backend/internal/web/router.go @backend/templates/tablos.templ @backend/internal/db/sqlc/tablos.sql.go @backend/internal/auth/middleware.goHandler constructors (all return http.HandlerFunc; signature pattern matches Plan 02):
- TabloDetailHandler(deps TablosDeps) → GET /tablos/{id}
- TabloEditTitleHandler(deps TablosDeps) → GET /tablos/{id}/edit-title
- TabloShowTitleHandler(deps TablosDeps) → GET /tablos/{id}/show-title
- TabloEditDescHandler(deps TablosDeps) → GET /tablos/{id}/edit-desc
- TabloShowDescHandler(deps TablosDeps) → GET /tablos/{id}/show-desc
- TabloUpdateHandler(deps TablosDeps) → POST /tablos/{id}
- TabloDeleteConfirmHandler(deps TablosDeps) → GET /tablos/{id}/delete-confirm
- TabloDeleteCancelHandler(deps TablosDeps) → GET /tablos/{id}/delete-cancel
- TabloDeleteHandler(deps TablosDeps) → POST /tablos/{id}/delete
Templates added to tablos.templ:
- TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo)
- TabloTitleDisplay(tablo sqlc.Tablo, csrfToken string)
- TabloTitleEditFragment(tablo sqlc.Tablo, errs TabloUpdateErrors, csrfToken string)
- TabloDescDisplay(tablo sqlc.Tablo, csrfToken string)
- TabloDescEditFragment(tablo sqlc.Tablo, errs TabloUpdateErrors, csrfToken string)
- TabloDeleteButtonFragment(tablo sqlc.Tablo, csrfToken string) -- the .tablo-delete-zone with the Delete button (same shape as in TabloCard, factored out)
- TabloDeleteConfirmFragment(tablo sqlc.Tablo, csrfToken string)
- TabloNotFoundPage(user *auth.User, csrfToken string) -- 404 body with copy from UI-SPEC
Form types (extend tablos_forms.go):
- TabloUpdateErrors { Title, Description, General string }
` (the zone class on the SAME element so outerHTML swap replaces it cleanly) with hx-get="/tablos/{id}/edit-title", hx-target="closest .tablo-title-zone", hx-swap="outerHTML", role="button", aria-label="Edit title". Wraps with class "tablo-title-zone" — every Display/Edit fragment for the title shares this class so outerHTML round-trips work.
- TabloTitleEditFragment renders a `` with hidden _method=PATCH (semantic clarity per UI-SPEC §4) NOT consumed by chi but harmless, @ui.CSRFField, a labelled title input prefilled with tablo.Title, a hidden description field carrying current description value, @FieldError(errs.Title), submit button "Save changes", discard button with hx-get="/tablos/{id}/show-title" hx-target="closest .tablo-title-zone" hx-swap="outerHTML" Variant Neutral Tone Soft.
- TabloDescDisplay analogous to TabloTitleDisplay but element is a `` containing a `` or empty-state hint "Add a description"; clicking fires hx-get="/tablos/{id}/edit-desc" hx-target="closest .tablo-desc-zone" hx-swap="outerHTML".
- TabloDescEditFragment analogous to TabloTitleEditFragment but for the description textarea (3 rows) + hidden title field carrying current title; submit "Save changes" + discard "Discard changes" with hx-get="/tablos/{id}/show-desc".
- TabloDeleteButtonFragment renders a `
` wrapping a Delete button (Variant Danger Tone Soft Size MD) with hx-get="/tablos/{id}/delete-confirm", hx-target="closest .tablo-delete-zone", hx-swap="outerHTML", aria-label="Delete tablo".
- TabloDeleteConfirmFragment renders a `` containing copy: "Delete tablo?" heading, "This cannot be undone." body, a "Yes, delete" submit button (Variant Danger Tone Solid Size MD, aria-label "Confirm delete tablo") inside a `` with @ui.CSRFField, and a "Keep tablo" cancel button (Variant Neutral Tone Soft, aria-label "Keep tablo") with hx-get="/tablos/{id}/delete-cancel" hx-target="closest .tablo-delete-zone" hx-swap="outerHTML".
- TabloNotFoundPage wraps @Layout("Not found", user, csrfToken) and renders heading "Not found" + body "This tablo doesn't exist or you don't have access." (per UI-SPEC Copywriting). user may be nil if called from a path before RequireAuth runs — accept nil and forward to Layout.
- tablos_forms.go gains `type TabloUpdateErrors struct { Title, Description, General string }`.
- Re-use the dashboard's TabloCard delete-zone shape so TabloDeleteButtonFragment is the canonical implementation; update TabloCard from Plan 02 (if necessary) to call `@TabloDeleteButtonFragment(t, csrfToken)` instead of inlining the delete button — this guarantees a single source of truth for the zone HTML.
Edit backend/templates/tablos.templ to add the eight new templ components listed above. Place them after the Plan 02 components. Use exact copywriting from UI-SPEC. Use ui.Button with Variant/Tone/Size enums (ButtonVariantDanger, ButtonVariantNeutral already exist; ButtonToneSoft exists per variants.go).
Append `type TabloUpdateErrors struct { Title, Description, General string }` to backend/templates/tablos_forms.go.
If TabloCard in Plan 02 inlined the delete button HTML, refactor it to call `@TabloDeleteButtonFragment(t, csrfToken)` — DO NOT duplicate the zone markup.
Use class attribute `tablo-title-zone`, `tablo-desc-zone`, and `tablo-delete-zone` ON the outermost element of each respective Display/Edit fragment so HTMX's `hx-target="closest .tablo-X-zone"` + `hx-swap="outerHTML"` round-trips replace the zone wholesale.
Pitfall 5 reminder: no nested OOB swaps required in this plan, but the zone elements must NOT be wrapped in extra containers that the swap target would miss.
Run `just generate` to produce backend/templates/tablos_templ.go.
cd backend && just generate && grep -q "templ TabloDetailPage" templates/tablos.templ && grep -q "templ TabloTitleDisplay" templates/tablos.templ && grep -q "templ TabloTitleEditFragment" templates/tablos.templ && grep -q "templ TabloDescDisplay" templates/tablos.templ && grep -q "templ TabloDescEditFragment" templates/tablos.templ && grep -q "templ TabloDeleteButtonFragment" templates/tablos.templ && grep -q "templ TabloDeleteConfirmFragment" templates/tablos.templ && grep -q "templ TabloNotFoundPage" templates/tablos.templ && grep -q "Delete tablo?" templates/tablos.templ && grep -q "This cannot be undone." templates/tablos.templ && grep -q "Yes, delete" templates/tablos.templ && grep -q "Keep tablo" templates/tablos.templ && grep -q "Save changes" templates/tablos.templ && grep -q "Discard changes" templates/tablos.templ && grep -q "tablo-title-zone" templates/tablos.templ && grep -q "tablo-desc-zone" templates/tablos.templ && grep -q "tablo-delete-zone" templates/tablos.templ && grep -q "This tablo doesn't exist or you don't have access." templates/tablos.templ && grep -q "type TabloUpdateErrors" templates/tablos_forms.go && go build ./...
- tablos.templ contains all 8 new templ component declarations exactly as named in the interfaces block.
- File contains exact UI-SPEC copy: "Delete tablo?", "This cannot be undone.", "Yes, delete", "Keep tablo", "Save changes", "Discard changes", "Not found", "This tablo doesn't exist or you don't have access.".
- Each of `tablo-title-zone`, `tablo-desc-zone`, `tablo-delete-zone` appears as a CSS class on at least TWO different templ components (Display + Edit, or Button + Confirm) — verified by `grep -c "class=\"tablo-title-zone\"" templates/tablos.templ` >= 2 (same for desc and delete zones).
- aria-label values "Edit title", "Confirm delete tablo", "Keep tablo", "Delete tablo" all present.
- hx-target uses `closest .tablo-{title,desc,delete}-zone` in every relevant fragment (not absolute selectors) so the same template works from card or detail-page contexts.
- tablos_forms.go declares `type TabloUpdateErrors struct` with Title, Description, General string fields.
- TabloCard delegates delete-button rendering to TabloDeleteButtonFragment (no inline duplication of the delete-zone HTML).
- `just generate` produced an updated tablos_templ.go.
- `go build ./...` exits 0.
All Phase 3 templates declared, generated, and compile.
Task 2: handlers_tablos.go — detail + edit + delete handlers + router wiring
backend/internal/web/handlers_tablos.go, backend/internal/web/router.go
backend/internal/web/handlers_tablos.go (current state from Plan 02)
backend/internal/web/router.go (current state from Plan 02)
backend/internal/web/handlers_auth.go (HTMX-aware redirect, fragment dispatch)
backend/internal/auth/middleware.go (Authed, redirectTo)
backend/internal/db/sqlc/tablos.sql.go (UpdateTablo signature)
backend/internal/web/handlers_tablos_test.go (RED tests to turn green)
.planning/phases/03-tablos-crud/03-RESEARCH.md (Patterns 3, 5, 6, 7, 8 + Pitfalls 1, 6, 7)
.planning/phases/03-tablos-crud/03-PATTERNS.md (UUID extraction, ownership check, HTMX redirect)
.planning/phases/03-tablos-crud/03-UI-SPEC.md (Interaction Contract §3-§5)
Common pattern shared by every /tablos/{id}* handler:
1. `_, user, _ := auth.Authed(r.Context())`
2. Parse id: `tabloID, err := uuid.Parse(chi.URLParam(r, "id"))`. err != nil → http.NotFound(w, r); return.
3. Fetch: `tablo, err := deps.Queries.GetTabloByID(r.Context(), tabloID)`. errors.Is(err, pgx.ErrNoRows) → http.NotFound. Other errors → 500.
4. Ownership: `if tablo.UserID != user.ID { http.NotFound(w, r); return }` (D-04: 404 not 403).
TabloDetailHandler (GET /tablos/{id}):
- After common steps, render TabloDetailPage(user, csrf.Token(r), tablo). Content-Type text/html; charset=utf-8.
TabloEditTitleHandler / TabloEditDescHandler (GET /tablos/{id}/edit-title|edit-desc):
- After common steps, render TabloTitleEditFragment / TabloDescEditFragment with empty TabloUpdateErrors.
TabloShowTitleHandler / TabloShowDescHandler (GET /tablos/{id}/show-title|show-desc):
- After common steps, render TabloTitleDisplay / TabloDescDisplay (used by discard-changes path).
TabloUpdateHandler (POST /tablos/{id}):
- After common steps, read r.PostFormValue("title") and r.PostFormValue("description"). Trim title.
- Validate: title required → "Title is required."; len(title) > 255 → "Title must be 255 characters or fewer.".
- On validation error: status 422 + render TabloTitleEditFragment (if r.PostFormValue("title") was the changed field) OR more simply re-render the detail page with errs surfaced. Pragmatic choice: render TabloTitleEditFragment with errs when HX-Request, else 422 + TabloDetailPage with errs.General. The test TestTabloUpdate only asserts happy path + DB update, so keep error path consistent with handlers_auth.go style.
- On success: call deps.Queries.UpdateTablo(ctx, UpdateTabloParams{ID: tabloID, Title: title, Description: pgtype.Text{Valid: description != "", String: description}}). Render TabloTitleDisplay (if title changed) OR TabloDescDisplay (if description changed) based on which field came through the form; simpler: detect which field was non-empty in the request and render the matching display fragment. Cleaner: since the title edit form submits BOTH title and description (description as hidden field) and vice versa, the handler always updates both and the caller's hx-target tells HTMX which zone to swap. Choose the cleaner approach — both fields always present; render the TabloTitleDisplay when r.PostFormValue("_zone") == "title" else TabloDescDisplay. To avoid the _zone hidden field complexity, render only TabloTitleDisplay for now and let TabloDescDisplay come on description-edit through a separate handler — but spec says one POST endpoint. Final decision: pass a `_zone` hidden input ("title" or "desc") in each edit fragment; handler reads it and renders the matching display. Non-HTMX path → 303 to /tablos/{id}.
- On success (HX-Request): 200 + display fragment in body. Non-HTMX: 303 to /tablos/{id}.
TabloDeleteConfirmHandler (GET /tablos/{id}/delete-confirm):
- After common steps, render TabloDeleteConfirmFragment(tablo, csrfToken).
TabloDeleteCancelHandler (GET /tablos/{id}/delete-cancel):
- After common steps, render TabloDeleteButtonFragment(tablo, csrfToken).
TabloDeleteHandler (POST /tablos/{id}/delete):
- After common steps, call deps.Queries.DeleteTablo(ctx, tabloID).
- HTMX path: 200 + HX-Redirect: / header. Non-HTMX path: 303 to /.
- Detail-page delete (HX-Request from detail page): same HX-Redirect: / behavior — the test TestTabloDelete checks for either deletion + redirect; document and pick one.
Router wiring (extend the protected group from Plan 02; MUST be added AFTER `r.Get("/tablos/new", ...)` and `r.Post("/tablos", ...)`):
- r.Get("/tablos/{id}", TabloDetailHandler(tabloDeps))
- r.Post("/tablos/{id}", TabloUpdateHandler(tabloDeps))
- r.Get("/tablos/{id}/edit-title", TabloEditTitleHandler(tabloDeps))
- r.Get("/tablos/{id}/show-title", TabloShowTitleHandler(tabloDeps))
- r.Get("/tablos/{id}/edit-desc", TabloEditDescHandler(tabloDeps))
- r.Get("/tablos/{id}/show-desc", TabloShowDescHandler(tabloDeps))
- r.Get("/tablos/{id}/delete-confirm", TabloDeleteConfirmHandler(tabloDeps))
- r.Get("/tablos/{id}/delete-cancel", TabloDeleteCancelHandler(tabloDeps))
- r.Post("/tablos/{id}/delete", TabloDeleteHandler(tabloDeps))
Pitfall 1: static `/tablos/new` already declared before parametric `/tablos/{id}` per Plan 02 ordering; verify by reading router.go before editing.
Append nine handler constructors to backend/internal/web/handlers_tablos.go per the behavior block. Each constructor returns http.HandlerFunc closing over deps. Factor the common preamble (Authed extract, uuid.Parse, GetTabloByID, ownership check) into a private helper `loadOwnedTablo(w, r, deps) (sqlc.Tablo, *auth.User, bool)` — returns false when any check fails (the helper has already written the 404/500 response). All nine specific-tablo handlers call this helper first.
Use imports: "github.com/go-chi/chi/v5", "github.com/google/uuid", "errors", "github.com/jackc/pgx/v5", "github.com/jackc/pgx/v5/pgtype". csrf token via `csrf.Token(r)`. PostFormValue for title/description (Pitfall 2). pgtype.Text{Valid: s != "", String: s} for nullable description.
For TabloUpdateHandler: emit `<input type="hidden" name="_zone" value="title">` or `..."desc"` from the respective edit fragment template (Task 1 must already do this — if not, fix Task 1's templates to add the hidden _zone field). Handler reads `zone := r.PostFormValue("_zone")` and renders TabloTitleDisplay if zone == "title", TabloDescDisplay if zone == "desc", else default to TabloTitleDisplay. Non-HTMX path: 303 redirect to /tablos/{id} (full page reload renders the new state).
For TabloDeleteHandler HTMX response: `w.Header().Set("HX-Redirect", "/"); w.WriteHeader(http.StatusOK)`. Non-HTMX: `http.Redirect(w, r, "/", http.StatusSeeOther)`.
Edit backend/internal/web/router.go: append the nine new routes inside the protected group AFTER the existing `r.Post("/tablos", ...)` line and BEFORE the closing `})`. Order: `r.Get("/tablos/{id}", ...)`, `r.Post("/tablos/{id}", ...)`, then the six sub-route GETs, then `r.Post("/tablos/{id}/delete", ...)`.
After edits run `just test`. All 10 TABLO tests must be green. Phase 1/2 tests must remain green.
cd backend && just generate && grep -q "func TabloDetailHandler" internal/web/handlers_tablos.go && grep -q "func TabloEditTitleHandler" internal/web/handlers_tablos.go && grep -q "func TabloShowTitleHandler" internal/web/handlers_tablos.go && grep -q "func TabloEditDescHandler" internal/web/handlers_tablos.go && grep -q "func TabloShowDescHandler" internal/web/handlers_tablos.go && grep -q "func TabloUpdateHandler" internal/web/handlers_tablos.go && grep -q "func TabloDeleteConfirmHandler" internal/web/handlers_tablos.go && grep -q "func TabloDeleteCancelHandler" internal/web/handlers_tablos.go && grep -q "func TabloDeleteHandler" internal/web/handlers_tablos.go && grep -q "uuid.Parse(chi.URLParam" internal/web/handlers_tablos.go && grep -q "tablo.UserID != user.ID" internal/web/handlers_tablos.go && grep -q "http.NotFound" internal/web/handlers_tablos.go && grep -q "HX-Redirect" internal/web/handlers_tablos.go && grep -q "deps.Queries.UpdateTablo" internal/web/handlers_tablos.go && grep -q "deps.Queries.DeleteTablo" internal/web/handlers_tablos.go && grep -q "r.Get(\"/tablos/{id}\"" internal/web/router.go && grep -q "r.Post(\"/tablos/{id}\"" internal/web/router.go && grep -q "r.Get(\"/tablos/{id}/edit-title\"" internal/web/router.go && grep -q "r.Post(\"/tablos/{id}/delete\"" internal/web/router.go && awk '/r.Get\("\/tablos\/new"/ {newln=NR} /r.Get\("\/tablos\/\{id\}"/ {idln=NR} END {exit !(newln>0 && idln>0 && newln
- handlers_tablos.go declares all nine new handler functions (signatures match the interfaces block).
- handlers_tablos.go contains a single ownership-and-load helper used by all nine new handlers (grep "loadOwnedTablo" or equivalent appears >=2 occurrences: the declaration + at least one caller).
- Every specific-tablo handler returns 404 on (a) uuid.Parse error, (b) pgx.ErrNoRows, (c) UserID mismatch — verified by passing TestTabloDetail_NonOwner and TestTabloDetail_InvalidID.
- TabloUpdateHandler calls deps.Queries.UpdateTablo with the parsed ID and trimmed title; pgtype.Text used for description.
- TabloDeleteHandler calls deps.Queries.DeleteTablo and sets HX-Redirect: / on HTMX path, 303 to / on non-HTMX path.
- router.go contains the nine new routes inside the existing RequireAuth group. The line declaring `r.Get("/tablos/new", ...)` appears BEFORE the line declaring `r.Get("/tablos/{id}", ...)` (Pitfall 1) — verified by the awk check in the verify block.
- `go build ./...` exits 0.
- `go test ./internal/web/... -run TestTablo -count=1` exits 0 — all 10 named TABLO tests pass.
- Phase 1/2 regression: `go test ./internal/web/...` exits 0 across all tests (no broken auth/signup/csrf tests).
Detail + edit + delete slice green; all 10 TABLO tests pass; static-before-parametric order verified.
Task 3: Human verify detail + edit + delete + ownership 404
Tablo detail page with inline edit for title and description, and inline-confirmation delete. Non-owner and bad-UUID requests return 404. HTMX paths swap fragments without full reloads; non-HTMX paths POST and redirect cleanly.
1. cd backend && just dev.
2. Log in as user A. Create two tablos.
3. Click "View" on a tablo card → detail page renders with title and description.
4. Click the title → input appears in place; change text; click "Save changes" — display swaps back to the new title without full reload. Click "Discard changes" instead to confirm it restores the original.
5. Repeat for description.
6. From the detail page, click "Delete" → inline confirmation "Delete tablo? This cannot be undone." [Yes, delete] [Keep tablo] appears in place.
7. Click "Keep tablo" → confirmation collapses, Delete button restored.
8. Click "Delete" → "Yes, delete" → page navigates to / (HX-Redirect); tablo is gone from list.
9. Repeat delete from dashboard card — confirmation appears inside the card's delete-zone, "Yes, delete" removes the card from the list.
10. Ownership test: open a private window, sign up as user B. Try GET /tablos/{user-A-tablo-uuid} → 404 page with copy "This tablo doesn't exist or you don't have access.".
11. Bad UUID test: GET /tablos/not-a-uuid → 404.
12. CSRF test: view-source the detail page; confirm `_csrf` hidden input is rendered in every form (edit-title form, edit-desc form, delete-confirm form).
13. Non-JS fallback: disable JavaScript; from detail page submit edit form via the actual Submit button — page reloads to /tablos/{id} with updated content. Submit delete via the actual button — redirects to /.
Type "approved" if all 13 checks pass, or describe issues.
<threat_model>
Trust Boundaries
Boundary
Description
Browser → /tablos/{id}*
URL parameter (UUID) is untrusted; must be parsed and ownership-checked on every request.
Browser → POST /tablos/{id}
Untrusted form input (title, description, _zone, _csrf).
Browser → POST /tablos/{id}/delete
Authenticated state-changing request guarded by CSRF token.
Handler → Postgres
Parameterised via sqlc UpdateTabloParams / DeleteTablo($1).
STRIDE Threat Register
Threat ID
Category
Component
Disposition
Mitigation Plan
T-03-03-01
Elevation of privilege
Cross-tenant tablo access
mitigate
loadOwnedTablo helper enforces tablo.UserID == user.ID; non-owner gets http.NotFound (D-04: 404 not 403 to avoid existence leak). Verified by TestTabloDetail_NonOwner.
T-03-03-02
Information disclosure
UUID enumeration
mitigate
UUIDs are gen_random_uuid (crypto-random) + 404 response for non-owner masks existence.
T-03-03-03
Tampering
UUID injection / path traversal via {id}
mitigate
uuid.Parse rejects non-UUID strings → http.NotFound before any DB query. Verified by TestTabloDetail_InvalidID.
T-03-03-04
Tampering
CSRF on POST /tablos/{id} and /tablos/{id}/delete
mitigate
gorilla/csrf middleware validates _csrf field; @ui.CSRFField rendered inside every form template (edit-title, edit-desc, delete-confirm).
T-03-03-05
DoS
over-long title on edit
mitigate
TabloUpdateHandler validates len(title) <= 255 with explicit 422 + field error before DB write.
T-03-03-06
XSS
reflected user content in title/description on detail page and edit fragments
mitigate
templ auto-escapes { tablo.Title }, { tablo.Description.String }. No templ.Raw. Inline color style attribute also escaped.
T-03-03-07
Repudiation
edit/delete not logged
accept
v1 scope — created_at/updated_at on the row; no audit table planned for v1.
T-03-03-08
Tampering
_zone hidden field abuse
accept
_zone selects which display fragment to render; misuse only affects the response body shape, not DB state — DB UPDATE always uses authenticated user's row regardless of _zone value.
T-03-03-09
Spoofing
Delete bypass via direct POST
mitigate
RequireAuth middleware + loadOwnedTablo helper run on /tablos/{id}/delete; unauthed → /login, non-owner → 404. CSRF middleware also blocks cross-origin POSTs.
</threat_model>
- All 10 TABLO tests pass: `go test ./internal/web/... -run TestTablo -count=1` exits 0.
- Phase 1/2 regression suite still green: `go test ./internal/web/...` exits 0.
- Manual browser verification (Task 3 checkpoint) passes all 13 sub-checks, including ownership 404 + bad-UUID 404 + non-JS fallbacks.
- Static-before-parametric route ordering verified in router.go (Pitfall 1).
- `grep -c "http.NotFound" backend/internal/web/handlers_tablos.go` >= 3 (uuid parse failure + pgx.ErrNoRows + ownership mismatch).
<success_criteria>
- TABLO-03: GET /tablos/{id} renders detail page for owner; non-owner and malformed UUID get 404.
- TABLO-04: Inline edit of title and description updates the row, refreshes the affected fragment, and sets updated_at = now() (Pitfall 7).
- TABLO-05: Inline confirmation gates delete; confirming hard-deletes the row and redirects to /; cancel restores the original button.
- TABLO-06: Every mutating route works under both HTMX and non-HTMX clients (303 fallback verified manually).
- All ten TABLO tests in handlers_tablos_test.go are green.
- No Phase 1/2 regressions.
</success_criteria>
After completion, create `.planning/phases/03-tablos-crud/03-03-SUMMARY.md` documenting: nine new handler functions and the loadOwnedTablo helper, eight new templ components, nine new router lines (in declared order), final TABLO test status (10/10 green), and notes on the _zone hidden-field approach used for the unified POST /tablos/{id} update endpoint.
` or empty-state hint "Add a description"; clicking fires hx-get="/tablos/{id}/edit-desc" hx-target="closest .tablo-desc-zone" hx-swap="outerHTML". - TabloDescEditFragment analogous to TabloTitleEditFragment but for the description textarea (3 rows) + hidden title field carrying current title; submit "Save changes" + discard "Discard changes" with hx-get="/tablos/{id}/show-desc". - TabloDeleteButtonFragment renders a `
Append `type TabloUpdateErrors struct { Title, Description, General string }` to backend/templates/tablos_forms.go.
If TabloCard in Plan 02 inlined the delete button HTML, refactor it to call `@TabloDeleteButtonFragment(t, csrfToken)` — DO NOT duplicate the zone markup.
Use class attribute `tablo-title-zone`, `tablo-desc-zone`, and `tablo-delete-zone` ON the outermost element of each respective Display/Edit fragment so HTMX's `hx-target="closest .tablo-X-zone"` + `hx-swap="outerHTML"` round-trips replace the zone wholesale.
Pitfall 5 reminder: no nested OOB swaps required in this plan, but the zone elements must NOT be wrapped in extra containers that the swap target would miss.
Run `just generate` to produce backend/templates/tablos_templ.go.
cd backend && just generate && grep -q "templ TabloDetailPage" templates/tablos.templ && grep -q "templ TabloTitleDisplay" templates/tablos.templ && grep -q "templ TabloTitleEditFragment" templates/tablos.templ && grep -q "templ TabloDescDisplay" templates/tablos.templ && grep -q "templ TabloDescEditFragment" templates/tablos.templ && grep -q "templ TabloDeleteButtonFragment" templates/tablos.templ && grep -q "templ TabloDeleteConfirmFragment" templates/tablos.templ && grep -q "templ TabloNotFoundPage" templates/tablos.templ && grep -q "Delete tablo?" templates/tablos.templ && grep -q "This cannot be undone." templates/tablos.templ && grep -q "Yes, delete" templates/tablos.templ && grep -q "Keep tablo" templates/tablos.templ && grep -q "Save changes" templates/tablos.templ && grep -q "Discard changes" templates/tablos.templ && grep -q "tablo-title-zone" templates/tablos.templ && grep -q "tablo-desc-zone" templates/tablos.templ && grep -q "tablo-delete-zone" templates/tablos.templ && grep -q "This tablo doesn't exist or you don't have access." templates/tablos.templ && grep -q "type TabloUpdateErrors" templates/tablos_forms.go && go build ./...
- tablos.templ contains all 8 new templ component declarations exactly as named in the interfaces block.
- File contains exact UI-SPEC copy: "Delete tablo?", "This cannot be undone.", "Yes, delete", "Keep tablo", "Save changes", "Discard changes", "Not found", "This tablo doesn't exist or you don't have access.".
- Each of `tablo-title-zone`, `tablo-desc-zone`, `tablo-delete-zone` appears as a CSS class on at least TWO different templ components (Display + Edit, or Button + Confirm) — verified by `grep -c "class=\"tablo-title-zone\"" templates/tablos.templ` >= 2 (same for desc and delete zones).
- aria-label values "Edit title", "Confirm delete tablo", "Keep tablo", "Delete tablo" all present.
- hx-target uses `closest .tablo-{title,desc,delete}-zone` in every relevant fragment (not absolute selectors) so the same template works from card or detail-page contexts.
- tablos_forms.go declares `type TabloUpdateErrors struct` with Title, Description, General string fields.
- TabloCard delegates delete-button rendering to TabloDeleteButtonFragment (no inline duplication of the delete-zone HTML).
- `just generate` produced an updated tablos_templ.go.
- `go build ./...` exits 0.
All Phase 3 templates declared, generated, and compile.
Task 2: handlers_tablos.go — detail + edit + delete handlers + router wiring
backend/internal/web/handlers_tablos.go, backend/internal/web/router.go
backend/internal/web/handlers_tablos.go (current state from Plan 02)
backend/internal/web/router.go (current state from Plan 02)
backend/internal/web/handlers_auth.go (HTMX-aware redirect, fragment dispatch)
backend/internal/auth/middleware.go (Authed, redirectTo)
backend/internal/db/sqlc/tablos.sql.go (UpdateTablo signature)
backend/internal/web/handlers_tablos_test.go (RED tests to turn green)
.planning/phases/03-tablos-crud/03-RESEARCH.md (Patterns 3, 5, 6, 7, 8 + Pitfalls 1, 6, 7)
.planning/phases/03-tablos-crud/03-PATTERNS.md (UUID extraction, ownership check, HTMX redirect)
.planning/phases/03-tablos-crud/03-UI-SPEC.md (Interaction Contract §3-§5)
Common pattern shared by every /tablos/{id}* handler:
1. `_, user, _ := auth.Authed(r.Context())`
2. Parse id: `tabloID, err := uuid.Parse(chi.URLParam(r, "id"))`. err != nil → http.NotFound(w, r); return.
3. Fetch: `tablo, err := deps.Queries.GetTabloByID(r.Context(), tabloID)`. errors.Is(err, pgx.ErrNoRows) → http.NotFound. Other errors → 500.
4. Ownership: `if tablo.UserID != user.ID { http.NotFound(w, r); return }` (D-04: 404 not 403).
TabloDetailHandler (GET /tablos/{id}):
- After common steps, render TabloDetailPage(user, csrf.Token(r), tablo). Content-Type text/html; charset=utf-8.
TabloEditTitleHandler / TabloEditDescHandler (GET /tablos/{id}/edit-title|edit-desc):
- After common steps, render TabloTitleEditFragment / TabloDescEditFragment with empty TabloUpdateErrors.
TabloShowTitleHandler / TabloShowDescHandler (GET /tablos/{id}/show-title|show-desc):
- After common steps, render TabloTitleDisplay / TabloDescDisplay (used by discard-changes path).
TabloUpdateHandler (POST /tablos/{id}):
- After common steps, read r.PostFormValue("title") and r.PostFormValue("description"). Trim title.
- Validate: title required → "Title is required."; len(title) > 255 → "Title must be 255 characters or fewer.".
- On validation error: status 422 + render TabloTitleEditFragment (if r.PostFormValue("title") was the changed field) OR more simply re-render the detail page with errs surfaced. Pragmatic choice: render TabloTitleEditFragment with errs when HX-Request, else 422 + TabloDetailPage with errs.General. The test TestTabloUpdate only asserts happy path + DB update, so keep error path consistent with handlers_auth.go style.
- On success: call deps.Queries.UpdateTablo(ctx, UpdateTabloParams{ID: tabloID, Title: title, Description: pgtype.Text{Valid: description != "", String: description}}). Render TabloTitleDisplay (if title changed) OR TabloDescDisplay (if description changed) based on which field came through the form; simpler: detect which field was non-empty in the request and render the matching display fragment. Cleaner: since the title edit form submits BOTH title and description (description as hidden field) and vice versa, the handler always updates both and the caller's hx-target tells HTMX which zone to swap. Choose the cleaner approach — both fields always present; render the TabloTitleDisplay when r.PostFormValue("_zone") == "title" else TabloDescDisplay. To avoid the _zone hidden field complexity, render only TabloTitleDisplay for now and let TabloDescDisplay come on description-edit through a separate handler — but spec says one POST endpoint. Final decision: pass a `_zone` hidden input ("title" or "desc") in each edit fragment; handler reads it and renders the matching display. Non-HTMX path → 303 to /tablos/{id}.
- On success (HX-Request): 200 + display fragment in body. Non-HTMX: 303 to /tablos/{id}.
TabloDeleteConfirmHandler (GET /tablos/{id}/delete-confirm):
- After common steps, render TabloDeleteConfirmFragment(tablo, csrfToken).
TabloDeleteCancelHandler (GET /tablos/{id}/delete-cancel):
- After common steps, render TabloDeleteButtonFragment(tablo, csrfToken).
TabloDeleteHandler (POST /tablos/{id}/delete):
- After common steps, call deps.Queries.DeleteTablo(ctx, tabloID).
- HTMX path: 200 + HX-Redirect: / header. Non-HTMX path: 303 to /.
- Detail-page delete (HX-Request from detail page): same HX-Redirect: / behavior — the test TestTabloDelete checks for either deletion + redirect; document and pick one.
Router wiring (extend the protected group from Plan 02; MUST be added AFTER `r.Get("/tablos/new", ...)` and `r.Post("/tablos", ...)`):
- r.Get("/tablos/{id}", TabloDetailHandler(tabloDeps))
- r.Post("/tablos/{id}", TabloUpdateHandler(tabloDeps))
- r.Get("/tablos/{id}/edit-title", TabloEditTitleHandler(tabloDeps))
- r.Get("/tablos/{id}/show-title", TabloShowTitleHandler(tabloDeps))
- r.Get("/tablos/{id}/edit-desc", TabloEditDescHandler(tabloDeps))
- r.Get("/tablos/{id}/show-desc", TabloShowDescHandler(tabloDeps))
- r.Get("/tablos/{id}/delete-confirm", TabloDeleteConfirmHandler(tabloDeps))
- r.Get("/tablos/{id}/delete-cancel", TabloDeleteCancelHandler(tabloDeps))
- r.Post("/tablos/{id}/delete", TabloDeleteHandler(tabloDeps))
Pitfall 1: static `/tablos/new` already declared before parametric `/tablos/{id}` per Plan 02 ordering; verify by reading router.go before editing.
Append nine handler constructors to backend/internal/web/handlers_tablos.go per the behavior block. Each constructor returns http.HandlerFunc closing over deps. Factor the common preamble (Authed extract, uuid.Parse, GetTabloByID, ownership check) into a private helper `loadOwnedTablo(w, r, deps) (sqlc.Tablo, *auth.User, bool)` — returns false when any check fails (the helper has already written the 404/500 response). All nine specific-tablo handlers call this helper first.
Use imports: "github.com/go-chi/chi/v5", "github.com/google/uuid", "errors", "github.com/jackc/pgx/v5", "github.com/jackc/pgx/v5/pgtype". csrf token via `csrf.Token(r)`. PostFormValue for title/description (Pitfall 2). pgtype.Text{Valid: s != "", String: s} for nullable description.
For TabloUpdateHandler: emit `<input type="hidden" name="_zone" value="title">` or `..."desc"` from the respective edit fragment template (Task 1 must already do this — if not, fix Task 1's templates to add the hidden _zone field). Handler reads `zone := r.PostFormValue("_zone")` and renders TabloTitleDisplay if zone == "title", TabloDescDisplay if zone == "desc", else default to TabloTitleDisplay. Non-HTMX path: 303 redirect to /tablos/{id} (full page reload renders the new state).
For TabloDeleteHandler HTMX response: `w.Header().Set("HX-Redirect", "/"); w.WriteHeader(http.StatusOK)`. Non-HTMX: `http.Redirect(w, r, "/", http.StatusSeeOther)`.
Edit backend/internal/web/router.go: append the nine new routes inside the protected group AFTER the existing `r.Post("/tablos", ...)` line and BEFORE the closing `})`. Order: `r.Get("/tablos/{id}", ...)`, `r.Post("/tablos/{id}", ...)`, then the six sub-route GETs, then `r.Post("/tablos/{id}/delete", ...)`.
After edits run `just test`. All 10 TABLO tests must be green. Phase 1/2 tests must remain green.
cd backend && just generate && grep -q "func TabloDetailHandler" internal/web/handlers_tablos.go && grep -q "func TabloEditTitleHandler" internal/web/handlers_tablos.go && grep -q "func TabloShowTitleHandler" internal/web/handlers_tablos.go && grep -q "func TabloEditDescHandler" internal/web/handlers_tablos.go && grep -q "func TabloShowDescHandler" internal/web/handlers_tablos.go && grep -q "func TabloUpdateHandler" internal/web/handlers_tablos.go && grep -q "func TabloDeleteConfirmHandler" internal/web/handlers_tablos.go && grep -q "func TabloDeleteCancelHandler" internal/web/handlers_tablos.go && grep -q "func TabloDeleteHandler" internal/web/handlers_tablos.go && grep -q "uuid.Parse(chi.URLParam" internal/web/handlers_tablos.go && grep -q "tablo.UserID != user.ID" internal/web/handlers_tablos.go && grep -q "http.NotFound" internal/web/handlers_tablos.go && grep -q "HX-Redirect" internal/web/handlers_tablos.go && grep -q "deps.Queries.UpdateTablo" internal/web/handlers_tablos.go && grep -q "deps.Queries.DeleteTablo" internal/web/handlers_tablos.go && grep -q "r.Get(\"/tablos/{id}\"" internal/web/router.go && grep -q "r.Post(\"/tablos/{id}\"" internal/web/router.go && grep -q "r.Get(\"/tablos/{id}/edit-title\"" internal/web/router.go && grep -q "r.Post(\"/tablos/{id}/delete\"" internal/web/router.go && awk '/r.Get\("\/tablos\/new"/ {newln=NR} /r.Get\("\/tablos\/\{id\}"/ {idln=NR} END {exit !(newln>0 && idln>0 && newln
- handlers_tablos.go declares all nine new handler functions (signatures match the interfaces block).
- handlers_tablos.go contains a single ownership-and-load helper used by all nine new handlers (grep "loadOwnedTablo" or equivalent appears >=2 occurrences: the declaration + at least one caller).
- Every specific-tablo handler returns 404 on (a) uuid.Parse error, (b) pgx.ErrNoRows, (c) UserID mismatch — verified by passing TestTabloDetail_NonOwner and TestTabloDetail_InvalidID.
- TabloUpdateHandler calls deps.Queries.UpdateTablo with the parsed ID and trimmed title; pgtype.Text used for description.
- TabloDeleteHandler calls deps.Queries.DeleteTablo and sets HX-Redirect: / on HTMX path, 303 to / on non-HTMX path.
- router.go contains the nine new routes inside the existing RequireAuth group. The line declaring `r.Get("/tablos/new", ...)` appears BEFORE the line declaring `r.Get("/tablos/{id}", ...)` (Pitfall 1) — verified by the awk check in the verify block.
- `go build ./...` exits 0.
- `go test ./internal/web/... -run TestTablo -count=1` exits 0 — all 10 named TABLO tests pass.
- Phase 1/2 regression: `go test ./internal/web/...` exits 0 across all tests (no broken auth/signup/csrf tests).
Detail + edit + delete slice green; all 10 TABLO tests pass; static-before-parametric order verified.
Task 3: Human verify detail + edit + delete + ownership 404
Tablo detail page with inline edit for title and description, and inline-confirmation delete. Non-owner and bad-UUID requests return 404. HTMX paths swap fragments without full reloads; non-HTMX paths POST and redirect cleanly.
1. cd backend && just dev.
2. Log in as user A. Create two tablos.
3. Click "View" on a tablo card → detail page renders with title and description.
4. Click the title → input appears in place; change text; click "Save changes" — display swaps back to the new title without full reload. Click "Discard changes" instead to confirm it restores the original.
5. Repeat for description.
6. From the detail page, click "Delete" → inline confirmation "Delete tablo? This cannot be undone." [Yes, delete] [Keep tablo] appears in place.
7. Click "Keep tablo" → confirmation collapses, Delete button restored.
8. Click "Delete" → "Yes, delete" → page navigates to / (HX-Redirect); tablo is gone from list.
9. Repeat delete from dashboard card — confirmation appears inside the card's delete-zone, "Yes, delete" removes the card from the list.
10. Ownership test: open a private window, sign up as user B. Try GET /tablos/{user-A-tablo-uuid} → 404 page with copy "This tablo doesn't exist or you don't have access.".
11. Bad UUID test: GET /tablos/not-a-uuid → 404.
12. CSRF test: view-source the detail page; confirm `_csrf` hidden input is rendered in every form (edit-title form, edit-desc form, delete-confirm form).
13. Non-JS fallback: disable JavaScript; from detail page submit edit form via the actual Submit button — page reloads to /tablos/{id} with updated content. Submit delete via the actual button — redirects to /.
Type "approved" if all 13 checks pass, or describe issues.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| Browser → /tablos/{id}* | URL parameter (UUID) is untrusted; must be parsed and ownership-checked on every request. |
| Browser → POST /tablos/{id} | Untrusted form input (title, description, _zone, _csrf). |
| Browser → POST /tablos/{id}/delete | Authenticated state-changing request guarded by CSRF token. |
| Handler → Postgres | Parameterised via sqlc UpdateTabloParams / DeleteTablo($1). |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-03-03-01 | Elevation of privilege | Cross-tenant tablo access | mitigate | loadOwnedTablo helper enforces tablo.UserID == user.ID; non-owner gets http.NotFound (D-04: 404 not 403 to avoid existence leak). Verified by TestTabloDetail_NonOwner. |
| T-03-03-02 | Information disclosure | UUID enumeration | mitigate | UUIDs are gen_random_uuid (crypto-random) + 404 response for non-owner masks existence. |
| T-03-03-03 | Tampering | UUID injection / path traversal via {id} | mitigate | uuid.Parse rejects non-UUID strings → http.NotFound before any DB query. Verified by TestTabloDetail_InvalidID. |
| T-03-03-04 | Tampering | CSRF on POST /tablos/{id} and /tablos/{id}/delete | mitigate | gorilla/csrf middleware validates _csrf field; @ui.CSRFField rendered inside every form template (edit-title, edit-desc, delete-confirm). |
| T-03-03-05 | DoS | over-long title on edit | mitigate | TabloUpdateHandler validates len(title) <= 255 with explicit 422 + field error before DB write. |
| T-03-03-06 | XSS | reflected user content in title/description on detail page and edit fragments | mitigate | templ auto-escapes { tablo.Title }, { tablo.Description.String }. No templ.Raw. Inline color style attribute also escaped. |
| T-03-03-07 | Repudiation | edit/delete not logged | accept | v1 scope — created_at/updated_at on the row; no audit table planned for v1. |
| T-03-03-08 | Tampering | _zone hidden field abuse | accept | _zone selects which display fragment to render; misuse only affects the response body shape, not DB state — DB UPDATE always uses authenticated user's row regardless of _zone value. |
| T-03-03-09 | Spoofing | Delete bypass via direct POST | mitigate | RequireAuth middleware + loadOwnedTablo helper run on /tablos/{id}/delete; unauthed → /login, non-owner → 404. CSRF middleware also blocks cross-origin POSTs. |
| </threat_model> |
<success_criteria>
- TABLO-03: GET /tablos/{id} renders detail page for owner; non-owner and malformed UUID get 404.
- TABLO-04: Inline edit of title and description updates the row, refreshes the affected fragment, and sets updated_at = now() (Pitfall 7).
- TABLO-05: Inline confirmation gates delete; confirming hard-deletes the row and redirects to /; cancel restores the original button.
- TABLO-06: Every mutating route works under both HTMX and non-HTMX clients (303 fallback verified manually).
- All ten TABLO tests in handlers_tablos_test.go are green.
- No Phase 1/2 regressions. </success_criteria>