Skip to content

feat(studio): refine feeds with ranked selector candidates - #1152

Draft
gildesmarais wants to merge 70 commits into
mainfrom
feat/config-studio
Draft

gildesmarais wants to merge 70 commits into
mainfrom
feat/config-studio

Conversation

@gildesmarais

@gildesmarais gildesmarais commented Sep 19, 2026

Copy link
Copy Markdown
Member

What changed

  • Token holders can refine selectors on a successful result. Copy feed URL stays the primary action and initial focus. Refine stays collapsed until opened.
  • POST /api/v1/feeds/suggest_selectors returns ranked candidates for items, title, link, and published. Empty buckets are HTTP 200. The SPA applies a candidate only when the user clicks Use.
  • Create, validate, and preview accept an allowlisted selectors document. Studio stays behind the access token, STUDIO_ENABLED, and live-fetch rules.
  • The SPA JSON API uses the generated OpenAPI client. html2rss is pinned to 281e5e90 on feat/suggest-selector-candidates.
  • make up / make down run this checkout with the published Botasaurus image on 127.0.0.1:4010.

Why

Auto-source is not enough when the list or a field is wrong. A single silent items selector overwrote the draft and could not offer title, link, or published choices. Local development also needs the scraper without starting the published web image.

Risk

  • Suggest response is a breaking change: selectors is replaced by candidates.
  • The gem pin is a Git revision, not a released gem. Merging this PR before the gem PR lands leaves the pin on an unreleased commit.
  • Field candidates are built from the winning items roots only.
  • make up is a host command. The Dev Container still uses make dev and host.docker.internal.

Review map

  1. spec/html2rss/web/api/v1_spec.rb — suggest returns ranked buckets and empty buckets as success.
  2. frontend/src/__tests__/ConfigStudio.test.tsx and frontend/src/__tests__/useStudio.test.ts — suggestion does not overwrite the draft until Use.
  3. app/web/api/v1/suggest_selectors.rb — studio gates unchanged; maps CaptureResult#candidates.
  4. frontend/src/studio/studioService.ts and frontend/src/components/ConfigStudio.tsx — wire parse and explicit per-bucket Use.
  5. spec/support/openapi_studio_schemas.rb and public/openapi.yaml — candidate schema, generated client left generated.
  6. docker-compose.botasaurus.yml, Makefile, and bin/dev — local boot and macOS listen check.

Validation

  • Earlier studio phases reported make openapi, make openapi-client, make ready, and make ci-ready exiting 0.
  • The gem pin commit and make up commit were not run through make ready or make ci-ready.

Preview and validate need ValidationReport and Test::Result#validation_issues, which only exist on gem master. html2rss-configs still resolves against that pin.
Preview and serve would otherwise build different configs. One expansion owner, with the old private builder deleted, keeps those paths identical.
…ramps

Refine is memory-bound: #/refine carries no token, and a visit with no in-memory URL recovers onto remounted #/create the same way an unmatched result does.
…onfigs

Client config must not be able to rewrite channel.url or headers. One allowlist owns that rule and runs again when a token is decoded.
The signature gains selectors only when a token carries them, so feed URLs already in the wild keep validating.
The served feed now expands the same signed selectors preview will use, and the allowlist runs again at serve time so a decoded token cannot skip it.
Studio edits stay server-authoritative: validate reports schema issues without minting a token, preview selects Test::Result fields so the RSS body never leaves the server, and suggest returns one capture-derived selector with its evidence.
…contract

