Series URL repair, owner-typed and gated by the poller's own fetch gate #151

Closed
opened 2026-08-22 08:38:00 +07:00 by sulthan · 1 comment
Owner

Parent

Spec #135.

What to build

The series URL column is write-once: it is set when the row is created, and no Poll and no Reader PUT ever changes it. A Series whose address went bad is therefore permanently unpollable and unrepairable by any client action. This ticket gives the owner a text input on the Series detail page that stores a new one.

Validated by the gate the poller already uses before it fetches anything — a client-supplied URL is exactly what that gate exists for. The gate is currently unexported in the poller package; export it and update its in-package callers. One implementation, never a second gate. The web package already imports the poller package.

The repair fetches nothing. Storing an address is not verifying it, and a synchronous fetch in an owner request would imply otherwise and could block on the browser sidecar. The owner presses Check now afterwards, so verification is an explicit act with a visible result.

This lifts the write-once rule for the owner only. Rejected: letting a Reader's PUT heal a stale URL — the client already sends a fresh one every time and the store drops it, deliberately. The series row is shared: one Reader could repoint a Series every other Reader reads at a different work on the same host, and the gate would pass it, because the gate stops SSRF, not mis-pointing.

Say the honest limit on the page rather than papering over it: a Site-wide host change invalidates every row of that Site at once, and a per-Series form is the wrong tool for it. That is a SQL migration and is out of scope — the owner should not sit typing the same fix into two hundred forms.

The control is unconfirmed, never ember, no danger accent, and sits in the detail page's two-column grid beside the Latest Chapter correction. The route joins the admin route list behind the owner gate and caps its form body the way the API path caps bodies. The repair records nothing: it changes what the Poll fetches, not what a Reader reads.

The fetch gate is security-critical, so say which invariant you preserved in the PR and run the full backend test suite.

Acceptance criteria

  • The fetch gate is exported, its in-package callers updated, and no second copy of it exists
  • POST /admin/series/{key}/series-url is owner-gated by joining the admin route list
  • A URL failing the gate answers 400 and never reaches the store
  • A URL passing the gate is stored where the Upsert would have ignored it
  • The request performs no outbound fetch
  • The detail page states that a Site-wide host change is not this control's job
  • The form body is capped the way the API path caps bodies

Blocked by

  • #149 — shares the detail page's two-column grid (a layout edge, not a logical one).
## Parent Spec #135. ## What to build The series URL column is write-once: it is set when the row is created, and no Poll and no Reader PUT ever changes it. A Series whose address went bad is therefore permanently unpollable and unrepairable by any client action. This ticket gives the owner a text input on the Series detail page that stores a new one. **Validated by the gate the poller already uses** before it fetches anything — a client-supplied URL is exactly what that gate exists for. The gate is currently unexported in the poller package; export it and update its in-package callers. **One implementation, never a second gate.** The web package already imports the poller package. **The repair fetches nothing.** Storing an address is not verifying it, and a synchronous fetch in an owner request would imply otherwise and could block on the browser sidecar. The owner presses *Check now* afterwards, so verification is an explicit act with a visible result. This lifts the write-once rule **for the owner only**. **Rejected: letting a Reader's PUT heal a stale URL** — the client already sends a fresh one every time and the store drops it, deliberately. The series row is *shared*: one Reader could repoint a Series every other Reader reads at a different work on the same host, and the gate would pass it, because the gate stops SSRF, not mis-pointing. **Say the honest limit on the page rather than papering over it**: a Site-wide host change invalidates every row of that Site at once, and a per-Series form is the wrong tool for it. That is a SQL migration and is out of scope — the owner should not sit typing the same fix into two hundred forms. The control is unconfirmed, never ember, no danger accent, and sits in the detail page's two-column grid beside the Latest Chapter correction. The route joins the admin route list behind the owner gate and caps its form body the way the API path caps bodies. The repair records nothing: it changes what the Poll fetches, not what a Reader reads. The fetch gate is security-critical, so say which invariant you preserved in the PR and run the full backend test suite. ## Acceptance criteria - [ ] The fetch gate is exported, its in-package callers updated, and no second copy of it exists - [ ] `POST /admin/series/{key}/series-url` is owner-gated by joining the admin route list - [ ] A URL failing the gate answers 400 and never reaches the store - [ ] A URL passing the gate is stored where the Upsert would have ignored it - [ ] The request performs no outbound fetch - [ ] The detail page states that a Site-wide host change is not this control's job - [ ] The form body is capped the way the API path caps bodies ## Blocked by - #149 — shares the detail page's two-column grid (a layout edge, not a logical one).
sulthan added the ready-for-agent label 2026-08-22 08:38:00 +07:00
sulthan self-assigned this 2026-08-22 09:09:55 +07:00
Author
Owner