Create signs an optional allowlisted fragment into the token, and the OpenAPI hook now derives issue codes and accepted keys from the runtime sets so the published client cannot drift from the rule.
`publish_allowed_keys` wrote `spec['components']` while rspec-openapi seeds
`spec[:components]` with `securitySchemes` (symbol-keyed, see
rspec-openapi's DefaultSchema). The string-keyed write shadowed that hash on
dump, so `public/openapi.yaml` published four `BearerAuth: []` requirements
against a scheme that no longer existed, and the generated frontend client
dropped bearer security from createFeed, previewFeed, suggestSelectors and
getHealthStatus.

Reuse whichever key form the document already carries -- the same string/symbol
grammar the tags and paths stamps already use -- and merge into the existing
components hash instead of replacing it.

Arm `openapi-lint` in `ci-ready` so this class of defect fails the gate:
`openapi-verify` only diffs the regenerated file against itself and never sees
a dangling security reference. Redocly was green before the studio endpoints
landed, so the two publish defects those endpoints introduced are fixed here
too: ValidationIssue `expected`/`actual` are JSON-ish or nil and now publish as
open schemas (OAS 3.0 rejects `nullable` without a sibling `type`), and preview
gains a recorded empty-extraction example so `failure_kind` and
`quality_report` publish a type next to their `nullable`.

The remaining Redocly error (`security-defined` on POST /feeds/validate) is
real: that operation is still unauthenticated. It is fixed in the next commit.
POST /api/v1/feeds/validate was the only studio operation reachable without a
token. Preview and suggest both call `Auth.authenticate` and raise
`UnauthorizedError`, so an unauthenticated caller could still drive the schema
validator, the YAML parser and the refined-selectors allowlist -- CPU the
instance owner never granted, and a free oracle for which config shapes the
instance accepts.

Gate it the same way its siblings are gated. Validate needs no live fetch, so
it takes the token check only, not the auto-source flag or the per-account URL
allowlist.

The request spec posted with no Authorization header and asserted 200; it now
carries the admin token, and the shared api-error-contract example covers the
401. Regenerated `public/openapi.yaml` and the frontend client so
validateFeedConfig declares bearer security and its 401 response, which also
clears the last Redocly `security-defined` error under `make openapi-lint`.
`STUDIO_ENABLED` existed only in app/web/config/flags.rb, so an operator had
to read the flag registry to learn the studio endpoints exist, that they are
off in production, or how to turn them on. Document it next to
`AUTO_SOURCE_ENABLED` in the docs flag table, .env.example, and the production
compose file (the quickstart compose is left alone: RACK_ENV=development
already defaults the flag on, and it lists only what it must set).

State what the flag exposes rather than just naming it: validate, preview and
suggest_selectors; all token-gated; 403 "Studio is disabled" when off; preview
and suggest_selectors additionally require AUTO_SOURCE_ENABLED because they
fetch the caller's URL live and are bounded by the account URL allowlist.

The 403 branch had no request coverage. The new examples iterate
`FeedRoutes::STUDIO_POSTS`, so a future studio endpoint is covered by the same
gate instead of silently shipping without one. They are `openapi: false`: the
flag-off shape is an operator concern, and the other ClimateControl-driven
examples in this file stay out of the published contract too.
Join feat/config-studio-rb (P01–P07 and A1 fixes) so later studio UI phases share one checkout with the published contract.
Join feat/config-studio-ts (P08) so the refine route, on-ramps, and ConfigStudio shell sit on the same checkout as the studio contract.
Unwrap the studio envelope in one service and keep refinement phase, issues, and preview in a discriminated union so later panels cannot invent a second client.
Fill the refine card with the items selector, enhance toggle, one-click capture suggestion, attribute extractors, and post-processors so refinement stays on shared primitives instead of a DOM picker.
Keep YAML as the same config as the builder by posting it through validate, and surface parse failures as a syntax notice so the builder does not hydrate from broken text.
Save posts the validated selectors through the existing create path and returns to the result view, and the refine route is now part of the journey grammar and operator docs.
FeedToken::Codec.decode rebuilt the signed selectors through
RefinedSelectors.from_wire, which ran Html2rss::Config.validate. That put the
full gem validator behind an unauthenticated, unsigned wire document: any
caller could drive it by putting a selectors subtree in a forged token.
Measured 0.004ms for a plain bad token against 0.218ms for an 808-byte forged
one carrying 60 selectors, a 58x amplification before a single HMAC byte is
compared.

Split the build so decode runs the structural allowlist only (denied keys,
shape, wire cap) and the schema check moves behind verification.
SourceResolver now calls from_verified_wire, which it only reaches after
authorize_feed_token! has checked the signature, so the defence-in-depth
re-validation is kept without exposing it pre-auth.
Codec.decode inflated whatever arrived on the token route with no cap on
either the encoded input or the inflated output. Auth.extract_token's 1024
byte limit only guards the Authorization header, not the path segment, so a
26KB token expanded to 20,000,060 bytes in 55ms for an unauthenticated
caller: a 769x memory amplification per request.

Reject encoded tokens over MAX_ENCODED_BYTES and inflate in chunks, aborting
at MAX_INFLATED_BYTES. The abort raises Zlib::DataError, which decode already
rescues, so an oversized token still fails closed as 401 rather than
surfacing a new error shape.
Flags.development_or_test? defaulted a missing RACK_ENV to "development",
while EnvironmentValidator.non_production? treats the same state as
production. A deployment that forgot the variable therefore got production
secret-key enforcement but silently enabled STUDIO_ENABLED and
AUTO_SOURCE_ENABLED, exposing the studio and live-fetch surfaces.

Treat unset as not-development so both flags default off. Every real
entrypoint sets RACK_ENV explicitly (docker-compose, bin/dev*, the dev
container, and spec_helper), so no supported workflow changes.
build_context read request.params, and Rack::Request#params parses POST for
form-encoded requests, which buffers the entire body. The middleware runs
before RateLimiter and before every handler's MAX_BODY_BYTES gate, so a
200KB form POST to a studio route was fully read into memory and only then
rejected with 400. The new routes' body limit was effectively decorative
against that content type.

Read the query string instead. The strategy parameter this line wants has
always been a query parameter on feed URLs, so the recorded context is
unchanged for every real caller.
FEED_TOKEN_ROUTE matches any single segment under /api/v1/feeds/, so the
studio routes logged as /api/v1/feeds/[REDACTED]. Validate, preview, and
suggest_selectors calls became indistinguishable in the audit trail, which
removes the evidence needed to investigate abuse of exactly the endpoints
that trigger upstream fetches.

Skip redaction for segments the router mounts by name. The list is read from
FeedRoutes::STUDIO_POSTS so the router stays the single owner and a future
studio route cannot drift out of the audit trail.
ValidateConfig told a schema failure apart from an allowlist rejection by
testing whether the exception message contained " [", the separator in
ValidationIssue#to_s. Root keys are echoed into the rejection message, so a
caller chose which branch ran by naming a key: POST /api/v1/feeds/validate
with a yaml root key of "foo [bar" returned 200 and report.success=true for
a config the create endpoint rejects, while "channel" and "request"
correctly returned 400. Validate and execute disagreed on the same input.

Raise RefinedSelectors::SchemaInvalid carrying the report, and rescue that
type instead of parsing a message. The rescue also stops re-validating: the
report it already has is returned directly.

assess now forwards the whole body rather than only body["selectors"], so a
denied sibling key is rejected on the JSON path as it already was on the
YAML path. The frontend only ever sends {selectors} or {yaml}, so no client
request shape changes.
All three studio handlers checked Flags.studio_enabled? and
Flags.auto_source_enabled? before Auth.authenticate, so an anonymous POST
was answered with 403 "Studio is disabled" or 403 "Auto source feature is
disabled". That hands an unauthenticated caller the deployment's feature
configuration and tells them which instances are worth probing further.

Authenticate first, then evaluate the flags, so an anonymous caller only
ever sees 401.
items_selector was forwarded to Html2rss.capture with no length limit; a
5000 character hint reached the gem unchanged. The selector is client text
handed to a parser during a live fetch, and the handler maps no gem parse
failure, so anything raised there classifies as 500 rather than a client
error.

Cap the hint and reject an oversized one as 400.
Drives every example as a real Rack request through the full middleware
stack, so a gate bypassed at the middleware or routing layer still fails
here rather than passing against a unit stub of the gate.

Verified red before the fixes in this branch: 14 of 29 examples failed
against the pre-fix tree, covering pre-HMAC schema validation, the token
wire caps, the request-context body buffering, the " [" message sniff, the
denied root keys on the JSON validate path, the RACK_ENV flag default, the
flag-state disclosure, and the items_selector cap. All 29 pass after.

The remaining 15 are verification rather than regression: IDOR on serve,
preview, and suggest, username replay, tampered signed selectors with an
assertion that no fetch happened, expired tokens, and unauthenticated POSTs.
They passed before and after, and are kept so the behaviour is pinned.
The lock pinned revision 84cad9e9, which predates the gsub work. Without it a
client-authored `post_process: gsub` pattern reaches `Regexp.new` unbounded, so
the studio's selector path had no pattern-length or star-height ceiling.

Pin `ref: fc5de544`, the current html2rss master tip. That commit is the
squash-merge of PR #512, so the four branch commits (ec834ff7, b114e16c,
385c77cf, c7be5c83) are not literal ancestors of it; the trees are identical
instead (`git rev-parse c7be5c83^{tree} fc5de544^{tree}` both yield fa9d14e4),
which is the verifiable form of "master contains them". The installed gem
carries `Gsub::MAX_PATTERN_LENGTH` and `Config::IssueMapper`.