Landed on spec-135 as a --no-ff merge of ticket/151-series-url-repair (424d2c6).

Implemented: latest.fetchableSeriesURL exported to latest.FetchableSeriesURL (same signature and body, one implementation) with every in-package caller and all four comment references updated and no lower-case copy surviving; (*Store).SetSeriesURL as one parameterized UPDATE beside the other single-column series setters, with the write-once doc comment on Series.SeriesURL corrected to record its one exception; POST /admin/series/{key}/series-url joined to adminRoutes so the existing owner-gate enumeration covers it; handler adminSeriesSetURL capping the body with http.MaxBytesReader at 64 KB, refusing a URL the gate rejects with 400 before anything reaches the store, 404 on an unknown Series, generic errors to the client and detail to the log, answering with the re-rendered series-detail-meta fragment; and a second .dform in the detail page's .detail-grid whose copy states the honest limit - storing an address is not verifying it, and a Site-wide host change is a migration, not two hundred forms. The request performs no outbound fetch of any kind.

Invariant preserved: the root AGENTS.md rule that any outbound fetch of a client-supplied URL passes the fetch gate first, and that a new path reuses the gate rather than re-deriving one. The web layer became a second caller of the one implementation; the Upsert's write-once rule for Readers was not loosened. Owner-gating is by route registration, so the existing owner-gate test covers the new pattern.

TDD red captured at both named seams: undefined: (*Store).SetSeriesURL at the store seam, and 404-instead-of-400 plus a detail page with no series_url input at the web seam.

The full backend suite is green on the merged base: 571 tests, 10 packages.

One incidental fix: the first draft of the repair hint contained the literal "Check now", which broke TestSeriesPollControlVisibility (it counts that string on the detail page). Reworded.

No concerns handed back. The live CDP smoke suite is not part of this gate and was not run.

Landed on `spec-135` as a `--no-ff` merge of `ticket/151-series-url-repair` (424d2c6). Implemented: `latest.fetchableSeriesURL` exported to `latest.FetchableSeriesURL` (same signature and body, one implementation) with every in-package caller and all four comment references updated and no lower-case copy surviving; `(*Store).SetSeriesURL` as one parameterized UPDATE beside the other single-column series setters, with the write-once doc comment on `Series.SeriesURL` corrected to record its one exception; `POST /admin/series/{key}/series-url` joined to `adminRoutes` so the existing owner-gate enumeration covers it; handler `adminSeriesSetURL` capping the body with `http.MaxBytesReader` at 64 KB, refusing a URL the gate rejects with 400 before anything reaches the store, 404 on an unknown Series, generic errors to the client and detail to the log, answering with the re-rendered `series-detail-meta` fragment; and a second `.dform` in the detail page's `.detail-grid` whose copy states the honest limit - storing an address is not verifying it, and a Site-wide host change is a migration, not two hundred forms. The request performs no outbound fetch of any kind. Invariant preserved: the root AGENTS.md rule that any outbound fetch of a client-supplied URL passes the fetch gate first, and that a new path reuses the gate rather than re-deriving one. The web layer became a second caller of the one implementation; the Upsert's write-once rule for Readers was not loosened. Owner-gating is by route registration, so the existing owner-gate test covers the new pattern. TDD red captured at both named seams: `undefined: (*Store).SetSeriesURL` at the store seam, and 404-instead-of-400 plus a detail page with no `series_url` input at the web seam. The full backend suite is green on the merged base: 571 tests, 10 packages. One incidental fix: the first draft of the repair hint contained the literal "Check now", which broke `TestSeriesPollControlVisibility` (it counts that string on the detail page). Reworded. No concerns handed back. The live CDP smoke suite is not part of this gate and was not run.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sulthan/mangaBookmark#151