`branch: 'master'` is dropped because a floating branch silently re-resolves
those bounds on any later `bundle update`. The commented `gem 'html2rss',
'~> 0.30'` line is dropped too: uncommenting it resolves the released 0.30.0
gem, which has neither the gsub bounds nor `Config::IssueMapper`.
`selectors_for` signed whatever `selectors` object the body carried and never
read `STUDIO_ENABLED`. The three studio POSTs honoured the kill switch, but
create did not, so an operator who turned the studio off could still be handed a
token whose signed `c:` document ran client-authored selectors — the flag closed
the door and left the window open.

Create now raises the same `403 Studio is disabled` the studio POSTs raise, but
only when the body actually carries `selectors`. A body without them stays the
automatic path and keeps working with the flag off, so ordinary URL-only create
is untouched.

The gate runs before the `AUTO_SOURCE_ENABLED` check so the refusal does not
depend on a second, unrelated flag. Authentication still runs first, so an
anonymous caller cannot probe flag state.

`require_account` collapses to one expression to stay under `Metrics/ClassLength`
without disabling the cop.
…eate

Two behaviours the suite asserted nowhere.

`post_process: gsub` is the selector feature that carries a client-authored
regexp all the way to `Regexp.new`, so the allowlist spec now proves the pattern
survives minting, signing, and decoding intact, and that the gem's compile bound
(nested quantifiers, 256-character ceiling) surfaces as a 400 rather than a
pathological match. A denied root key travelling next to a post_process is still
rejected by name, so the post_process path does not widen the allowlist.

The create kill switch is HTTP behaviour, so it is covered with real Rack
requests next to the existing `AUTO_SOURCE_ENABLED` example: a `selectors` body
under `STUDIO_ENABLED=false` gets the same `403 Studio is disabled` as the studio
POSTs, and a URL-only body under the same flag still returns 201. Checked out
against the pre-fix `create_feed.rb` the first example fails (201 instead of 403)
and the second passes, so the pair discriminates the fix from the regression it
guards.

Both are `openapi: false`: they document flag states, not the published contract.
@gildesmarais
gildesmarais marked this pull request as draft September 19, 2026 22:15
Replace example-inferred items-only hulls with RefinedSelectorsDocument and PreviewSampleItem so the SPA client matches the allowlisted refine payload.
Consolidate create-shell mounts and repeated hook stubs into
file-local scenario helpers without dropping observable assertions.
Zero out ConfigStudio settle delays via a file-local useStudio wrap,
share Studio response builders, and keep useStudio debounce proofs on
deterministic fake timers without waitFor under those timers.
Extract pure API wire builders for MSW and Playwright, then cover
refine URL re-suggest/preview plus empty-blur remount and studio-off
hide/bounce as two deterministic browser journeys.
Point CI Vitest at one combined test:run process, pin Playwright to a
single worker, and correct contributor docs for the all-suite entrypoint.
@gildesmarais
gildesmarais marked this pull request as draft September 20, 2026 10:43
Apply was still sending the typed items selector as the suggest hint, so a bad edit could bias capture instead of a gem-fresh determination. Post an empty hint (same as auto-prefill) and clarify the control as Use determined selector.
Authenticated live preview was stuck on the gem's three-item sample. PreviewFeed now owns a ten-item meadow and copies only safe HTTP(S) images from the RSS document.
The JSON preview parser always capped at five items and dropped images. Audience now follows the access token used at creation, so retry cannot widen a guest meadow.
Successful results kept a separate refine route that competed with Copy feed URL. Token-backed studio now edits selectors under the one result meadow, and stale refine routes return there.
Successful results keep selector controls on the result route, and #/refine stays extraction-empty recovery. The parity gate also required the preview tests to satisfy existing ESLint rules without dropping the accepted HTTP image case.
Default MSW validate now echoes request selectors so contract paths cannot
pass article from a silent overwrite without clicking Use.
Pin html2rss to the capture candidates commit and replace the single selectors suggestion with ranked items/title/link/published buckets on suggest_selectors.
Map suggest candidates through studioService and render explicit per-bucket Use actions in gated refine without auto-applying into the draft.
The candidates branch now includes the Falcon HTTPX retry fix from master.
make up runs this checkout plus the scraper image on loopback. The macOS
port check uses lsof so Falcon is not killed after boot.
@gildesmarais gildesmarais changed the title feat: add an on-demand config studio for refined feeds feat(studio): refine feeds with ranked selector candidates Sep 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant