From 3c084197c6611b65cddcade5f18d689e60516933 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 19 Sep 2026 21:25:01 +0200 Subject: [PATCH 001/108] chore(deps)!: pin html2rss to master for structured validation report Preview and validate need ValidationReport and Test::Result#validation_issues, which only exist on gem master. html2rss-configs still resolves against that pin. --- Gemfile | 4 ++-- Gemfile.lock | 64 ++++++++++++++++++++++++++++------------------------ 2 files changed, 37 insertions(+), 31 deletions(-) diff --git a/Gemfile b/Gemfile index 6c1c1a75..875094f5 100644 --- a/Gemfile +++ b/Gemfile @@ -4,8 +4,8 @@ source 'https://rubygems.org' git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } -gem 'html2rss', '~> 0.30' -# gem 'html2rss', github: 'html2rss/html2rss', branch: 'master' +# gem 'html2rss', '~> 0.30' +gem 'html2rss', github: 'html2rss/html2rss', branch: 'master' gem 'html2rss-configs', github: 'html2rss/html2rss-configs' # Use these instead of the two above (uncomment them) when developing locally: diff --git a/Gemfile.lock b/Gemfile.lock index 815ad48e..080b7013 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,3 +1,27 @@ +GIT + remote: https://github.com/html2rss/html2rss + revision: 84cad9e9870e8159088c65e0b1a306e7f4ba9026 + branch: master + specs: + html2rss (0.30.0) + addressable (~> 2.7) + brotli + dry-validation + httpx (~> 1.8) + kramdown + mcp (~> 1.2) + mime-types (> 3.0) + nokogiri (>= 1.10, < 2.0) + rack (~> 3.0) + rackup (~> 2.0) + regexp_parser + rss + sanitize + thor + tzinfo + webrick (~> 1.9) + zeitwerk + GIT remote: https://github.com/html2rss/html2rss-configs revision: 2355ce1b4189b01282acfb99f8e4548fe3ec417a @@ -69,7 +93,7 @@ GEM bigdecimal samovar (~> 2.1) base64 (0.3.0) - bigdecimal (4.1.2) + bigdecimal (4.1.3) brotli (0.8.0) builder (3.3.0) climate_control (1.2.0) @@ -141,26 +165,8 @@ GEM fiber-storage (1.0.1) hana (1.3.7) hashdiff (1.2.1) - html2rss (0.30.0) - addressable (~> 2.7) - brotli - dry-validation - httpx (~> 1.8) - kramdown - mcp (~> 1.2) - mime-types (> 3.0) - nokogiri (>= 1.10, < 2.0) - rack (~> 3.0) - rackup (~> 2.0) - regexp_parser - rss - sanitize - thor - tzinfo - webrick (~> 1.9) - zeitwerk - http-2 (1.2.2) - httpx (1.8.3) + http-2 (1.2.3) + httpx (1.8.4) http-2 (>= 1.2.0) i18n (1.15.2) concurrent-ruby (~> 1.0) @@ -191,7 +197,7 @@ GEM loofah (2.25.2) crass (~> 1.0.2) nokogiri (>= 1.12.0) - mcp (1.5.0) + mcp (1.5.1) json_schemer (>= 2.4) mime-types (3.7.0) logger @@ -370,7 +376,7 @@ DEPENDENCIES base64 climate_control falcon - html2rss (~> 0.30) + html2rss! html2rss-configs! irb rack-test @@ -407,7 +413,7 @@ CHECKSUMS async-utilization (0.5.0) sha256=96c347634e09edcbcb7f70cf8c5a1ae058c64d88186341e3f924a44440f95981 bake (0.25.0) sha256=a47bdc6a26addc048827debc36fe27bb4d5d71ac2958ad910b5386e9baf49869 base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b - bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd + bigdecimal (4.1.3) sha256=61ebe1e5e559bdc3cc6f2c0ee7f427321fc838f59611c294356eb04d6e21cf66 brotli (0.8.0) sha256=0c5a42046b3b603fb109656881147fd76064c034b7d19c1b4fcc32a093a4d55d builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f climate_control (1.2.0) sha256=36b21896193fa8c8536fa1cd843a07cf8ddbd03aaba43665e26c53ec1bd70aa5 @@ -434,10 +440,10 @@ CHECKSUMS fiber-storage (1.0.1) sha256=f48e5b6d8b0be96dac486332b55cee82240057065dc761c1ea692b2e719240e1 hana (1.3.7) sha256=5425db42d651fea08859811c29d20446f16af196308162894db208cac5ce9b0d hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1 - html2rss (0.30.0) sha256=a0ec169cf4a41954b2ec327625b15e81d718061a59a0bd27670ca01f238a2852 + html2rss (0.30.0) html2rss-configs (0.2.0) - http-2 (1.2.2) sha256=81b5d45f50fd4cd5f8c5d09651184bec9401e3ef169c3eb3e5b003d5614a92b9 - httpx (1.8.3) sha256=cb88f2285c4ef17164d803a640dc393d28722cba7f282ad0e7f9f926cd02dc47 + http-2 (1.2.3) sha256=f4e3a7000e9a8ab97595f09eac44c6ec10a2aa438360758124df0ca5bcb280d4 + httpx (1.8.4) sha256=1782ecde81eb4e961ad8735ec74a6fec8e60efeaa4592c6b2f55d72e19aaeca8 i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5 io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08 io-endpoint (0.18.0) sha256=720d186bcd584e8ba490bfc91dab332cc3c5ffee848b5b03894555cb275151aa @@ -452,7 +458,7 @@ CHECKSUMS localhost (1.8.0) sha256=df7ea825b4f64949c588c17efac86bc47ddc4460d723778abe933b71759b2701 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 loofah (2.25.2) sha256=2007f746959ac65552456e04b433e83deb22759ab38c838b4445c70e43425918 - mcp (1.5.0) sha256=6f97785fa6e069eb667aa3d50299851ea9aca904b4abe8306f156bb8fd9eaff0 + mcp (1.5.1) sha256=883ddd0f59e5bf43805e300158c7ecc4b6a559d126d584d41bd241355545bedf mime-types (3.7.0) sha256=dcebf61c246f08e15a4de34e386ebe8233791e868564a470c3fe77c00eed5e56 mime-types-data (3.2026.0701) sha256=cd8811e1fb89d836499ba0582368a10ee74cef929ba956d1d5ddca045e6a730f minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 @@ -529,4 +535,4 @@ CHECKSUMS zeitwerk (2.8.3) sha256=2c85125a8467ce069e20123d1e709a08955c9d29c118c25b46b7b7fafdbb92e5 BUNDLED WITH - 4.0.9 + 4.0.3 From 71810b9fce506185acf7845ae15da7b1ae503c25 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 19 Sep 2026 21:31:50 +0200 Subject: [PATCH 002/108] refactor(feeds): own token config expansion in one place Preview and serve would otherwise build different configs. One expansion owner, with the old private builder deleted, keeps those paths identical. --- app/web/feeds/generator_input.rb | 36 ++++++++++++ app/web/feeds/source_resolver.rb | 13 +---- .../web/feeds/generator_input_spec.rb | 57 +++++++++++++++++++ 3 files changed, 94 insertions(+), 12 deletions(-) create mode 100644 app/web/feeds/generator_input.rb create mode 100644 spec/html2rss/web/feeds/generator_input_spec.rb diff --git a/app/web/feeds/generator_input.rb b/app/web/feeds/generator_input.rb new file mode 100644 index 00000000..ef1b5912 --- /dev/null +++ b/app/web/feeds/generator_input.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Html2rss + module Web + module Feeds + ## + # Single owner of token-feed config expansion. + # + # Serve and studio preview both call {.for_token} so a preview cannot + # diverge from the feed a reader later receives. + module GeneratorInput + class << self + ## + # Builds the generator config for a signed token feed. + # + # Selector fragments are accepted on the signature now so both call + # sites share one method; merging them is a later widening. + # + # @param url [String] channel URL bound to the token + # @param strategy [String, Symbol] resolved request strategy + # @param selectors [Object, nil] reserved until refined selectors are signed + # @return [Hash{Symbol=>Object}] + def for_token(url:, strategy:, selectors: nil) + raise ArgumentError, 'selector expansion is not available' unless selectors.nil? + + LocalConfig.global.slice(:stylesheets, :headers).merge( + channel: { url: }, + auto_source: {}, + strategy: strategy.to_sym + ) + end + end + end + end + end +end diff --git a/app/web/feeds/source_resolver.rb b/app/web/feeds/source_resolver.rb index c96d185c..ec589712 100644 --- a/app/web/feeds/source_resolver.rb +++ b/app/web/feeds/source_resolver.rb @@ -63,7 +63,7 @@ def resolve_token(feed_request) # @param feed_token [Html2rss::Web::FeedToken] # @return [Html2rss::Web::Feeds::Contracts::ResolvedSource] def build_token_source(feed_request, feed_token) - generator_input = token_generator_input(feed_token.url, resolved_strategy(feed_token)) + generator_input = GeneratorInput.for_token(url: feed_token.url, strategy: resolved_strategy(feed_token)) resolved_source( source_kind: :token, cache_identity: token_cache_identity(feed_request.token), @@ -139,17 +139,6 @@ def resolved_strategy(feed_token) strategy end - # @param url [String] - # @param strategy [String] - # @return [Hash{Symbol=>Object}] - def token_generator_input(url, strategy) - LocalConfig.global.slice(:stylesheets, :headers).merge( - channel: { url: }, - auto_source: {}, - strategy: strategy.to_sym - ) - end - # @return [String] def default_strategy_name configured = Html2rss::Config.default_strategy_name.to_s diff --git a/spec/html2rss/web/feeds/generator_input_spec.rb b/spec/html2rss/web/feeds/generator_input_spec.rb new file mode 100644 index 00000000..4cd6e3c6 --- /dev/null +++ b/spec/html2rss/web/feeds/generator_input_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Html2rss::Web::Feeds::GeneratorInput do + let(:url) { 'https://example.com/private' } + let(:global_config) do + { + stylesheets: ['/rss.xsl'], + headers: { 'User-Agent' => 'html2rss-web' }, + ignored: 'not copied' + } + end + + before do + allow(Html2rss::Web::LocalConfig).to receive(:global).and_return(global_config) + end + + describe '.for_token' do + it 'reproduces the token generator hash when selectors are absent' do + expect(described_class.for_token(url:, strategy: 'default')).to eq( + stylesheets: ['/rss.xsl'], + headers: { 'User-Agent' => 'html2rss-web' }, + channel: { url: }, + auto_source: {}, + strategy: :default + ) + end + + context 'when serve and preview expand the same token' do + let(:feed_token) do + instance_double(Html2rss::Web::FeedToken, username: 'admin', url:, strategy: 'default') + end + let(:feed_request) do + Html2rss::Web::Feeds::Contracts::Request.new( + target_kind: :token, feed_name: nil, token: 'public-token', params: {} + ) + end + + before do + allow(Html2rss::Web::Auth).to receive(:validate_and_decode_feed_token) + .with('public-token').and_return(feed_token) + allow(Html2rss::Web::AccountManager).to receive(:get_account_by_username) + .with('admin').and_return({ username: 'admin' }) + allow(Html2rss::Web::UrlValidator).to receive(:url_allowed?) + .with({ username: 'admin' }, url).and_return(true) + allow(Html2rss::Web::Flags).to receive(:auto_source_enabled?).and_return(true) + end + + it 'returns one config for both paths' do + served = Html2rss::Web::Feeds::SourceResolver.call(feed_request).generator_input + + expect(served).to eq(described_class.for_token(url:, strategy: feed_token.strategy)) + end + end + end +end From 4e1b1d0cf51550c1fcc502b20f9c3f4685b123a5 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 19 Sep 2026 21:35:35 +0200 Subject: [PATCH 003/108] feat(frontend): add refine route with result and empty-extraction on-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. --- frontend/src/__tests__/ResultDisplay.test.tsx | 118 ++++++++++++++++++ frontend/src/__tests__/appRoute.test.ts | 30 +++++ frontend/src/__tests__/decideJourney.test.ts | 37 ++++++ frontend/src/components/App.tsx | 6 + frontend/src/components/AppPanels.tsx | 32 ++++- frontend/src/components/ConfigStudio.tsx | 16 +++ frontend/src/components/ResultDisplay.tsx | 11 +- frontend/src/feed/decideJourney.ts | 9 +- frontend/src/feed/useFeedFlow.ts | 21 +++- frontend/src/journey/copy.ts | 3 +- frontend/src/routes/appRoute.ts | 14 +++ 11 files changed, 287 insertions(+), 10 deletions(-) create mode 100644 frontend/src/components/ConfigStudio.tsx diff --git a/frontend/src/__tests__/ResultDisplay.test.tsx b/frontend/src/__tests__/ResultDisplay.test.tsx index 810e6692..29b27cc4 100644 --- a/frontend/src/__tests__/ResultDisplay.test.tsx +++ b/frontend/src/__tests__/ResultDisplay.test.tsx @@ -1,11 +1,14 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/preact'; import { ResultDisplay } from '../components/ResultDisplay'; +import { CreateFeedPanel } from '../components/AppPanels'; import { COPY } from '../journey/copy'; import type { AppViewModel } from '../feed'; +import type { FeedCreationError } from '../api/contracts'; describe('ResultDisplay', () => { const mockOnCreateAnother = vi.fn(); + const mockOnRefine = vi.fn(); const mockOnRetryPreview = vi.fn(); const mockViewModel: Extract = { kind: 'result', @@ -77,6 +80,7 @@ describe('ResultDisplay', () => { ); @@ -106,6 +110,7 @@ describe('ResultDisplay', () => { preview: { status: 'preview_loading', items: [], isLoading: true }, }} onCreateAnother={mockOnCreateAnother} + onRefine={mockOnRefine} onRetryPreview={mockOnRetryPreview} /> ); @@ -132,6 +137,7 @@ describe('ResultDisplay', () => { ], }} onCreateAnother={mockOnCreateAnother} + onRefine={mockOnRefine} onRetryPreview={mockOnRetryPreview} /> ); @@ -146,6 +152,7 @@ describe('ResultDisplay', () => { ); @@ -164,6 +171,7 @@ describe('ResultDisplay', () => { ); @@ -191,6 +199,7 @@ describe('ResultDisplay', () => { ], }} onCreateAnother={mockOnCreateAnother} + onRefine={mockOnRefine} onRetryPreview={mockOnRetryPreview} /> ); @@ -217,6 +226,7 @@ describe('ResultDisplay', () => { ], }} onCreateAnother={mockOnCreateAnother} + onRefine={mockOnRefine} onRetryPreview={mockOnRetryPreview} /> ); @@ -224,4 +234,112 @@ describe('ResultDisplay', () => { expect(screen.getByText('Feed fetching is temporarily unavailable.')).toBeInTheDocument(); expect(screen.getByRole('button', { name: COPY.checkAgain })).toBeInTheDocument(); }); + + it('offers a quiet refine action beside create another without taking copy focus', async () => { + render( + + ); + + const refine = screen.getByRole('button', { name: COPY.refineSelectors }); + const createAnother = screen.getByRole('button', { name: COPY.createAnother }); + const copyFeedUrl = screen.getByRole('button', { name: COPY.copyFeedUrl }); + + expect(refine).toHaveClass('btn', 'btn--quiet', 'btn--linkish'); + expect(refine.parentElement).toBe(createAnother.parentElement); + expect(refine.parentElement).toHaveClass('ui-actions'); + await waitFor(() => { + expect(document.activeElement).toBe(copyFeedUrl); + }); + + fireEvent.click(refine); + expect(mockOnRefine).toHaveBeenCalledTimes(1); + }); +}); + +function renderEmptyExtractionError(error: FeedCreationError, onRefine = vi.fn(), onRetryCreate = vi.fn()) { + const noop = vi.fn(); + render( + + ); + + return { onRefine, onRetryCreate }; +} + +describe('empty extraction refine on-ramp', () => { + it('offers refine beside Try again only for an empty extraction', () => { + const { onRefine } = renderEmptyExtractionError({ + kind: 'input', + code: 'EXTRACTION_EMPTY', + retryable: true, + nextAction: 'retry', + retryAction: 'primary', + message: 'We could not extract feed items from this page yet.', + }); + + const refine = screen.getByRole('button', { name: COPY.refineSelectors }); + const tryAgain = screen.getByRole('button', { name: COPY.tryAgain }); + + expect(refine).toHaveClass('btn', 'btn--quiet', 'btn--linkish'); + expect(refine.parentElement).toBe(tryAgain.parentElement); + expect(refine.parentElement).toHaveClass('notice__actions'); + + fireEvent.click(refine); + expect(onRefine).toHaveBeenCalledTimes(1); + }); + + it('offers refine without Try again when empty extraction is not retryable', () => { + renderEmptyExtractionError({ + kind: 'input', + code: 'EXTRACTION_EMPTY', + retryable: false, + nextAction: 'correct_input', + retryAction: 'none', + message: 'We could not extract feed items from this page yet.', + }); + + expect(screen.getByRole('button', { name: COPY.refineSelectors })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: COPY.tryAgain })).not.toBeInTheDocument(); + }); + + it('does not offer refine for other create errors', () => { + renderEmptyExtractionError({ + kind: 'input', + code: 'BLOCKED_SURFACE', + retryable: false, + nextAction: 'correct_input', + retryAction: 'none', + message: 'This site blocked automated access.', + }); + + expect(screen.queryByRole('button', { name: COPY.refineSelectors })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: COPY.tryAgain })).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/__tests__/appRoute.test.ts b/frontend/src/__tests__/appRoute.test.ts index 0f7c3b5f..a2cb2eca 100644 --- a/frontend/src/__tests__/appRoute.test.ts +++ b/frontend/src/__tests__/appRoute.test.ts @@ -88,4 +88,34 @@ describe('appRoute', () => { }); unmount(); }); + + it('reads refine without a token or prefill URL', () => { + expect( + readAppRoute({ + pathname: '/', + search: '?url=https%3A%2F%2Fexample.com', + hash: '#/refine?url=https%3A%2F%2Fexample.com', + }) + ).toEqual({ kind: 'refine' }); + + expect( + readAppRoute({ + pathname: '/', + search: '', + hash: '#/refine/', + }) + ).toEqual({ kind: 'refine' }); + + expect(buildAppRouteHref({ kind: 'refine' }, 'http://localhost/')).toBe('http://localhost/#/refine'); + }); + + it('canonicalizes hashbang refine hashes to #/refine', () => { + history.replaceState({}, '', 'http://localhost:3000/#!/refine'); + + const { result, unmount } = renderHook(() => useAppRoute()); + + expect(location.hash).toBe('#/refine'); + expect(result.current.route).toEqual({ kind: 'refine' }); + unmount(); + }); }); diff --git a/frontend/src/__tests__/decideJourney.test.ts b/frontend/src/__tests__/decideJourney.test.ts index 9e0ef9af..cd6b9afc 100644 --- a/frontend/src/__tests__/decideJourney.test.ts +++ b/frontend/src/__tests__/decideJourney.test.ts @@ -10,6 +10,7 @@ describe('decideJourney', () => { feedFieldErrors: emptyErrors, isCreating: false, route: { kind: 'create' }, + sourceUrl: '', tokenError: '', }) ).toEqual({ kind: 'create' }); @@ -20,6 +21,7 @@ describe('decideJourney', () => { feedFieldErrors: emptyErrors, isCreating: false, route: { kind: 'result', feedToken: 'token' }, + sourceUrl: 'https://example.com', tokenError: '', result: { feed: { @@ -49,6 +51,7 @@ describe('decideJourney', () => { feedFieldErrors: emptyErrors, isCreating: false, route: { kind: 'result', feedToken: 'route-token' }, + sourceUrl: '', tokenError: '', result: { feed: { @@ -72,6 +75,7 @@ describe('decideJourney', () => { feedFieldErrors: emptyErrors, isCreating: false, route: { kind: 'result', feedToken: 'route-token' }, + sourceUrl: '', tokenError: '', }) ).toEqual({ kind: 'create' }); @@ -83,6 +87,7 @@ describe('decideJourney', () => { feedFieldErrors: emptyErrors, isCreating: true, route: { kind: 'create' }, + sourceUrl: '', tokenError: '', }) ).toEqual({ kind: 'submitting' }); @@ -92,6 +97,7 @@ describe('decideJourney', () => { feedFieldErrors: { url: '', form: 'Bad url' }, isCreating: false, route: { kind: 'create' }, + sourceUrl: '', tokenError: '', creationError: { kind: 'input', @@ -111,8 +117,39 @@ describe('decideJourney', () => { feedFieldErrors: emptyErrors, isCreating: true, route: { kind: 'token' }, + sourceUrl: 'https://example.com', tokenError: '', }) ).toEqual({ kind: 'token_prompt', tokenError: '' }); }); + + it('keeps refine only while an in-memory URL exists', () => { + expect( + decideJourney({ + feedFieldErrors: emptyErrors, + isCreating: false, + route: { kind: 'refine' }, + sourceUrl: 'https://example.com/articles', + tokenError: '', + creationError: { + kind: 'input', + code: 'EXTRACTION_EMPTY', + retryable: false, + nextAction: 'correct_input', + retryAction: 'none', + message: 'Empty', + }, + }) + ).toEqual({ kind: 'refine' }); + + expect( + decideJourney({ + feedFieldErrors: emptyErrors, + isCreating: false, + route: { kind: 'refine' }, + sourceUrl: ' '.repeat(3), + tokenError: '', + }) + ).toEqual({ kind: 'create' }); + }); }); diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index daebf778..56b29e22 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -1,6 +1,7 @@ import type { JSX } from 'preact'; import { ResultDisplay } from './ResultDisplay'; import { CreateFeedPanel, UtilityStrip } from './AppPanels'; +import { ConfigStudio } from './ConfigStudio'; import { Notice } from './Notice'; import { COPY } from '../journey/copy'; import { useSession } from '../session'; @@ -62,6 +63,7 @@ export function App() { onCancelTokenPrompt, onRetryCreate, onCreateAnother, + onRefine, onRetryPreview, setBookmarkletNotice, setTokenDraft, @@ -105,9 +107,12 @@ export function App() { ); + } else if (viewModel.kind === 'refine') { + bodyContent = ; } else { bodyContent = ( ); } diff --git a/frontend/src/components/AppPanels.tsx b/frontend/src/components/AppPanels.tsx index be1963b8..94164e73 100644 --- a/frontend/src/components/AppPanels.tsx +++ b/frontend/src/components/AppPanels.tsx @@ -17,7 +17,7 @@ export interface FeedFieldErrors { form: string; } -type CreatePanelViewModel = Exclude; +type CreatePanelViewModel = Exclude; interface CreateFeedPanelProperties { focusComposerKey: number; @@ -36,6 +36,7 @@ interface CreateFeedPanelProperties { onSaveToken: () => void; onCancelTokenPrompt: () => void; onRetryCreate: () => void; + onRefine: () => void; } interface UrlEntrySectionProperties { @@ -353,15 +354,21 @@ interface ActionFeedbackProperties { failureMessage: string; isCreating: boolean; isShowRetryButton: boolean; + shouldOfferRefine: boolean; onRetryCreate: () => void; + onRefine: () => void; } function ActionFeedback({ failureMessage, isCreating, isShowRetryButton, + shouldOfferRefine, onRetryCreate, + onRefine, }: ActionFeedbackProperties) { + const hasActions = isShowRetryButton || shouldOfferRefine; + return ( <> {failureMessage && ( @@ -370,11 +377,20 @@ function ActionFeedback({ tone="error" title={isShowRetryButton ? COPY.createFailedRetryTitle : COPY.createFailedTitle} actions={ - isShowRetryButton && ( - - ) + hasActions ? ( + <> + {isShowRetryButton ? ( + + ) : undefined} + {shouldOfferRefine ? ( + + ) : undefined} + + ) : undefined } >

{failureMessage}

@@ -403,6 +419,7 @@ export function CreateFeedPanel({ onSaveToken, onCancelTokenPrompt, onRetryCreate, + onRefine, }: CreateFeedPanelProperties) { const urlInputReference = useRef(null); const tokenInputReference = useRef(null); @@ -422,6 +439,7 @@ export function CreateFeedPanel({ const isShowRetryButton = Boolean( creationError && creationError.nextAction === 'retry' && creationError.retryAction !== 'none' ); + const shouldOfferRefine = viewModel.kind === 'error' && creationError?.code === 'EXTRACTION_EMPTY'; const isCreatingFeed = !isTokenPrompt && isSubmitting; const tokenCreating = isTokenPrompt && flowCreating; @@ -493,7 +511,9 @@ export function CreateFeedPanel({ failureMessage={failureMessage} isCreating={isCreatingFeed} isShowRetryButton={isShowRetryButton} + shouldOfferRefine={shouldOfferRefine} onRetryCreate={onRetryCreate} + onRefine={onRefine} /> {isTokenPrompt ? ( diff --git a/frontend/src/components/ConfigStudio.tsx b/frontend/src/components/ConfigStudio.tsx new file mode 100644 index 00000000..42e95bec --- /dev/null +++ b/frontend/src/components/ConfigStudio.tsx @@ -0,0 +1,16 @@ +import { COPY } from '../journey/copy'; + +/** Empty refine shell. Selector controls land in a later phase. */ +export function ConfigStudio() { + return ( +
+

+ {COPY.refineSelectors} +

+
+ ); +} diff --git a/frontend/src/components/ResultDisplay.tsx b/frontend/src/components/ResultDisplay.tsx index 6045e390..6aa3237e 100644 --- a/frontend/src/components/ResultDisplay.tsx +++ b/frontend/src/components/ResultDisplay.tsx @@ -8,6 +8,7 @@ import { PreviewItem } from './PreviewItem'; interface ResultDisplayProperties { viewModel: Extract; onCreateAnother: () => void; + onRefine: () => void; onRetryPreview: () => void; } @@ -26,7 +27,12 @@ function PreviewSection({ ariaLabel, eyebrow, children }: PreviewSectionProperti ); } -export function ResultDisplay({ viewModel, onCreateAnother, onRetryPreview }: ResultDisplayProperties) { +export function ResultDisplay({ + viewModel, + onCreateAnother, + onRefine, + onRetryPreview, +}: ResultDisplayProperties) { const [copied, setCopied] = useState(false); const copyResetReference = useRef | undefined>(undefined); const copyButtonReference = useRef(null); @@ -96,6 +102,9 @@ export function ResultDisplay({ viewModel, onCreateAnother, onRetryPreview }: Re {COPY.openInFeedReader} )} + diff --git a/frontend/src/feed/decideJourney.ts b/frontend/src/feed/decideJourney.ts index 5c2fedff..9b63eefe 100644 --- a/frontend/src/feed/decideJourney.ts +++ b/frontend/src/feed/decideJourney.ts @@ -23,7 +23,8 @@ export type AppViewModel = message: string; error?: FeedCreationError; errorKind?: FeedCreationError['kind']; - }; + } + | { kind: 'refine' }; /** * Pure projection of journey kind from Feed Flow state. @@ -35,6 +36,7 @@ export function decideJourney({ feedFieldErrors, isCreating, route, + sourceUrl, tokenError, result, }: { @@ -42,9 +44,14 @@ export function decideJourney({ feedFieldErrors: { url: string; form: string }; isCreating: boolean; route: AppRoute; + sourceUrl: string; tokenError: string; result?: CreatedFeedResult; }): AppViewModel { + if (route.kind === 'refine') { + return sourceUrl.trim() ? { kind: 'refine' } : { kind: 'create' }; + } + if (route.kind === 'result' && result && result.feed.feed_token === route.feedToken) { return { kind: 'result', diff --git a/frontend/src/feed/useFeedFlow.ts b/frontend/src/feed/useFeedFlow.ts index 3f0d3bb1..54e13f8d 100644 --- a/frontend/src/feed/useFeedFlow.ts +++ b/frontend/src/feed/useFeedFlow.ts @@ -57,7 +57,7 @@ export function useFeedFlow({ const [bookmarkletNotice, setBookmarkletNotice] = useState(''); const [focusCreateComposerKey, setFocusCreateComposerKey] = useState(0); - const routePrefillUrl = route.kind === 'result' ? undefined : route.prefillUrl; + const routePrefillUrl = route.kind === 'create' || route.kind === 'token' ? route.prefillUrl : undefined; const autoSubmitUrlReference = useRef(routePrefillUrl); const hasAutoSubmittedReference = useRef(false); const previousRouteKindReference = useRef(route.kind); @@ -202,6 +202,15 @@ export function useFeedFlow({ navigate({ kind: 'create' }, { replace: true }); }, [navigate, result, route]); + // Recover refine routes with no in-memory URL onto a remounted create view. + useEffect(() => { + if (route.kind !== 'refine') return; + if (inMemorySourceUrl(feedFormData.url, result?.feed.url)) return; + + // Do not carry a prefill URL — that would re-trigger auto-submit. + navigate({ kind: 'create' }, { replace: true }); + }, [feedFormData.url, navigate, result, route]); + useEffect(() => { const previousKind = previousRouteKindReference.current; const previousCreateEntryKey = previousCreateEntryKeyReference.current; @@ -236,6 +245,7 @@ export function useFeedFlow({ feedFieldErrors, isCreating, route, + sourceUrl: inMemorySourceUrl(feedFormData.url, result?.feed.url), tokenError, result, }); @@ -275,9 +285,18 @@ export function useFeedFlow({ setFocusCreateComposerKey((current) => current + 1); navigate({ kind: 'create', prefillUrl: feedFormData.url || undefined }); }, + onRefine: () => { + navigate({ kind: 'refine' }); + }, onRetryPreview: retryPreviewFetch, setBookmarkletNotice, setTokenDraft, setTokenError, }; } + +function inMemorySourceUrl(formUrl: string, pageUrl: string | undefined): string { + const fromForm = formUrl.trim(); + if (fromForm) return fromForm; + return pageUrl?.trim() ?? ''; +} diff --git a/frontend/src/journey/copy.ts b/frontend/src/journey/copy.ts index 7ebe49af..41bc3a5a 100644 --- a/frontend/src/journey/copy.ts +++ b/frontend/src/journey/copy.ts @@ -1,4 +1,4 @@ -/** Sole UI copy owner for create / token / result journey states. */ +/** Sole UI copy owner for create / token / result / refine journey states. */ export const COPY = { creating: 'Creating feed', previewChecking: 'Checking preview', @@ -40,6 +40,7 @@ export const COPY = { openJsonFeed: 'Open JSON Feed', openInFeedReader: 'Open in feed reader', createAnother: 'Create another feed', + refineSelectors: 'Refine selectors', createFeed: 'Create feed', saveAndContinue: 'Save and continue', back: 'Back', diff --git a/frontend/src/routes/appRoute.ts b/frontend/src/routes/appRoute.ts index 27ec38db..6d4daf8a 100644 --- a/frontend/src/routes/appRoute.ts +++ b/frontend/src/routes/appRoute.ts @@ -12,6 +12,9 @@ export type AppRoute = | { kind: 'result'; feedToken: string; + } + | { + kind: 'refine'; }; interface RouteNavigationOptions { @@ -27,6 +30,7 @@ interface RouteLocationLike { const ROUTE_PATHS = { create: '/create', token: '/token', + refine: '/refine', resultPrefix: '/result/', } as const; @@ -43,6 +47,10 @@ export function readAppRoute(locationLike: RouteLocationLike = getCurrentLocatio return prefillUrl ? { kind: 'token', prefillUrl } : { kind: 'token' }; } + if (pathname === ROUTE_PATHS.refine) { + return { kind: 'refine' }; + } + if (pathname.startsWith(ROUTE_PATHS.resultPrefix)) { const feedToken = pathname.slice(ROUTE_PATHS.resultPrefix.length); if (feedToken) return { kind: 'result', feedToken }; @@ -72,6 +80,12 @@ export function buildAppRouteHref(route: AppRoute, baseHref = getCurrentHref()): return url.href; } + if (route.kind === 'refine') { + url.hash = ROUTE_PATHS.refine; + url.search = ''; + return url.href; + } + url.hash = `${ROUTE_PATHS.resultPrefix}${route.feedToken}`; url.search = ''; return url.href; From 0e25d5b338012d354d411459d6cdde5b19d68950 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 19 Sep 2026 21:41:00 +0200 Subject: [PATCH 004/108] feat(security): add refined-selectors allowlist for client-authored configs 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. --- app/web/security/refined_selectors.rb | 156 ++++++++++++++++++ .../web/security/refined_selectors_spec.rb | 76 +++++++++ 2 files changed, 232 insertions(+) create mode 100644 app/web/security/refined_selectors.rb create mode 100644 spec/html2rss/web/security/refined_selectors_spec.rb diff --git a/app/web/security/refined_selectors.rb b/app/web/security/refined_selectors.rb new file mode 100644 index 00000000..00011c6a --- /dev/null +++ b/app/web/security/refined_selectors.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +require 'json' + +module Html2rss + module Web + ## + # Allowlisted selector fragment a client may sign into a feed token. + # + # This is the only owner of which config keys may be client-authored. + # {.from_client} and {.from_wire} share one rule so a leaked signing + # secret cannot widen what decode will execute. + RefinedSelectors = Data.define(:selectors) do + ## + # @param selectors [Hash{Symbol=>Object}] allowlisted selectors subtree + # @return [Html2rss::Web::RefinedSelectors] + def initialize(selectors:) + super(selectors: deep_freeze(selectors)) + end + + ## + # Fragment merged into the token generator config. Never includes channel, + # headers, or any other client-controlled root key. + # + # @return [Hash{Symbol=>Object}] + def to_config_fragment = { selectors: }.freeze + + ## + # Wire document stored under the token +c:+ key and covered by the signature. + # + # @return [Hash{Symbol=>Object}] + def to_wire = to_config_fragment + + ## + # @return [Boolean] + def empty? = selectors.empty? + + private + + # @param value [Object] + # @return [Object] + def deep_freeze(value) + case value + in Hash then value.each_value { deep_freeze(it) } + in Array then value.each { deep_freeze(it) } + in String then return value.freeze + else return value + end + value.freeze + end + end + + class RefinedSelectors + # Root keys a client fragment may contain. Published schema reads this set. + ALLOWED_KEYS = Set[:selectors].freeze + # Root keys rejected by name so the error is not a generic invalid-config. + DENIED_KEYS = Set[ + :auto_source, :channel, :directory, :headers, :params, :registry, :request, :strategy, :stylesheets + ].freeze + # Keeps the signed token inside practical feed-URL lengths. + MAX_WIRE_BYTES = 2_048 + # Placeholder channel used only so schema validation can judge the selectors subtree. + VALIDATION_CHANNEL_URL = 'https://example.com/' + private_constant :VALIDATION_CHANNEL_URL + + class << self + ## + # Builds refined selectors from a client config fragment. + # + # @param hash [Hash] client object; only +selectors+ is permitted + # @return [Html2rss::Web::RefinedSelectors] + # @raise [Html2rss::Web::BadRequestError] when the fragment is not allowlisted + def from_client(hash) = build(hash) + + ## + # Rebuilds refined selectors from a decoded token wire document. + # + # @param hash [Hash] wire object previously returned by {#to_wire} + # @return [Html2rss::Web::RefinedSelectors] + # @raise [Html2rss::Web::BadRequestError] when the fragment is not allowlisted + def from_wire(hash) = build(hash) + + private + + # @param hash [Object] + # @return [Html2rss::Web::RefinedSelectors] + def build(hash) + selectors = permitted_selectors(normalized_fragment(hash)) + enforce_wire_limit!(selectors) + validate_shape!(selectors) + new(selectors:) + end + + # @param hash [Object] + # @return [Hash{Symbol=>Object}] + def normalized_fragment(hash) + raise BadRequestError, 'refined selectors must be an object' unless hash.is_a?(Hash) + + symbolize(hash) + end + + # @param fragment [Hash{Symbol=>Object}] + # @return [Hash{Symbol=>Object}] + def permitted_selectors(fragment) + reject_keys!(fragment) + selectors = fragment[:selectors] + raise BadRequestError, 'selectors must be an object' unless selectors.is_a?(Hash) + + selectors + end + + # @param fragment [Hash{Symbol=>Object}] + # @return [void] + def reject_keys!(fragment) + denied = fragment.each_key.find { DENIED_KEYS.include?(it) } + raise BadRequestError, "#{denied} is not allowed" if denied + + unknown = fragment.each_key.find { !ALLOWED_KEYS.include?(it) } + raise BadRequestError, "#{unknown} is not allowed" if unknown + end + + # @param selectors [Hash{Symbol=>Object}] + # @return [void] + def enforce_wire_limit!(selectors) + return if JSON.generate({ selectors: }).bytesize <= MAX_WIRE_BYTES + + raise BadRequestError, "refined selectors exceed #{MAX_WIRE_BYTES} bytes" + end + + # @param selectors [Hash{Symbol=>Object}] + # @return [void] + def validate_shape!(selectors) + report = Html2rss::Config.validate({ channel: { url: VALIDATION_CHANNEL_URL }, selectors: }) + return if report.success? + + raise BadRequestError, report.to_s + end + + # @param value [Object] + # @return [Object] + def symbolize(value) + case value + in Hash + value.to_h { |key, nested| [key.to_sym, symbolize(nested)] } + in Array + value.map { symbolize(it) } + in String + value.dup + else + value + end + end + end + end + end +end diff --git a/spec/html2rss/web/security/refined_selectors_spec.rb b/spec/html2rss/web/security/refined_selectors_spec.rb new file mode 100644 index 00000000..29e4ba7f --- /dev/null +++ b/spec/html2rss/web/security/refined_selectors_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Html2rss::Web::RefinedSelectors do # rubocop:disable RSpec/SpecFilePathFormat + let(:selectors) { { items: { selector: 'article', enhance: true } } } + let(:fragment) { { selectors: } } + + describe '.from_client and .from_wire' do + it 'accepts an allowlisted selectors fragment and freezes the handoff', :aggregate_failures do + refined = described_class.from_client(fragment) + + expect(refined.to_config_fragment).to eq(selectors:) + expect(refined.to_wire).to eq(refined.to_config_fragment) + expect(refined).not_to be_empty + expect { refined.selectors[:items][:selector] << 'x' }.to raise_error(FrozenError) + expect(described_class.from_wire(refined.to_wire)).to eq(refined) + end + + it 'does not freeze the caller hash' do + raw = { 'selectors' => { 'items' => { 'selector' => +'article', 'enhance' => true } } } + + described_class.from_client(raw) + + expect { raw['selectors']['items']['selector'] << 'x' }.not_to raise_error + end + + it 'accepts string keys' do + refined = described_class.from_client( + 'selectors' => { 'items' => { 'selector' => 'article', 'enhance' => false } } + ) + + expect(refined.selectors).to eq(items: { selector: 'article', enhance: false }) + end + + described_class::DENIED_KEYS.each do |key| + it "rejects #{key} on mint and on decode", :aggregate_failures do + payload = fragment.merge(key => { 'url' => 'https://evil.example' }) + + expect { described_class.from_client(payload) } + .to raise_error(Html2rss::Web::BadRequestError, "#{key} is not allowed") + expect { described_class.from_wire(payload) } + .to raise_error(Html2rss::Web::BadRequestError, "#{key} is not allowed") + end + end + + it 'rejects unknown root keys' do + expect { described_class.from_client(foo: 1) } + .to raise_error(Html2rss::Web::BadRequestError, 'foo is not allowed') + end + + it 'rejects a non-object fragment' do + expect { described_class.from_wire('selectors') } + .to raise_error(Html2rss::Web::BadRequestError, 'refined selectors must be an object') + end + + it 'rejects a non-object selectors value' do + expect { described_class.from_client(selectors: 'article') } + .to raise_error(Html2rss::Web::BadRequestError, 'selectors must be an object') + end + + it 'rejects fragments over the wire byte cap' do + payload = { selectors: { items: { selector: 'a' * (described_class::MAX_WIRE_BYTES + 1) } } } + + expect { described_class.from_client(payload) }.to raise_error( + Html2rss::Web::BadRequestError, + "refined selectors exceed #{described_class::MAX_WIRE_BYTES} bytes" + ) + end + + it 'delegates shape failures to config validation' do + expect { described_class.from_client(selectors: { items: { selector: '' } }) } + .to raise_error(Html2rss::Web::BadRequestError, /selectors\.items\.selector \[invalid_value\]/) + end + end +end From 133fce170281a123211a500970f8484434ed4be5 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 19 Sep 2026 21:51:11 +0200 Subject: [PATCH 005/108] feat(security): sign refined selectors into feed tokens The signature gains selectors only when a token carries them, so feed URLs already in the wild keep validating. --- app/web/security/auth.rb | 4 +- app/web/security/feed_token.rb | 72 +++++++++++++++++++++++----- spec/html2rss/web/feed_token_spec.rb | 62 +++++++++++++++++++++++- 3 files changed, 123 insertions(+), 15 deletions(-) diff --git a/app/web/security/auth.rb b/app/web/security/auth.rb index 89e5cca6..4c4188c9 100644 --- a/app/web/security/auth.rb +++ b/app/web/security/auth.rb @@ -25,13 +25,15 @@ def authenticate(request) # @param username [String] # @param url [String] # @param strategy [String, nil] + # @param selectors [Html2rss::Web::RefinedSelectors, nil] # @param expires_in [Integer] seconds (default: 10 years) # @return [String, nil] signed feed token when generation succeeds. - def generate_feed_token(username, url, strategy: nil, expires_in: FeedToken::DEFAULT_EXPIRY) + def generate_feed_token(username, url, strategy: nil, selectors: nil, expires_in: FeedToken::DEFAULT_EXPIRY) token = FeedToken::Signer.create( username: username, url: url, strategy: strategy, + selectors: selectors, expires_in: expires_in, secret_key: secret_key ) diff --git a/app/web/security/feed_token.rb b/app/web/security/feed_token.rb index 37505d1e..2e947c6e 100644 --- a/app/web/security/feed_token.rb +++ b/app/web/security/feed_token.rb @@ -12,7 +12,20 @@ module Web # # Wire encoding and signature verification are unified inside this class to # keep the token lifetime concerns cohesive. - FeedToken = Data.define(:username, :url, :expires_at, :signature, :strategy) do + FeedToken = Data.define(:username, :url, :expires_at, :signature, :strategy, :selectors) do + ## + # @param username [String] + # @param url [String] + # @param expires_at [Integer] + # @param signature [String] + # @param strategy [String, nil] + # @param selectors [Html2rss::Web::RefinedSelectors, nil] + # @return [Html2rss::Web::FeedToken] + # rubocop:disable-next Metrics/ParameterLists + def initialize(username:, url:, expires_at:, signature:, strategy:, selectors: nil) + super + end + # @return [Boolean] def expired? Time.now.to_i > expires_at @@ -48,7 +61,7 @@ def decode(encoded_token) return unless valid_token_data?(token_data) decoded_token(token_data) - rescue JSON::ParserError, ArgumentError, Zlib::DataError, Zlib::BufError + rescue JSON::ParserError, ArgumentError, Zlib::DataError, Zlib::BufError, BadRequestError nil end @@ -59,6 +72,7 @@ def decode(encoded_token) def wire_document(token) payload = { u: token.username, l: token.url, e: token.expires_at } payload[:t] = token.strategy if token.strategy + payload[:c] = token.selectors.to_wire if signable_selectors?(token.selectors) { p: payload, s: token.signature } end @@ -78,10 +92,17 @@ def decoded_token(token_data) url: payload[:l], expires_at: payload[:e], signature: token_data[:s], - strategy: payload[:t] + strategy: payload[:t], + selectors: selectors_from_wire(payload[:c]) ) end + # @param wire [Object] + # @return [Boolean] + def valid_selectors_wire?(wire) + wire.nil? || wire.is_a?(Hash) + end + # @param token_data [Object] # @return [Boolean] def valid_token_data?(token_data) @@ -91,6 +112,20 @@ def valid_token_data?(token_data) signature.is_a?(String) && !signature.empty? && valid_payload?(token_data[:p]) end + # @param wire [Hash, nil] + # @return [Html2rss::Web::RefinedSelectors, nil] + def selectors_from_wire(wire) + return if wire.nil? + + RefinedSelectors.from_wire(wire) + end + + # @param selectors [Html2rss::Web::RefinedSelectors, nil] + # @return [Boolean] + def signable_selectors?(selectors) + !selectors.nil? && !selectors.empty? + end + # @param payload [Object] # @return [Boolean] def valid_payload?(payload) @@ -98,7 +133,8 @@ def valid_payload?(payload) payload[:u].is_a?(String) && payload[:l].is_a?(String) && payload[:e].is_a?(Integer) && - (payload[:t].nil? || payload[:t].is_a?(String)) + (payload[:t].nil? || payload[:t].is_a?(String)) && + valid_selectors_wire?(payload[:c]) end end end @@ -113,14 +149,16 @@ class << self # @param url [String] # @param secret_key [String] # @param strategy [String, nil] + # @param selectors [Html2rss::Web::RefinedSelectors, nil] # @param expires_in [Integer] # @return [Html2rss::Web::FeedToken, nil] - def create(username:, url:, secret_key:, strategy: nil, expires_in: DEFAULT_EXPIRY) - return unless valid_inputs?(username, url, secret_key, strategy) + # rubocop:disable-next Metrics/ParameterLists + def create(username:, url:, secret_key:, strategy: nil, selectors: nil, expires_in: DEFAULT_EXPIRY) + return unless valid_inputs?(username, url, secret_key, strategy, selectors) expires_at = Time.now.to_i + expires_in.to_i - signature = sign(secret_key, signature_payload(username, url, expires_at, strategy)) - FeedToken.new(username:, url:, expires_at:, signature:, strategy:) + signature = sign(secret_key, signature_payload(username, url, expires_at, strategy, selectors)) + FeedToken.new(username:, url:, expires_at:, signature:, strategy:, selectors:) end # @param encoded_token [String, nil] @@ -153,7 +191,8 @@ def valid_signature?(token, secret_key) return false unless secret_key.is_a?(String) && !secret_key.empty? expected_signature = sign(secret_key, signature_payload( - token.username, token.url, token.expires_at, token.strategy + token.username, token.url, token.expires_at, + token.strategy, token.selectors )) signatures_match?(token.signature, expected_signature) end @@ -164,10 +203,12 @@ def valid_signature?(token, secret_key) # @param url [String] # @param expires_at [Integer] # @param strategy [String, nil] + # @param selectors [Html2rss::Web::RefinedSelectors, nil] # @return [Hash{Symbol=>Object}] - def signature_payload(username, url, expires_at, strategy) + def signature_payload(username, url, expires_at, strategy, selectors = nil) payload = { username:, url:, expires_at: } payload[:strategy] = strategy if strategy + payload[:selectors] = selectors.to_wire if selectors && !selectors.empty? payload end @@ -192,10 +233,11 @@ def signatures_match?(first, second) # @param url [Object] # @param secret_key [Object] # @param strategy [Object] + # @param selectors [Object] # @return [Boolean] - def valid_inputs?(username, url, secret_key, strategy) + def valid_inputs?(username, url, secret_key, strategy, selectors = nil) valid_username?(username) && UrlValidator.valid_url?(url) && valid_secret_key?(secret_key) && - valid_strategy?(strategy) + valid_strategy?(strategy) && valid_selectors?(selectors) end # @param username [Object] @@ -218,6 +260,12 @@ def valid_strategy?(strategy) strategy.is_a?(String) && !strategy.empty? && strategy.length <= 50 && strategy.match?(/\A[a-z0-9_]+\z/) end + + # @param selectors [Object] + # @return [Boolean] + def valid_selectors?(selectors) + selectors.nil? || selectors.is_a?(RefinedSelectors) + end end end end diff --git a/spec/html2rss/web/feed_token_spec.rb b/spec/html2rss/web/feed_token_spec.rb index 3059af41..4efff54f 100644 --- a/spec/html2rss/web/feed_token_spec.rb +++ b/spec/html2rss/web/feed_token_spec.rb @@ -171,6 +171,21 @@ expect(described_class.decode(nil)).to be_nil end + it 'rejects a selectors wire key that is not an object' do + bad_token = Base64.urlsafe_encode64(Zlib::Deflate.deflate({ + p: { u: 'alice', l: 'https://example.com/feed', e: 123, c: 'nope' }, s: 'sig' + }.to_json)) + + expect(described_class.decode(bad_token)).to be_nil + end + + it 'rejects selector wire outside the allowlist' do + wire = { u: 'alice', l: 'https://example.com/feed', e: 123, c: { channel: { url: 'https://evil.example' } } } + bad_token = Base64.urlsafe_encode64(Zlib::Deflate.deflate({ p: wire, s: 'sig' }.to_json)) + + expect(described_class.decode(bad_token)).to be_nil + end + it 'rejects payloads with incorrect types', :aggregate_failures do invalid_payloads = [ { u: 'alice', l: 'https://example.com/feed', e: '123456' }, @@ -203,15 +218,58 @@ end end + describe 'refined selectors' do + let(:secret_key) { 'test-secret' } + let(:url) { 'https://example.com/feed' } + let(:selectors) do + Html2rss::Web::RefinedSelectors.from_client(selectors: { items: { selector: 'article', enhance: true } }) + end + + it 'keeps the legacy signature when selectors are absent' do + token = Html2rss::Web::FeedToken::Signer.create(username: 'alice', url:, secret_key:, strategy: 'some_strategy') + legacy = { username: 'alice', url:, expires_at: token.expires_at, strategy: 'some_strategy' } + digest = OpenSSL::HMAC.hexdigest('SHA256', secret_key, JSON.generate(legacy)) + + expect(token.signature).to eq(digest) + end + + it 'round-trips signed selectors and rejects a swap', :aggregate_failures do + token = Html2rss::Web::FeedToken::Signer.create( + username: 'alice', url:, secret_key:, strategy: 'some_strategy', selectors: + ) + encoded = Html2rss::Web::FeedToken::Codec.encode(token) + swapped = Html2rss::Web::RefinedSelectors.from_client(selectors: { items: { selector: 'section' } }) + tampered = Html2rss::Web::FeedToken::Codec.encode(token.with(selectors: swapped)) + + expect(Html2rss::Web::FeedToken::Signer.validate(encoded, url, secret_key).selectors).to eq(selectors) + expect(Html2rss::Web::FeedToken::Signer.validate(tampered, url, secret_key)).to be_nil + end + + it 'signs selectors passed through Auth.generate_feed_token' do + encoded = Html2rss::Web::Auth.generate_feed_token('admin', url, selectors:) + + expect(Html2rss::Web::Auth.validate_and_decode_feed_token(encoded).selectors).to eq(selectors) + end + end + describe '#expired?' do it 'returns true for past timestamps' do - token = described_class.new('alice', 'https://example.com/feed', Time.now.to_i - 1, 'sig', nil) + token = described_class.new( + username: 'alice', + url: 'https://example.com/feed', + expires_at: Time.now.to_i - 1, + signature: 'sig', + strategy: nil + ) expect(token.expired?).to be(true) end it 'returns false for future timestamps' do - token = described_class.new('alice', 'https://example.com/feed', Time.now.to_i + 3600, 'sig', nil) + token = described_class.new( + username: 'alice', url: 'https://example.com/feed', expires_at: Time.now.to_i + 3600, signature: 'sig', + strategy: nil + ) expect(token.expired?).to be(false) end From ac850c8d1b530a9082efb5f7af93bfb9576069f2 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 19 Sep 2026 22:05:35 +0200 Subject: [PATCH 006/108] feat(feeds): resolve token feeds with signed refined selectors 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. --- app/web/feeds/generator_input.rb | 12 ++++---- app/web/feeds/source_resolver.rb | 13 ++++++++- spec/html2rss/web/app_integration_spec.rb | 3 +- .../web/feeds/generator_input_spec.rb | 22 +++++++++++++- .../web/feeds/source_resolver_spec.rb | 29 ++++++++++++++++++- 5 files changed, 68 insertions(+), 11 deletions(-) diff --git a/app/web/feeds/generator_input.rb b/app/web/feeds/generator_input.rb index ef1b5912..8b393966 100644 --- a/app/web/feeds/generator_input.rb +++ b/app/web/feeds/generator_input.rb @@ -13,21 +13,19 @@ class << self ## # Builds the generator config for a signed token feed. # - # Selector fragments are accepted on the signature now so both call - # sites share one method; merging them is a later widening. - # # @param url [String] channel URL bound to the token # @param strategy [String, Symbol] resolved request strategy - # @param selectors [Object, nil] reserved until refined selectors are signed + # @param selectors [Html2rss::Web::RefinedSelectors, nil] signed selector fragment # @return [Hash{Symbol=>Object}] def for_token(url:, strategy:, selectors: nil) - raise ArgumentError, 'selector expansion is not available' unless selectors.nil? - - LocalConfig.global.slice(:stylesheets, :headers).merge( + expanded = LocalConfig.global.slice(:stylesheets, :headers).merge( channel: { url: }, auto_source: {}, strategy: strategy.to_sym ) + return expanded if selectors.nil? || selectors.empty? + + expanded.merge(selectors.to_config_fragment) end end end diff --git a/app/web/feeds/source_resolver.rb b/app/web/feeds/source_resolver.rb index ec589712..9b55fb3e 100644 --- a/app/web/feeds/source_resolver.rb +++ b/app/web/feeds/source_resolver.rb @@ -63,7 +63,7 @@ def resolve_token(feed_request) # @param feed_token [Html2rss::Web::FeedToken] # @return [Html2rss::Web::Feeds::Contracts::ResolvedSource] def build_token_source(feed_request, feed_token) - generator_input = GeneratorInput.for_token(url: feed_token.url, strategy: resolved_strategy(feed_token)) + generator_input = expanded_generator_input(feed_token) resolved_source( source_kind: :token, cache_identity: token_cache_identity(feed_request.token), @@ -75,6 +75,17 @@ def build_token_source(feed_request, feed_token) ) end + # @param feed_token [Html2rss::Web::FeedToken] + # @return [Hash{Symbol=>Object}] + def expanded_generator_input(feed_token) + selectors = feed_token.selectors + GeneratorInput.for_token( + url: feed_token.url, + strategy: resolved_strategy(feed_token), + selectors: selectors.nil? ? nil : RefinedSelectors.from_wire(selectors.to_wire) + ) + end + # @param kwargs [Hash{Symbol=>Object}] # @return [Html2rss::Web::Feeds::Contracts::ResolvedSource] def resolved_source(**kwargs) diff --git a/spec/html2rss/web/app_integration_spec.rb b/spec/html2rss/web/app_integration_spec.rb index 8d171710..508154f9 100644 --- a/spec/html2rss/web/app_integration_spec.rb +++ b/spec/html2rss/web/app_integration_spec.rb @@ -231,7 +231,8 @@ def stub_escaped_feed_token(raw_token:, encoded_token:) Html2rss::Web::FeedToken, url: feed_url, username: account[:username], - strategy: 'default' + strategy: 'default', + selectors: nil ) allow(Html2rss::Web::FeedToken::Codec).to receive(:decode).with(raw_token).and_return(escaped_token_payload) diff --git a/spec/html2rss/web/feeds/generator_input_spec.rb b/spec/html2rss/web/feeds/generator_input_spec.rb index 4cd6e3c6..381ae3a7 100644 --- a/spec/html2rss/web/feeds/generator_input_spec.rb +++ b/spec/html2rss/web/feeds/generator_input_spec.rb @@ -27,9 +27,21 @@ ) end + it 'merges signed selectors without replacing the token url' do + selectors = Html2rss::Web::RefinedSelectors.from_client( + selectors: { items: { selector: 'article', enhance: true } } + ) + + expect(described_class.for_token(url:, strategy: 'default', selectors:)).to include( + channel: { url: }, + strategy: :default, + selectors: { items: { selector: 'article', enhance: true } } + ) + end + context 'when serve and preview expand the same token' do let(:feed_token) do - instance_double(Html2rss::Web::FeedToken, username: 'admin', url:, strategy: 'default') + instance_double(Html2rss::Web::FeedToken, username: 'admin', url:, strategy: 'default', selectors: nil) end let(:feed_request) do Html2rss::Web::Feeds::Contracts::Request.new( @@ -52,6 +64,14 @@ expect(served).to eq(described_class.for_token(url:, strategy: feed_token.strategy)) end + + it 'returns one config when both paths carry the same selectors' do + selectors = Html2rss::Web::RefinedSelectors.from_client(selectors: { items: { selector: 'article' } }) + allow(feed_token).to receive(:selectors).and_return(selectors) + served = Html2rss::Web::Feeds::SourceResolver.call(feed_request).generator_input + + expect(served).to eq(described_class.for_token(url:, strategy: feed_token.strategy, selectors:)) + end end end end diff --git a/spec/html2rss/web/feeds/source_resolver_spec.rb b/spec/html2rss/web/feeds/source_resolver_spec.rb index d30d882c..e777f3ee 100644 --- a/spec/html2rss/web/feeds/source_resolver_spec.rb +++ b/spec/html2rss/web/feeds/source_resolver_spec.rb @@ -89,7 +89,8 @@ def resolved_tuple(resolved) Html2rss::Web::FeedToken, username: 'admin', url: 'https://example.com/private', - strategy: 'default' + strategy: 'default', + selectors: nil ) end @@ -175,6 +176,32 @@ def resolved_tuple(resolved) expect { described_class.call(feed_request) } .to raise_error(Html2rss::Web::ForbiddenError, 'Access Denied') end + + it 're-checks selector wire before building the generator config' do + allow(feed_token).to receive(:selectors).and_return( + instance_double( + Html2rss::Web::RefinedSelectors, + to_wire: { channel: { url: 'https://evil.example' } } + ) + ) + + expect { described_class.call(feed_request) } + .to raise_error(Html2rss::Web::BadRequestError, 'channel is not allowed') + end + + it 'keeps the token url when selectors are signed' do + selectors = Html2rss::Web::RefinedSelectors.from_client( + selectors: { items: { selector: 'article' } } + ) + allow(feed_token).to receive(:selectors).and_return(selectors) + + resolved = described_class.call(feed_request) + + expect(resolved.generator_input).to include( + channel: { url: 'https://example.com/private' }, + selectors: { items: { selector: 'article' } } + ) + end end end end From fa02d60cd92a80c0ba4089e577001b68f4540772 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 19 Sep 2026 22:13:52 +0200 Subject: [PATCH 007/108] feat(api): add studio validate, preview, and suggest endpoints 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. --- app/web/api/v1/preview_feed.rb | 116 +++++++++++++++++++ app/web/api/v1/suggest_selectors.rb | 113 ++++++++++++++++++ app/web/api/v1/validate_config.rb | 109 ++++++++++++++++++ app/web/config/flags.rb | 14 ++- app/web/routes/api_v1/feed_routes.rb | 35 ++++-- frontend/src/api/generated/index.ts | 4 +- frontend/src/api/generated/sdk.gen.ts | 17 ++- frontend/src/api/generated/types.gen.ts | 69 +++++++++++ public/openapi.yaml | 146 ++++++++++++++++++++++++ spec/html2rss/web/api/v1_spec.rb | 105 +++++++++++++++++ 10 files changed, 717 insertions(+), 11 deletions(-) create mode 100644 app/web/api/v1/preview_feed.rb create mode 100644 app/web/api/v1/suggest_selectors.rb create mode 100644 app/web/api/v1/validate_config.rb diff --git a/app/web/api/v1/preview_feed.rb b/app/web/api/v1/preview_feed.rb new file mode 100644 index 00000000..30cf07d4 --- /dev/null +++ b/app/web/api/v1/preview_feed.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require 'json' + +module Html2rss + module Web + module Api + module V1 + ## + # Live extraction preview for a refined token config. + # + # Builds the config through {Feeds::GeneratorInput} and selects + # {Html2rss::Test::Result} fields. Never forwards +#to_h+, which includes +rss:+. + module PreviewFeed + class << self + ## + # @param request [Rack::Request] + # @return [Hash{Symbol=>Object}] + def call(request) + account = live_account(request) + body = json_object(request) + url = allowed_url(body['url'], account) + result = Html2rss.test(preview_config(url, body['selectors']), min_items: 1) + Response.success(response: request.response, data: preview_fields(result)) + end + + private + + # @param request [Rack::Request] + # @return [Hash] + def live_account(request) + raise ForbiddenError, 'Studio is disabled' unless Flags.studio_enabled? + raise AutoSourceDisabledError unless Flags.auto_source_enabled? + + account = Auth.authenticate(request) + raise UnauthorizedError, 'Authentication required' unless account + + account + end + + # @param request [Rack::Request] + # @return [Hash] + def json_object(request) + raw = read_body(request) + return {} if raw.strip.empty? + + parsed = JSON.parse(raw) + raise BadRequestError, 'Invalid JSON payload' unless parsed.is_a?(Hash) + + parsed + rescue JSON::ParserError + raise BadRequestError, 'Invalid JSON payload' + end + + def read_body(request) + limit = CreateFeed::MAX_BODY_BYTES + raise BadRequestError, 'Payload too large' if request.content_length.to_i > limit + + request.body.read(limit + 1).to_s.tap do |raw| + request.body.rewind + raise BadRequestError, 'Payload too large' if raw.bytesize > limit + end + end + + # @param raw_url [String, nil] + # @param account [Hash] + # @return [String] + def allowed_url(raw_url, account) + url = UrlValidator.canonical_url(raw_url.to_s.strip) + raise BadRequestError, 'URL parameter is required' if raw_url.to_s.strip.empty? + raise BadRequestError, 'Invalid URL format' unless url + raise ForbiddenError, 'URL not allowed for this account' unless UrlValidator.url_allowed?(account, url) + + url + end + + # @param url [String] + # @param selectors [Object] + # @return [Hash{Symbol=>Object}] + def preview_config(url, selectors) + Feeds::GeneratorInput.for_token(url:, strategy: default_strategy, selectors: refined(selectors)) + end + + # @param selectors [Object] + # @return [Html2rss::Web::RefinedSelectors, nil] + def refined(selectors) + return if selectors.nil? + + RefinedSelectors.from_client({ selectors: }) + end + + # @return [String] + def default_strategy + configured = Html2rss::Config.default_strategy_name.to_s + configured.strip.empty? ? 'auto' : configured + end + + # Selected fields only. {Html2rss::Test::Result#to_h} also carries +rss+. + # + # @param result [Html2rss::Test::Result] + # @return [Hash{Symbol=>Object}] + def preview_fields(result) + { + item_count: result.item_count, + sample_items: result.sample_items, + quality_report: result.quality_report&.to_h, + failure_kind: result.failure_kind&.to_sym, + validation_issues: result.validation_issues&.map(&:to_h) + } + end + end + end + end + end + end +end diff --git a/app/web/api/v1/suggest_selectors.rb b/app/web/api/v1/suggest_selectors.rb new file mode 100644 index 00000000..219ef42a --- /dev/null +++ b/app/web/api/v1/suggest_selectors.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require 'json' + +module Html2rss + module Web + module Api + module V1 + ## + # One capture-derived items selector for the refinement studio. + # + # +Html2rss.capture+ returns the first segment strategy that clears its + # quality gate, not a candidate list. + module SuggestSelectors + class << self + ## + # @param request [Rack::Request] + # @return [Hash{Symbol=>Object}] + def call(request) + account = live_account(request) + body = json_object(request) + captured = Html2rss.capture( + allowed_url(body['url'], account), + strategy: :auto, + items_selector: hint(body['items_selector']) + ) + Response.success(response: request.response, data: suggestion(captured)) + end + + private + + # @param request [Rack::Request] + # @return [Hash] + def live_account(request) + raise ForbiddenError, 'Studio is disabled' unless Flags.studio_enabled? + raise AutoSourceDisabledError unless Flags.auto_source_enabled? + + account = Auth.authenticate(request) + raise UnauthorizedError, 'Authentication required' unless account + + account + end + + # @param request [Rack::Request] + # @return [Hash] + def json_object(request) + raw = read_body(request) + return {} if raw.strip.empty? + + parsed = JSON.parse(raw) + raise BadRequestError, 'Invalid JSON payload' unless parsed.is_a?(Hash) + + parsed + rescue JSON::ParserError + raise BadRequestError, 'Invalid JSON payload' + end + + def read_body(request) + limit = CreateFeed::MAX_BODY_BYTES + raise BadRequestError, 'Payload too large' if request.content_length.to_i > limit + + request.body.read(limit + 1).to_s.tap do |raw| + request.body.rewind + raise BadRequestError, 'Payload too large' if raw.bytesize > limit + end + end + + # @param raw_url [String, nil] + # @param account [Hash] + # @return [String] + def allowed_url(raw_url, account) + url = UrlValidator.canonical_url(raw_url.to_s.strip) + raise BadRequestError, 'URL parameter is required' if raw_url.to_s.strip.empty? + raise BadRequestError, 'Invalid URL format' unless url + raise ForbiddenError, 'URL not allowed for this account' unless UrlValidator.url_allowed?(account, url) + + url + end + + # @param raw [Object] + # @return [String, nil] + def hint(raw) + text = raw.to_s.strip + text.empty? ? nil : text + end + + # @param captured [Html2rss::Capture::CaptureResult] + # @return [Hash{Symbol=>Object}] + def suggestion(captured) + selectors = captured.config[:selectors] || captured.config['selectors'] + items = selectors.is_a?(Hash) ? selectors[:items] || selectors['items'] : nil + { + selectors:, + segment_strategy: captured.segment_strategy&.to_s, + admission_drops: captured.admission_drops, + enhance: enhance_flag(items) + } + end + + # @param items [Hash, nil] + # @return [Boolean, nil] + def enhance_flag(items) + return unless items.is_a?(Hash) + return items[:enhance] if items.key?(:enhance) + + items['enhance'] if items.key?('enhance') + end + end + end + end + end + end +end diff --git a/app/web/api/v1/validate_config.rb b/app/web/api/v1/validate_config.rb new file mode 100644 index 00000000..c932c371 --- /dev/null +++ b/app/web/api/v1/validate_config.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require 'json' + +module Html2rss + module Web + module Api + module V1 + ## + # Server-authoritative config check for the refinement studio. + # + # Parses YAML or a selectors object, gates it through {RefinedSelectors}, + # and returns the gem validation report plus normalized YAML. + module ValidateConfig + PLACEHOLDER_URL = 'https://example.com/' + private_constant :PLACEHOLDER_URL + + class << self + ## + # @param request [Rack::Request] + # @return [Hash{Symbol=>Object}] + def call(request) + ensure_enabled! + body = json_object(request) + report, selectors = assess(body) + Response.success(response: request.response, data: payload(report, selectors)) + end + + private + + # @return [void] + def ensure_enabled! + raise ForbiddenError, 'Studio is disabled' unless Flags.studio_enabled? + end + + # @param request [Rack::Request] + # @return [Hash] + def json_object(request) + raw = read_body(request) + return {} if raw.strip.empty? + + parsed = JSON.parse(raw) + raise BadRequestError, 'Invalid JSON payload' unless parsed.is_a?(Hash) + + parsed + rescue JSON::ParserError + raise BadRequestError, 'Invalid JSON payload' + end + + # @param request [Rack::Request] + # @return [String] + def read_body(request) + limit = CreateFeed::MAX_BODY_BYTES + raise BadRequestError, 'Payload too large' if request.content_length.to_i > limit + + request.body.read(limit + 1).to_s.tap do |raw| + request.body.rewind + raise BadRequestError, 'Payload too large' if raw.bytesize > limit + end + end + + # @param body [Hash] + # @return [Array(Html2rss::Config::ValidationReport, Hash, nil)] + def assess(body) + return assess_yaml(body['yaml']) if body.key?('yaml') && !body['yaml'].nil? + raise BadRequestError, 'yaml or selectors is required' unless body.key?('selectors') + + assess_fragment({ selectors: body['selectors'] }) + end + + # @param yaml [Object] + # @return [Array(Html2rss::Config::ValidationReport, Hash, nil)] + def assess_yaml(yaml) + assess_fragment(Html2rss::Config.from_yaml(yaml.to_s)) + rescue ArgumentError, Psych::Exception => error + [Html2rss::Config::IssueMapper.parse_failure(error.message), nil] + end + + # @param fragment [Hash] + # @return [Array(Html2rss::Config::ValidationReport, Hash, nil)] + def assess_fragment(fragment) + refined = RefinedSelectors.from_client(fragment) + [validation_report(refined.selectors), refined.selectors] + rescue BadRequestError => error + raise unless error.message.include?(' [') + + selectors = fragment.is_a?(Hash) ? fragment[:selectors] || fragment['selectors'] : nil + [validation_report(selectors), selectors.is_a?(Hash) ? selectors : nil] + end + + # @param selectors [Hash, nil] + # @return [Html2rss::Config::ValidationReport] + def validation_report(selectors) + Html2rss::Config.validate({ channel: { url: PLACEHOLDER_URL }, selectors: selectors || {} }) + end + + # @param report [Html2rss::Config::ValidationReport] + # @param selectors [Hash, nil] + # @return [Hash{Symbol=>Object}] + def payload(report, selectors) + yaml = selectors.is_a?(Hash) ? Html2rss::Config.to_yaml({ selectors: }) : nil + { report: report.to_h, selectors:, yaml: } + end + end + end + end + end + end +end diff --git a/app/web/config/flags.rb b/app/web/config/flags.rb index 33a343fb..10b85497 100644 --- a/app/web/config/flags.rb +++ b/app/web/config/flags.rb @@ -24,6 +24,13 @@ module Flags # rubocop:disable Metrics/ModuleLength default: -> { development_or_test? }, validator: nil ), + studio_enabled: Definition.new( + name: :studio_enabled, + env_key: 'STUDIO_ENABLED', + type: :boolean, + default: -> { development_or_test? }, + validator: nil + ), config_catalog_enabled: Definition.new( name: :config_catalog_enabled, env_key: 'CONFIG_CATALOG_ENABLED', @@ -68,7 +75,7 @@ module Flags # rubocop:disable Metrics/ModuleLength ) }.freeze MANAGED_ENV_PREFIXES = %w[ - AUTO_SOURCE_ CONFIG_CATALOG_ FEEDS_CACHE_ RATE_LIMIT_ RETRY_AFTER_ + AUTO_SOURCE_ CONFIG_CATALOG_ FEEDS_CACHE_ RATE_LIMIT_ RETRY_AFTER_ STUDIO_ ].freeze KNOWN_ENV_KEYS = Set.new(DEFINITIONS.values.map(&:env_key)).freeze DEV_OR_TEST_ENVS = Set['development', 'test'].freeze @@ -109,6 +116,11 @@ def auto_source_enabled? fetch(:auto_source_enabled) end + # @return [Boolean] + def studio_enabled? + fetch(:studio_enabled) + end + # Validates all known flags and managed env key prefixes. # # @return [void] diff --git a/app/web/routes/api_v1/feed_routes.rb b/app/web/routes/api_v1/feed_routes.rb index 2c54cce0..bdf48727 100644 --- a/app/web/routes/api_v1/feed_routes.rb +++ b/app/web/routes/api_v1/feed_routes.rb @@ -7,23 +7,44 @@ module ApiV1 ## # Mounts feed-related API routes under `/api/v1/feeds`. module FeedRoutes + STUDIO_POSTS = { + 'validate' => Api::V1::ValidateConfig, + 'preview' => Api::V1::PreviewFeed, + 'suggest_selectors' => Api::V1::SuggestSelectors + }.freeze + class << self # @param router [Roda::RodaRequest] # @return [void] def call(router) router.on 'feeds' do - router.get String do |token| - router.env[RequestTarget::ENV_KEY] = RequestTarget::FEED - Feeds::Responder.call(request: router, target_kind: :token, identifier: token) - end + mount_studio_posts(router) + mount_token_get(router) - router.post do - JSON.generate(Api::V1::CreateFeed.call(router)) - end + router.post { JSON.generate(Api::V1::CreateFeed.call(router)) } raise NotFoundError end end + + private + + # @param router [Roda::RodaRequest] + # @return [void] + def mount_studio_posts(router) + STUDIO_POSTS.each do |path, handler| + router.post(path) { JSON.generate(handler.call(router)) } + end + end + + # @param router [Roda::RodaRequest] + # @return [void] + def mount_token_get(router) + router.get(String) do |token| + router.env[RequestTarget::ENV_KEY] = RequestTarget::FEED + Feeds::Responder.call(request: router, target_kind: :token, identifier: token) + end + end end end end diff --git a/frontend/src/api/generated/index.ts b/frontend/src/api/generated/index.ts index 56b4fe60..947952c1 100644 --- a/frontend/src/api/generated/index.ts +++ b/frontend/src/api/generated/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { createFeed, getApiMetadata, getConfigCatalog, getHealthStatus, getLivenessProbe, getReadinessProbe, listStrategies, type Options, optionsConfigCatalog, renderFeedByToken } from './sdk.gen'; -export type { ClientOptions, CreateFeedData, CreateFeedError, CreateFeedErrors, CreateFeedResponse, CreateFeedResponses, GetApiMetadataData, GetApiMetadataResponse, GetApiMetadataResponses, GetConfigCatalogData, GetConfigCatalogError, GetConfigCatalogErrors, GetConfigCatalogResponse, GetConfigCatalogResponses, GetHealthStatusData, GetHealthStatusError, GetHealthStatusErrors, GetHealthStatusResponse, GetHealthStatusResponses, GetLivenessProbeData, GetLivenessProbeResponse, GetLivenessProbeResponses, GetReadinessProbeData, GetReadinessProbeResponse, GetReadinessProbeResponses, ListStrategiesData, ListStrategiesResponse, ListStrategiesResponses, OptionsConfigCatalogData, OptionsConfigCatalogResponse, OptionsConfigCatalogResponses, RenderFeedByTokenData, RenderFeedByTokenError, RenderFeedByTokenErrors, RenderFeedByTokenResponse, RenderFeedByTokenResponses } from './types.gen'; +export { createFeed, getApiMetadata, getConfigCatalog, getHealthStatus, getLivenessProbe, getReadinessProbe, listStrategies, type Options, optionsConfigCatalog, renderFeedByToken, validateFeedConfig } from './sdk.gen'; +export type { ClientOptions, CreateFeedData, CreateFeedError, CreateFeedErrors, CreateFeedResponse, CreateFeedResponses, GetApiMetadataData, GetApiMetadataResponse, GetApiMetadataResponses, GetConfigCatalogData, GetConfigCatalogError, GetConfigCatalogErrors, GetConfigCatalogResponse, GetConfigCatalogResponses, GetHealthStatusData, GetHealthStatusError, GetHealthStatusErrors, GetHealthStatusResponse, GetHealthStatusResponses, GetLivenessProbeData, GetLivenessProbeResponse, GetLivenessProbeResponses, GetReadinessProbeData, GetReadinessProbeResponse, GetReadinessProbeResponses, ListStrategiesData, ListStrategiesResponse, ListStrategiesResponses, OptionsConfigCatalogData, OptionsConfigCatalogResponse, OptionsConfigCatalogResponses, RenderFeedByTokenData, RenderFeedByTokenError, RenderFeedByTokenErrors, RenderFeedByTokenResponse, RenderFeedByTokenResponses, ValidateFeedConfigData, ValidateFeedConfigError, ValidateFeedConfigErrors, ValidateFeedConfigResponse, ValidateFeedConfigResponses } from './types.gen'; diff --git a/frontend/src/api/generated/sdk.gen.ts b/frontend/src/api/generated/sdk.gen.ts index 858a3abc..27e18dba 100644 --- a/frontend/src/api/generated/sdk.gen.ts +++ b/frontend/src/api/generated/sdk.gen.ts @@ -2,7 +2,7 @@ import { type Client, type ClientMeta, type Options as Options2, type RequestResult, type TDataShape, urlSearchParamsBodySerializer } from './client'; import { client } from './client.gen'; -import type { CreateFeedData, CreateFeedErrors, CreateFeedResponses, GetApiMetadataData, GetApiMetadataResponses, GetConfigCatalogData, GetConfigCatalogErrors, GetConfigCatalogResponses, GetHealthStatusData, GetHealthStatusErrors, GetHealthStatusResponses, GetLivenessProbeData, GetLivenessProbeResponses, GetReadinessProbeData, GetReadinessProbeResponses, ListStrategiesData, ListStrategiesResponses, OptionsConfigCatalogData, OptionsConfigCatalogResponses, RenderFeedByTokenData, RenderFeedByTokenErrors, RenderFeedByTokenResponses } from './types.gen'; +import type { CreateFeedData, CreateFeedErrors, CreateFeedResponses, GetApiMetadataData, GetApiMetadataResponses, GetConfigCatalogData, GetConfigCatalogErrors, GetConfigCatalogResponses, GetHealthStatusData, GetHealthStatusErrors, GetHealthStatusResponses, GetLivenessProbeData, GetLivenessProbeResponses, GetReadinessProbeData, GetReadinessProbeResponses, ListStrategiesData, ListStrategiesResponses, OptionsConfigCatalogData, OptionsConfigCatalogResponses, RenderFeedByTokenData, RenderFeedByTokenErrors, RenderFeedByTokenResponses, ValidateFeedConfigData, ValidateFeedConfigErrors, ValidateFeedConfigResponses } from './types.gen'; export type Options = Options2 & { /** @@ -69,6 +69,21 @@ export const createFeed = (options: Option */ export const renderFeedByToken = (options: Options): RequestResult => (options.client ?? client).get({ url: '/feeds/{token}', ...options }); +/** + * Validate a refined config + * + * Preview a refined feed + */ +export const validateFeedConfig = (options: Options): RequestResult => (options.client ?? client).post({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/feeds/{token}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + /** * Authenticated health check * diff --git a/frontend/src/api/generated/types.gen.ts b/frontend/src/api/generated/types.gen.ts index f24843d1..28b4600b 100644 --- a/frontend/src/api/generated/types.gen.ts +++ b/frontend/src/api/generated/types.gen.ts @@ -298,6 +298,75 @@ export type RenderFeedByTokenResponses = { export type RenderFeedByTokenResponse = RenderFeedByTokenResponses[keyof RenderFeedByTokenResponses]; +export type ValidateFeedConfigData = { + body?: { + selectors?: { + items: { + enhance: boolean; + selector: string; + }; + }; + yaml?: string; + }; + headers: { + Authorization: string; + }; + path: { + token: string; + }; + query?: never; + url: '/feeds/{token}'; +}; + +export type ValidateFeedConfigErrors = { + /** + * rejects channel so the token url cannot be replaced + */ + 400: { + error: { + code: string; + kind: string; + message: string; + next_action: string; + retry_action: string; + retryable: boolean; + }; + success: boolean; + }; +}; + +export type ValidateFeedConfigError = ValidateFeedConfigErrors[keyof ValidateFeedConfigErrors]; + +export type ValidateFeedConfigResponses = { + /** + * returns a parse issue for unparseable yaml + */ + 200: { + data: { + report: { + issues: Array<{ + actual: unknown; + code: string; + expected: unknown; + message: string; + path: Array; + }>; + success: boolean; + }; + selectors: { + items: { + enhance: boolean; + selector: string; + }; + } | null; + yaml: string | null; + }; + success: boolean; + }; +}; + +export type ValidateFeedConfigResponse = ValidateFeedConfigResponses[keyof ValidateFeedConfigResponses]; + export type GetHealthStatusData = { body?: never; headers: { diff --git a/public/openapi.yaml b/public/openapi.yaml index adafa5a9..0bee484d 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -619,6 +619,152 @@ paths: summary: Render feed by token tags: - Feeds + post: + description: Preview a refined feed + operationId: validateFeedConfig + parameters: + - in: header + name: Authorization + required: true + schema: + type: string + - in: path + name: token + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + properties: + selectors: + properties: + items: + properties: + enhance: + type: boolean + selector: + type: string + required: + - selector + - enhance + type: object + required: + - items + type: object + yaml: + type: string + type: object + responses: + '200': + content: + application/json: + schema: + properties: + data: + properties: + report: + properties: + issues: + items: + properties: + actual: + nullable: true + code: + type: string + expected: + nullable: true + message: + type: string + path: + items: + type: string + type: array + required: + - path + - code + - message + - expected + - actual + type: object + type: array + success: + type: boolean + required: + - success + - issues + type: object + selectors: + nullable: true + properties: + items: + properties: + enhance: + type: boolean + selector: + type: string + required: + - selector + - enhance + type: object + required: + - items + type: object + yaml: + nullable: true + type: string + required: + - report + - selectors + - yaml + type: object + success: + type: boolean + required: + - success + - data + type: object + description: returns a parse issue for unparseable yaml + '400': + content: + application/json: + schema: + properties: + error: + properties: + code: + type: string + kind: + type: string + message: + type: string + next_action: + type: string + retry_action: + type: string + retryable: + type: boolean + required: + - code + - message + - kind + - retryable + - next_action + - retry_action + type: object + success: + type: boolean + required: + - success + - error + type: object + description: rejects channel so the token url cannot be replaced + security: + - BearerAuth: [] + summary: Validate a refined config + tags: + - Studio "/health": get: description: Authenticated health check diff --git a/spec/html2rss/web/api/v1_spec.rb b/spec/html2rss/web/api/v1_spec.rb index 95786d47..f30a4431 100644 --- a/spec/html2rss/web/api/v1_spec.rb +++ b/spec/html2rss/web/api/v1_spec.rb @@ -832,4 +832,109 @@ def relative_feed_link_header(token) expect(json.dig('error', 'message')).to eq('Payload too large') end end + + describe 'POST /api/v1/feeds/validate', openapi: { + summary: 'Validate a refined config', + operation_id: 'validateFeedConfig', + tags: ['Studio'] + } do + def post_validate(payload) + header 'Content-Type', 'application/json' + post '/api/v1/feeds/validate', payload.to_json + end + + it 'returns a structured report for an allowlisted selectors object', :aggregate_failures do + post_validate(selectors: { items: { selector: 'article', enhance: true } }) + + json = expect_success_response(last_response) + expect(json.dig('data', 'report', 'success')).to be(true) + expect(json.dig('data', 'selectors', 'items', 'selector')).to eq('article') + end + + it 'returns a parse issue for unparseable yaml' do + post_validate(yaml: "selectors: [\n") + + json = expect_success_response(last_response) + expect(json.dig('data', 'report', 'issues', 0, 'code')).to eq('parse') + end + + it 'rejects channel so the token url cannot be replaced' do + post_validate(yaml: "channel:\n url: https://evil.example\nselectors:\n items:\n selector: article\n") + + expect(last_response.status).to eq(400) + expect(JSON.parse(last_response.body).dig('error', 'message')).to eq('channel is not allowed') + end + end + + describe 'POST /api/v1/feeds/preview', openapi: { + summary: 'Preview a refined feed', + operation_id: 'previewFeed', + tags: ['Studio'], + security: [{ 'BearerAuth' => [] }] + } do + let(:preview_result) do + Html2rss::Test::Result.new( + success: true, item_count: 2, sample_items: [{ 'title' => 'A' }], channel_title: 'Example', + channel_url: feed_url, strategy_used: :auto, duration_seconds: 0.1, validation_issues: [], + error_message: nil, failure_kind: nil, rss: 'do-not-leak' + ) + end + + before do + allow(Html2rss).to receive(:test).and_return(preview_result) + end + + it 'selects test fields and never returns the rss body', :aggregate_failures do + header 'Authorization', "Bearer #{admin_token}" + header 'Content-Type', 'application/json' + post '/api/v1/feeds/preview', { url: feed_url, selectors: { items: { selector: 'article' } } }.to_json + + json = expect_success_response(last_response) + expect(json.fetch('data').keys).to contain_exactly( + 'item_count', 'sample_items', 'quality_report', 'failure_kind', 'validation_issues' + ) + expect(json.dig('data', 'item_count')).to eq(2) + end + + it 'previews through the token generator config' do + header 'Authorization', "Bearer #{admin_token}" + header 'Content-Type', 'application/json' + selectors = Html2rss::Web::RefinedSelectors.from_client(selectors: { items: { selector: 'article' } }) + post '/api/v1/feeds/preview', { url: feed_url, selectors: { items: { selector: 'article' } } }.to_json + + strategy = Html2rss::Config.default_strategy_name.to_s + strategy = 'auto' if strategy.strip.empty? + expect(Html2rss).to have_received(:test).with( + Html2rss::Web::Feeds::GeneratorInput.for_token(url: feed_url, strategy:, selectors:), + min_items: 1 + ) + end + end + + describe 'POST /api/v1/feeds/suggest_selectors', openapi: { + summary: 'Suggest selectors for a page', + operation_id: 'suggestSelectors', + tags: ['Studio'], + security: [{ 'BearerAuth' => [] }] + } do + let(:captured) do + instance_double( + Html2rss::Capture::CaptureResult, + config: { selectors: { items: { selector: 'article', enhance: false } } }, + segment_strategy: :list, + admission_drops: { 'chrome' => 2 } + ) + end + + it 'returns the capture suggestion and its evidence', :aggregate_failures do + allow(Html2rss).to receive(:capture).and_return(captured) + header 'Authorization', "Bearer #{admin_token}" + header 'Content-Type', 'application/json' + post '/api/v1/feeds/suggest_selectors', { url: feed_url, items_selector: 'h2' }.to_json + + json = expect_success_response(last_response) + expect(json.dig('data', 'segment_strategy')).to eq('list') + expect(json.dig('data', 'enhance')).to be(false) + end + end end From 9aff9c0dbd05e9ba86e1cf4693965f1401812dba Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 19 Sep 2026 22:23:33 +0200 Subject: [PATCH 008/108] feat(api): accept refined selectors on create and publish the studio 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. --- app/web/api/v1/create_feed.rb | 20 +- frontend/src/api/generated/index.ts | 4 +- frontend/src/api/generated/sdk.gen.ts | 52 +++- frontend/src/api/generated/types.gen.ts | 170 +++++++++--- public/openapi.yaml | 355 +++++++++++++++++------- spec/html2rss/web/api/v1_spec.rb | 8 + spec/support/openapi.rb | 67 ++++- 7 files changed, 511 insertions(+), 165 deletions(-) diff --git a/app/web/api/v1/create_feed.rb b/app/web/api/v1/create_feed.rb index b3638f57..d01fc89b 100644 --- a/app/web/api/v1/create_feed.rb +++ b/app/web/api/v1/create_feed.rb @@ -18,6 +18,16 @@ module CreateFeed # rubocop:disable Metrics/ModuleLength (?:[/?#].*)?\z }ix + # Allowlists an optional selectors object before it is signed. Absent keeps legacy tokens valid. + # + # @param request [Rack::Request] + # @return [Html2rss::Web::RefinedSelectors, nil] + def self.selectors_for(request) + raw = request_params(request)['selectors'] + raw.nil? ? nil : RefinedSelectors.from_client({ selectors: raw }) + end + private_class_method :selectors_for + class << self # Creates a feed and returns a normalized API success payload. # @@ -26,8 +36,7 @@ class << self def call(request) account = require_account(request) params = build_create_params(request, account) - feed_data = create_feed(params, account) - + feed_data = create_feed(params, account, request) emit_create(status: :success, details: { url: params.url }) Response.success(response: request.response, status: 201, data: { feed: feed_attributes(feed_data) }, meta: { created: true }) @@ -117,15 +126,14 @@ def normalized_input_url(raw_url) HOSTNAME_INPUT_REGEXP.match?(url) ? "https://#{url}" : url end - # Extracts via {Feeds::Service} before minting so create and serve share one expansion path. - # # @param params [Html2rss::Web::Api::V1::FeedMetadata::CreateParams] # @param account [Hash] + # @param request [Rack::Request] # @return [Html2rss::Web::Api::V1::FeedMetadata::Metadata] - def create_feed(params, account) + def create_feed(params, account, request) raise Html2rss::Web::AutoSourceDisabledError unless Flags.auto_source_enabled? - feed_token = Auth.generate_feed_token(account[:username], params.url) + feed_token = Auth.generate_feed_token(account[:username], params.url, selectors: selectors_for(request)) raise Html2rss::Web::InternalServerError, 'Failed to create feed' unless feed_token result = Feeds::Service.call(resolved_source_for(feed_token)) diff --git a/frontend/src/api/generated/index.ts b/frontend/src/api/generated/index.ts index 947952c1..4d175972 100644 --- a/frontend/src/api/generated/index.ts +++ b/frontend/src/api/generated/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { createFeed, getApiMetadata, getConfigCatalog, getHealthStatus, getLivenessProbe, getReadinessProbe, listStrategies, type Options, optionsConfigCatalog, renderFeedByToken, validateFeedConfig } from './sdk.gen'; -export type { ClientOptions, CreateFeedData, CreateFeedError, CreateFeedErrors, CreateFeedResponse, CreateFeedResponses, GetApiMetadataData, GetApiMetadataResponse, GetApiMetadataResponses, GetConfigCatalogData, GetConfigCatalogError, GetConfigCatalogErrors, GetConfigCatalogResponse, GetConfigCatalogResponses, GetHealthStatusData, GetHealthStatusError, GetHealthStatusErrors, GetHealthStatusResponse, GetHealthStatusResponses, GetLivenessProbeData, GetLivenessProbeResponse, GetLivenessProbeResponses, GetReadinessProbeData, GetReadinessProbeResponse, GetReadinessProbeResponses, ListStrategiesData, ListStrategiesResponse, ListStrategiesResponses, OptionsConfigCatalogData, OptionsConfigCatalogResponse, OptionsConfigCatalogResponses, RenderFeedByTokenData, RenderFeedByTokenError, RenderFeedByTokenErrors, RenderFeedByTokenResponse, RenderFeedByTokenResponses, ValidateFeedConfigData, ValidateFeedConfigError, ValidateFeedConfigErrors, ValidateFeedConfigResponse, ValidateFeedConfigResponses } from './types.gen'; +export { createFeed, getApiMetadata, getConfigCatalog, getHealthStatus, getLivenessProbe, getReadinessProbe, listStrategies, type Options, optionsConfigCatalog, previewFeed, renderFeedByToken, suggestSelectors, validateFeedConfig } from './sdk.gen'; +export type { ClientOptions, CreateFeedData, CreateFeedError, CreateFeedErrors, CreateFeedResponse, CreateFeedResponses, GetApiMetadataData, GetApiMetadataResponse, GetApiMetadataResponses, GetConfigCatalogData, GetConfigCatalogError, GetConfigCatalogErrors, GetConfigCatalogResponse, GetConfigCatalogResponses, GetHealthStatusData, GetHealthStatusError, GetHealthStatusErrors, GetHealthStatusResponse, GetHealthStatusResponses, GetLivenessProbeData, GetLivenessProbeResponse, GetLivenessProbeResponses, GetReadinessProbeData, GetReadinessProbeResponse, GetReadinessProbeResponses, ListStrategiesData, ListStrategiesResponse, ListStrategiesResponses, OptionsConfigCatalogData, OptionsConfigCatalogResponse, OptionsConfigCatalogResponses, PreviewFeedData, PreviewFeedResponse, PreviewFeedResponses, RefinedSelectorKey, RenderFeedByTokenData, RenderFeedByTokenError, RenderFeedByTokenErrors, RenderFeedByTokenResponse, RenderFeedByTokenResponses, SuggestSelectorsData, SuggestSelectorsResponse, SuggestSelectorsResponses, ValidateFeedConfigData, ValidateFeedConfigError, ValidateFeedConfigErrors, ValidateFeedConfigResponse, ValidateFeedConfigResponses } from './types.gen'; diff --git a/frontend/src/api/generated/sdk.gen.ts b/frontend/src/api/generated/sdk.gen.ts index 27e18dba..601fefc9 100644 --- a/frontend/src/api/generated/sdk.gen.ts +++ b/frontend/src/api/generated/sdk.gen.ts @@ -2,7 +2,7 @@ import { type Client, type ClientMeta, type Options as Options2, type RequestResult, type TDataShape, urlSearchParamsBodySerializer } from './client'; import { client } from './client.gen'; -import type { CreateFeedData, CreateFeedErrors, CreateFeedResponses, GetApiMetadataData, GetApiMetadataResponses, GetConfigCatalogData, GetConfigCatalogErrors, GetConfigCatalogResponses, GetHealthStatusData, GetHealthStatusErrors, GetHealthStatusResponses, GetLivenessProbeData, GetLivenessProbeResponses, GetReadinessProbeData, GetReadinessProbeResponses, ListStrategiesData, ListStrategiesResponses, OptionsConfigCatalogData, OptionsConfigCatalogResponses, RenderFeedByTokenData, RenderFeedByTokenErrors, RenderFeedByTokenResponses, ValidateFeedConfigData, ValidateFeedConfigErrors, ValidateFeedConfigResponses } from './types.gen'; +import type { CreateFeedData, CreateFeedErrors, CreateFeedResponses, GetApiMetadataData, GetApiMetadataResponses, GetConfigCatalogData, GetConfigCatalogErrors, GetConfigCatalogResponses, GetHealthStatusData, GetHealthStatusErrors, GetHealthStatusResponses, GetLivenessProbeData, GetLivenessProbeResponses, GetReadinessProbeData, GetReadinessProbeResponses, ListStrategiesData, ListStrategiesResponses, OptionsConfigCatalogData, OptionsConfigCatalogResponses, PreviewFeedData, PreviewFeedResponses, RenderFeedByTokenData, RenderFeedByTokenErrors, RenderFeedByTokenResponses, SuggestSelectorsData, SuggestSelectorsResponses, ValidateFeedConfigData, ValidateFeedConfigErrors, ValidateFeedConfigResponses } from './types.gen'; export type Options = Options2 & { /** @@ -53,7 +53,6 @@ export const optionsConfigCatalog = (optio * Create a feed */ export const createFeed = (options: Options): RequestResult => (options.client ?? client).post({ - security: [{ scheme: 'bearer', type: 'http' }], url: '/feeds', ...options, headers: { @@ -63,20 +62,26 @@ export const createFeed = (options: Option }); /** - * Render feed by token + * Preview a refined feed * - * Render feed by token + * Preview a refined feed */ -export const renderFeedByToken = (options: Options): RequestResult => (options.client ?? client).get({ url: '/feeds/{token}', ...options }); +export const previewFeed = (options: Options): RequestResult => (options.client ?? client).post({ + url: '/feeds/preview', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); /** - * Validate a refined config + * Suggest selectors for a page * - * Preview a refined feed + * Suggest selectors for a page */ -export const validateFeedConfig = (options: Options): RequestResult => (options.client ?? client).post({ - security: [{ scheme: 'bearer', type: 'http' }], - url: '/feeds/{token}', +export const suggestSelectors = (options: Options): RequestResult => (options.client ?? client).post({ + url: '/feeds/suggest_selectors', ...options, headers: { 'Content-Type': 'application/json', @@ -84,16 +89,33 @@ export const validateFeedConfig = (options } }); +/** + * Validate a refined config + * + * Validate a refined config + */ +export const validateFeedConfig = (options?: Options): RequestResult => (options?.client ?? client).post({ + url: '/feeds/validate', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); + +/** + * Render feed by token + * + * Render feed by token + */ +export const renderFeedByToken = (options: Options): RequestResult => (options.client ?? client).get({ url: '/feeds/{token}', ...options }); + /** * Authenticated health check * * Authenticated health check */ -export const getHealthStatus = (options: Options): RequestResult => (options.client ?? client).get({ - security: [{ scheme: 'bearer', type: 'http' }], - url: '/health', - ...options -}); +export const getHealthStatus = (options: Options): RequestResult => (options.client ?? client).get({ url: '/health', ...options }); /** * Liveness probe diff --git a/frontend/src/api/generated/types.gen.ts b/frontend/src/api/generated/types.gen.ts index 28b4600b..f03da78d 100644 --- a/frontend/src/api/generated/types.gen.ts +++ b/frontend/src/api/generated/types.gen.ts @@ -4,6 +4,11 @@ export type ClientOptions = { baseUrl: 'https://api.html2rss.dev/api/v1' | 'http://127.0.0.1:4000/api/v1' | (string & {}); }; +/** + * Root keys a client-authored config fragment may contain. + */ +export type RefinedSelectorKey = 'selectors'; + export type GetApiMetadataData = { body?: never; path?: never; @@ -150,6 +155,12 @@ export type OptionsConfigCatalogResponse = OptionsConfigCatalogResponses[keyof O export type CreateFeedData = { body?: { name?: string; + selectors?: { + items: { + enhance: boolean; + selector: string; + }; + }; url: string; }; headers: { @@ -247,56 +258,79 @@ export type CreateFeedResponses = { export type CreateFeedResponse = CreateFeedResponses[keyof CreateFeedResponses]; -export type RenderFeedByTokenData = { - body?: never; - path: { - token: string; +export type PreviewFeedData = { + body?: { + selectors: { + items: { + selector: string; + }; + }; + url: string; + }; + headers: { + Authorization: string; }; + path?: never; query?: never; - url: '/feeds/{token}'; + url: '/feeds/preview'; }; -export type RenderFeedByTokenErrors = { - /** - * returns unauthorized for invalid tokens - */ - 401: string; +export type PreviewFeedResponses = { /** - * returns forbidden when auto source is disabled - */ - 403: string; - /** - * returns 422 for empty extraction feeds in xml representation - */ - 422: string; - /** - * returns 429 when rate limit is exceeded + * previews through the token generator config */ - 429: string; - /** - * returns non-cacheable feed errors when service generation fails - */ - 500: string; - /** - * returns 503 when the scraper queue times out - */ - 503: string; - /** - * returns 504 when the gateway times out - */ - 504: string; + 200: { + data: { + failure_kind: unknown; + item_count: number; + quality_report: unknown; + sample_items: Array<{ + title: string; + }>; + validation_issues: Array; + }; + success: boolean; + }; }; -export type RenderFeedByTokenError = RenderFeedByTokenErrors[keyof RenderFeedByTokenErrors]; +export type PreviewFeedResponse = PreviewFeedResponses[keyof PreviewFeedResponses]; -export type RenderFeedByTokenResponses = { +export type SuggestSelectorsData = { + body?: { + items_selector: string; + url: string; + }; + headers: { + Authorization: string; + }; + path?: never; + query?: never; + url: '/feeds/suggest_selectors'; +}; + +export type SuggestSelectorsResponses = { /** - * renders feed for a valid token + * returns the capture suggestion and its evidence */ - 200: string; + 200: { + data: { + admission_drops: { + chrome: number; + }; + enhance: boolean; + segment_strategy: string; + selectors: { + items: { + enhance: boolean; + selector: string; + }; + }; + }; + success: boolean; + }; }; -export type RenderFeedByTokenResponse = RenderFeedByTokenResponses[keyof RenderFeedByTokenResponses]; +export type SuggestSelectorsResponse = SuggestSelectorsResponses[keyof SuggestSelectorsResponses]; export type ValidateFeedConfigData = { body?: { @@ -308,14 +342,9 @@ export type ValidateFeedConfigData = { }; yaml?: string; }; - headers: { - Authorization: string; - }; - path: { - token: string; - }; + path?: never; query?: never; - url: '/feeds/{token}'; + url: '/feeds/validate'; }; export type ValidateFeedConfigErrors = { @@ -346,7 +375,7 @@ export type ValidateFeedConfigResponses = { report: { issues: Array<{ actual: unknown; - code: string; + code: 'constraint' | 'invalid_value' | 'missing_key' | 'parse' | 'type_mismatch' | 'unknown_value'; expected: unknown; message: string; path: Array; @@ -367,6 +396,57 @@ export type ValidateFeedConfigResponses = { export type ValidateFeedConfigResponse = ValidateFeedConfigResponses[keyof ValidateFeedConfigResponses]; +export type RenderFeedByTokenData = { + body?: never; + path: { + token: string; + }; + query?: never; + url: '/feeds/{token}'; +}; + +export type RenderFeedByTokenErrors = { + /** + * returns unauthorized for invalid tokens + */ + 401: string; + /** + * returns forbidden when auto source is disabled + */ + 403: string; + /** + * returns 422 for empty extraction feeds in xml representation + */ + 422: string; + /** + * returns 429 when rate limit is exceeded + */ + 429: string; + /** + * returns non-cacheable feed errors when service generation fails + */ + 500: string; + /** + * returns 503 when the scraper queue times out + */ + 503: string; + /** + * returns 504 when the gateway times out + */ + 504: string; +}; + +export type RenderFeedByTokenError = RenderFeedByTokenErrors[keyof RenderFeedByTokenErrors]; + +export type RenderFeedByTokenResponses = { + /** + * renders feed for a valid token + */ + 200: string; +}; + +export type RenderFeedByTokenResponse = RenderFeedByTokenResponses[keyof RenderFeedByTokenResponses]; + export type GetHealthStatusData = { body?: never; headers: { diff --git a/public/openapi.yaml b/public/openapi.yaml index 0bee484d..095dee2f 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -323,6 +323,21 @@ paths: properties: name: type: string + selectors: + properties: + items: + properties: + enhance: + type: boolean + selector: + type: string + required: + - selector + - enhance + type: object + required: + - items + type: object url: type: string required: @@ -529,110 +544,163 @@ paths: summary: Create a feed tags: - Feeds - "/feeds/{token}": - get: - description: Render feed by token - operationId: renderFeedByToken + "/feeds/preview": + post: + description: Preview a refined feed + operationId: previewFeed parameters: - - in: path - name: token + - in: header + name: Authorization required: true schema: type: string + requestBody: + content: + application/json: + schema: + properties: + selectors: + properties: + items: + properties: + selector: + type: string + required: + - selector + type: object + required: + - items + type: object + url: + type: string + required: + - url + - selectors + type: object responses: '200': content: - application/feed+json: - schema: - type: string - application/xml: - schema: - type: string - description: renders feed for a valid token - '401': - content: - text/plain: - example: 'Failed to generate feed: Invalid token' - schema: - type: string - description: returns unauthorized for invalid tokens - '403': - content: - text/plain: - example: 'Failed to generate feed: Invalid token' - schema: - type: string - description: returns forbidden when auto source is disabled - '422': - content: - text/plain: - example: We could not extract feed items from this page yet. Try a more - specific listing URL or explicit selectors. - schema: - type: string - description: returns 422 for empty extraction feeds in xml representation - '429': - content: - text/plain: - example: 'Failed to generate feed: Invalid token' - schema: - type: string - description: returns 429 when rate limit is exceeded - headers: - Retry-After: - description: The number of seconds to wait before retrying the request. - schema: - type: integer - '500': - content: - text/plain: - example: 'Failed to generate feed: Invalid token' - schema: - type: string - description: returns non-cacheable feed errors when service generation fails - '503': - content: - text/plain: - example: 'Failed to generate feed: Invalid token' - schema: - type: string - description: returns 503 when the scraper queue times out - headers: - Retry-After: - description: The number of seconds to wait before retrying the request. - schema: - type: integer - '504': - content: - text/plain: - example: 'Failed to generate feed: Invalid token' - schema: - type: string - description: returns 504 when the gateway times out - headers: - Retry-After: - description: The number of seconds to wait before retrying the request. + application/json: schema: - type: integer + properties: + data: + properties: + failure_kind: + nullable: true + item_count: + type: integer + quality_report: + nullable: true + sample_items: + items: + properties: + title: + type: string + required: + - title + type: object + type: array + validation_issues: + items: {} + type: array + required: + - item_count + - sample_items + - quality_report + - failure_kind + - validation_issues + type: object + success: + type: boolean + required: + - success + - data + type: object + description: previews through the token generator config security: - - {} - summary: Render feed by token + - BearerAuth: [] + summary: Preview a refined feed tags: - - Feeds + - Studio + "/feeds/suggest_selectors": post: - description: Preview a refined feed - operationId: validateFeedConfig + description: Suggest selectors for a page + operationId: suggestSelectors parameters: - in: header name: Authorization required: true schema: type: string - - in: path - name: token - required: true - schema: - type: string + requestBody: + content: + application/json: + schema: + properties: + items_selector: + type: string + url: + type: string + required: + - url + - items_selector + type: object + responses: + '200': + content: + application/json: + schema: + properties: + data: + properties: + admission_drops: + properties: + chrome: + type: integer + required: + - chrome + type: object + enhance: + type: boolean + segment_strategy: + type: string + selectors: + properties: + items: + properties: + enhance: + type: boolean + selector: + type: string + required: + - selector + - enhance + type: object + required: + - items + type: object + required: + - selectors + - segment_strategy + - admission_drops + - enhance + type: object + success: + type: boolean + required: + - success + - data + type: object + description: returns the capture suggestion and its evidence + security: + - BearerAuth: [] + summary: Suggest selectors for a page + tags: + - Studio + "/feeds/validate": + post: + description: Validate a refined config + operationId: validateFeedConfig requestBody: content: application/json: @@ -673,6 +741,13 @@ paths: nullable: true code: type: string + enum: + - constraint + - invalid_value + - missing_key + - parse + - type_mismatch + - unknown_value expected: nullable: true message: @@ -760,11 +835,99 @@ paths: - error type: object description: rejects channel so the token url cannot be replaced - security: - - BearerAuth: [] summary: Validate a refined config tags: - Studio + "/feeds/{token}": + get: + description: Render feed by token + operationId: renderFeedByToken + parameters: + - in: path + name: token + required: true + schema: + type: string + responses: + '200': + content: + application/feed+json: + schema: + type: string + application/xml: + schema: + type: string + description: renders feed for a valid token + '401': + content: + text/plain: + example: 'Failed to generate feed: Invalid token' + schema: + type: string + description: returns unauthorized for invalid tokens + '403': + content: + text/plain: + example: 'Failed to generate feed: Invalid token' + schema: + type: string + description: returns forbidden when auto source is disabled + '422': + content: + text/plain: + example: We could not extract feed items from this page yet. Try a more + specific listing URL or explicit selectors. + schema: + type: string + description: returns 422 for empty extraction feeds in xml representation + '429': + content: + text/plain: + example: 'Failed to generate feed: Invalid token' + schema: + type: string + description: returns 429 when rate limit is exceeded + headers: + Retry-After: + description: The number of seconds to wait before retrying the request. + schema: + type: integer + '500': + content: + text/plain: + example: 'Failed to generate feed: Invalid token' + schema: + type: string + description: returns non-cacheable feed errors when service generation fails + '503': + content: + text/plain: + example: 'Failed to generate feed: Invalid token' + schema: + type: string + description: returns 503 when the scraper queue times out + headers: + Retry-After: + description: The number of seconds to wait before retrying the request. + schema: + type: integer + '504': + content: + text/plain: + example: 'Failed to generate feed: Invalid token' + schema: + type: string + description: returns 504 when the gateway times out + headers: + Retry-After: + description: The number of seconds to wait before retrying the request. + schema: + type: integer + security: + - {} + summary: Render feed by token + tags: + - Feeds "/health": get: description: Authenticated health check @@ -1023,12 +1186,12 @@ paths: tags: - Strategies components: - securitySchemes: - BearerAuth: - description: Bearer token authentication for API access. - type: http - scheme: bearer - bearerFormat: JWT + schemas: + RefinedSelectorKey: + description: Root keys a client-authored config fragment may contain. + enum: + - selectors + type: string tags: - name: Root description: API metadata and service-level information. @@ -1040,3 +1203,5 @@ tags: description: Feed extraction strategy discovery. - name: Feeds description: Feed creation and feed rendering operations. +- name: Studio + description: Validate, preview, and suggest refined feed configs. diff --git a/spec/html2rss/web/api/v1_spec.rb b/spec/html2rss/web/api/v1_spec.rb index f30a4431..1d937f4d 100644 --- a/spec/html2rss/web/api/v1_spec.rb +++ b/spec/html2rss/web/api/v1_spec.rb @@ -728,6 +728,14 @@ def relative_feed_link_header(token) retry_action: 'none' } + it 'signs optional refined selectors into the feed token', :aggregate_failures do + post_feed_request(url: feed_url, selectors: { items: { selector: 'article', enhance: true } }) + + token = expect_success_response(last_response).dig('data', 'feed', 'feed_token') + decoded = Html2rss::Web::Auth.validate_and_decode_feed_token(token) + expect(decoded.selectors.to_config_fragment.dig(:selectors, :items, :selector)).to eq('article') + end + it 'creates a feed when request is valid', :aggregate_failures do header 'Authorization', "Bearer #{admin_token}" header 'Content-Type', 'application/json' diff --git a/spec/support/openapi.rb b/spec/support/openapi.rb index 11c3188c..27bc805b 100644 --- a/spec/support/openapi.rb +++ b/spec/support/openapi.rb @@ -1,5 +1,63 @@ # frozen_string_literal: true +## +# Stamps the published document from runtime allowlists so the contract cannot drift. +module OpenapiRuntimeContract + module_function + + # @param spec [Hash] + # @return [void] + def stamp(spec) + walk(spec) { |node| stamp_issue_code(node) } + publish_allowed_keys(spec) + end + + # @param node [Object] + # @return [void] + def walk(node, &) + case node + in Hash + yield node + node.each_value { walk(it, &) } + in Array + node.each { walk(it, &) } + else + nil + end + end + + # @param node [Hash] + # @return [void] + def stamp_issue_code(node) + properties = node['properties'] + return unless properties.is_a?(Hash) && issue_schema?(properties) + + code = properties['code'] + return unless code.is_a?(Hash) + + code['type'] = 'string' + # Data.define publishes this Set on Config, not on ValidationIssue. + code['enum'] = Html2rss::Config::CODES.map(&:to_s).sort + end + + # @param properties [Hash] + # @return [Boolean] + def issue_schema?(properties) + %w[actual code expected message path].all? { properties.key?(it) } + end + + # @param spec [Hash] + # @return [void] + def publish_allowed_keys(spec) + schemas = (spec['components'] ||= {})['schemas'] ||= {} + schemas['RefinedSelectorKey'] = { + 'description' => 'Root keys a client-authored config fragment may contain.', + 'enum' => Html2rss::Web::RefinedSelectors::ALLOWED_KEYS.map(&:to_s).sort, + 'type' => 'string' + } + end +end + if ENV['OPENAPI'] require 'rspec/openapi' @@ -144,10 +202,12 @@ path_map = spec['paths'] || spec[:paths] next unless path_map.is_a?(Hash) + studio_segment = Regexp.union(Html2rss::Web::Routes::ApiV1::FeedRoutes::STUDIO_POSTS.keys).source + token_path = %r{\A/api/v1/feeds/(?!(?:#{studio_segment})\z)[^/]+\z} normalized_paths = {} path_map.each do |raw_path, operation| original_path = raw_path.to_s - normalized = if original_path.match?(%r{\A/api/v1/feeds/[^/]+\z}) + normalized = if original_path.match?(token_path) '/feeds/{token}' elsif original_path.start_with?('/api/v1') original_path.delete_prefix('/api/v1') @@ -228,7 +288,8 @@ { 'name' => 'Catalog', 'description' => 'Public feed-directory catalog metadata.' }, { 'name' => 'Health', 'description' => 'Health and readiness endpoints.' }, { 'name' => 'Strategies', 'description' => 'Feed extraction strategy discovery.' }, - { 'name' => 'Feeds', 'description' => 'Feed creation and feed rendering operations.' } + { 'name' => 'Feeds', 'description' => 'Feed creation and feed rendering operations.' }, + { 'name' => 'Studio', 'description' => 'Validate, preview, and suggest refined feed configs.' } ] if spec.key?('tags') @@ -236,5 +297,7 @@ else spec[:tags] = tags end + + OpenapiRuntimeContract.stamp(spec) end end From d0117e9803bc83a55e256036507065de6c458e8c Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 19 Sep 2026 22:42:55 +0200 Subject: [PATCH 009/108] fix(api): keep BearerAuth when publishing the studio schema `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. --- Makefile | 3 +- frontend/src/api/generated/sdk.gen.ts | 9 +++++- frontend/src/api/generated/types.gen.ts | 15 ++++++++-- public/openapi.yaml | 29 +++++++++++++++++-- spec/html2rss/web/api/v1_spec.rb | 21 ++++++++++++++ spec/support/openapi.rb | 38 ++++++++++++++++++++++--- 6 files changed, 105 insertions(+), 10 deletions(-) diff --git a/Makefile b/Makefile index c4444eb7..789dac75 100644 --- a/Makefile +++ b/Makefile @@ -111,10 +111,11 @@ ready: ## Pre-commit gate (quick checks + RSpec) bundle exec rspec @echo "Pre-commit checks complete!" -ci-ready: ## CI parity gate (ready + OpenAPI verify + frontend e2e smoke) +ci-ready: ## CI parity gate (ready + OpenAPI verify/lint + frontend e2e smoke) @echo "Running CI parity checks..." $(MAKE) ready $(MAKE) openapi-verify + $(MAKE) openapi-lint $(MAKE) test-frontend-e2e @echo "CI parity checks complete!" diff --git a/frontend/src/api/generated/sdk.gen.ts b/frontend/src/api/generated/sdk.gen.ts index 601fefc9..ca91cce0 100644 --- a/frontend/src/api/generated/sdk.gen.ts +++ b/frontend/src/api/generated/sdk.gen.ts @@ -53,6 +53,7 @@ export const optionsConfigCatalog = (optio * Create a feed */ export const createFeed = (options: Options): RequestResult => (options.client ?? client).post({ + security: [{ scheme: 'bearer', type: 'http' }], url: '/feeds', ...options, headers: { @@ -67,6 +68,7 @@ export const createFeed = (options: Option * Preview a refined feed */ export const previewFeed = (options: Options): RequestResult => (options.client ?? client).post({ + security: [{ scheme: 'bearer', type: 'http' }], url: '/feeds/preview', ...options, headers: { @@ -81,6 +83,7 @@ export const previewFeed = (options: Optio * Suggest selectors for a page */ export const suggestSelectors = (options: Options): RequestResult => (options.client ?? client).post({ + security: [{ scheme: 'bearer', type: 'http' }], url: '/feeds/suggest_selectors', ...options, headers: { @@ -115,7 +118,11 @@ export const renderFeedByToken = (options: * * Authenticated health check */ -export const getHealthStatus = (options: Options): RequestResult => (options.client ?? client).get({ url: '/health', ...options }); +export const getHealthStatus = (options: Options): RequestResult => (options.client ?? client).get({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/health', + ...options +}); /** * Liveness probe diff --git a/frontend/src/api/generated/types.gen.ts b/frontend/src/api/generated/types.gen.ts index f03da78d..b3ba6927 100644 --- a/frontend/src/api/generated/types.gen.ts +++ b/frontend/src/api/generated/types.gen.ts @@ -281,9 +281,14 @@ export type PreviewFeedResponses = { */ 200: { data: { - failure_kind: unknown; + failure_kind: string | null; item_count: number; - quality_report: unknown; + quality_report: { + metrics: { + short_title_count: number; + }; + warnings: Array; + } | null; sample_items: Array<{ title: string; }>; @@ -374,8 +379,14 @@ export type ValidateFeedConfigResponses = { data: { report: { issues: Array<{ + /** + * JSON-ish value, or null when the issue carries none. + */ actual: unknown; code: 'constraint' | 'invalid_value' | 'missing_key' | 'parse' | 'type_mismatch' | 'unknown_value'; + /** + * JSON-ish value, or null when the issue carries none. + */ expected: unknown; message: string; path: Array; diff --git a/public/openapi.yaml b/public/openapi.yaml index 095dee2f..37afe559 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -587,10 +587,27 @@ paths: properties: failure_kind: nullable: true + type: string item_count: type: integer quality_report: nullable: true + properties: + metrics: + properties: + short_title_count: + type: integer + required: + - short_title_count + type: object + warnings: + items: + type: string + type: array + required: + - warnings + - metrics + type: object sample_items: items: properties: @@ -738,7 +755,8 @@ paths: items: properties: actual: - nullable: true + description: JSON-ish value, or null when the issue + carries none. code: type: string enum: @@ -749,7 +767,8 @@ paths: - type_mismatch - unknown_value expected: - nullable: true + description: JSON-ish value, or null when the issue + carries none. message: type: string path: @@ -1186,6 +1205,12 @@ paths: tags: - Strategies components: + securitySchemes: + BearerAuth: + description: Bearer token authentication for API access. + type: http + scheme: bearer + bearerFormat: JWT schemas: RefinedSelectorKey: description: Root keys a client-authored config fragment may contain. diff --git a/spec/html2rss/web/api/v1_spec.rb b/spec/html2rss/web/api/v1_spec.rb index 1d937f4d..ba4f48d8 100644 --- a/spec/html2rss/web/api/v1_spec.rb +++ b/spec/html2rss/web/api/v1_spec.rb @@ -887,6 +887,16 @@ def post_validate(payload) error_message: nil, failure_kind: nil, rss: 'do-not-leak' ) end + let(:failed_preview_result) do + Html2rss::Test::Result.new( + success: false, item_count: 0, sample_items: [], channel_title: nil, + channel_url: feed_url, strategy_used: :auto, duration_seconds: 0.1, validation_issues: [], + error_message: 'no items', failure_kind: Html2rss::Test::FailureKind.new(name: :min_items), rss: nil, + quality_report: Html2rss::Test::QualityReport.new( + warnings: [:short_titles], metrics: { 'short_title_count' => 2 }, native_feed: nil, defer_reason: nil + ) + ) + end before do allow(Html2rss).to receive(:test).and_return(preview_result) @@ -904,6 +914,17 @@ def post_validate(payload) expect(json.dig('data', 'item_count')).to eq(2) end + it 'reports the failure kind and quality report when extraction comes up empty', :aggregate_failures do + allow(Html2rss).to receive(:test).and_return(failed_preview_result) + header 'Authorization', "Bearer #{admin_token}" + header 'Content-Type', 'application/json' + post '/api/v1/feeds/preview', { url: feed_url, selectors: { items: { selector: 'article' } } }.to_json + + json = expect_success_response(last_response) + expect(json.dig('data', 'failure_kind')).to eq('min_items') + expect(json.dig('data', 'quality_report', 'warnings')).to eq(['short_titles']) + end + it 'previews through the token generator config' do header 'Authorization', "Bearer #{admin_token}" header 'Content-Type', 'application/json' diff --git a/spec/support/openapi.rb b/spec/support/openapi.rb index 27bc805b..e0e21d04 100644 --- a/spec/support/openapi.rb +++ b/spec/support/openapi.rb @@ -8,7 +8,7 @@ module OpenapiRuntimeContract # @param spec [Hash] # @return [void] def stamp(spec) - walk(spec) { |node| stamp_issue_code(node) } + walk(spec) { |node| stamp_issue_schema(node) } publish_allowed_keys(spec) end @@ -28,11 +28,18 @@ def walk(node, &) # @param node [Hash] # @return [void] - def stamp_issue_code(node) + def stamp_issue_schema(node) properties = node['properties'] return unless properties.is_a?(Hash) && issue_schema?(properties) - code = properties['code'] + stamp_issue_code(properties['code']) + stamp_open_value(properties['expected']) + stamp_open_value(properties['actual']) + end + + # @param code [Object] + # @return [void] + def stamp_issue_code(code) return unless code.is_a?(Hash) code['type'] = 'string' @@ -40,6 +47,18 @@ def stamp_issue_code(node) code['enum'] = Html2rss::Config::CODES.map(&:to_s).sort end + # ValidationIssue carries expected/actual as JSON-ish or nil, which OAS 3.0 + # cannot type; a bare `nullable` without a sibling `type` is invalid there. + # + # @param value [Object] + # @return [void] + def stamp_open_value(value) + return unless value.is_a?(Hash) + + value.delete('nullable') + value['description'] = 'JSON-ish value, or null when the issue carries none.' + end + # @param properties [Hash] # @return [Boolean] def issue_schema?(properties) @@ -49,13 +68,24 @@ def issue_schema?(properties) # @param spec [Hash] # @return [void] def publish_allowed_keys(spec) - schemas = (spec['components'] ||= {})['schemas'] ||= {} + schemas = section(section(spec, :components), :schemas) schemas['RefinedSelectorKey'] = { 'description' => 'Root keys a client-authored config fragment may contain.', 'enum' => Html2rss::Web::RefinedSelectors::ALLOWED_KEYS.map(&:to_s).sort, 'type' => 'string' } end + + # rspec-openapi seeds `components` symbol-keyed (`securitySchemes`), so a + # string-keyed write would shadow it and drop BearerAuth on dump. + # + # @param node [Hash] + # @param name [Symbol] + # @return [Hash] + def section(node, name) + key = node.key?(name.to_s) ? name.to_s : name + node[key] ||= {} + end end if ENV['OPENAPI'] From a2354df19d1971468502d03e101635b52fc6aa54 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 19 Sep 2026 22:45:08 +0200 Subject: [PATCH 010/108] fix(api): require access token on studio validate 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`. --- app/web/api/v1/validate_config.rb | 6 ++-- frontend/src/api/generated/sdk.gen.ts | 5 +-- frontend/src/api/generated/types.gen.ts | 17 ++++++++++ public/openapi.yaml | 42 +++++++++++++++++++++++++ spec/html2rss/web/api/v1_spec.rb | 20 +++++++++++- 5 files changed, 85 insertions(+), 5 deletions(-) diff --git a/app/web/api/v1/validate_config.rb b/app/web/api/v1/validate_config.rb index c932c371..d1a3ef38 100644 --- a/app/web/api/v1/validate_config.rb +++ b/app/web/api/v1/validate_config.rb @@ -20,7 +20,7 @@ class << self # @param request [Rack::Request] # @return [Hash{Symbol=>Object}] def call(request) - ensure_enabled! + ensure_allowed!(request) body = json_object(request) report, selectors = assess(body) Response.success(response: request.response, data: payload(report, selectors)) @@ -28,9 +28,11 @@ def call(request) private + # @param request [Rack::Request] # @return [void] - def ensure_enabled! + def ensure_allowed!(request) raise ForbiddenError, 'Studio is disabled' unless Flags.studio_enabled? + raise UnauthorizedError, 'Authentication required' unless Auth.authenticate(request) end # @param request [Rack::Request] diff --git a/frontend/src/api/generated/sdk.gen.ts b/frontend/src/api/generated/sdk.gen.ts index ca91cce0..53308194 100644 --- a/frontend/src/api/generated/sdk.gen.ts +++ b/frontend/src/api/generated/sdk.gen.ts @@ -97,12 +97,13 @@ export const suggestSelectors = (options: * * Validate a refined config */ -export const validateFeedConfig = (options?: Options): RequestResult => (options?.client ?? client).post({ +export const validateFeedConfig = (options: Options): RequestResult => (options.client ?? client).post({ + security: [{ scheme: 'bearer', type: 'http' }], url: '/feeds/validate', ...options, headers: { 'Content-Type': 'application/json', - ...options?.headers + ...options.headers } }); diff --git a/frontend/src/api/generated/types.gen.ts b/frontend/src/api/generated/types.gen.ts index b3ba6927..b2ea3071 100644 --- a/frontend/src/api/generated/types.gen.ts +++ b/frontend/src/api/generated/types.gen.ts @@ -347,6 +347,9 @@ export type ValidateFeedConfigData = { }; yaml?: string; }; + headers: { + Authorization: string; + }; path?: never; query?: never; url: '/feeds/validate'; @@ -367,6 +370,20 @@ export type ValidateFeedConfigErrors = { }; success: boolean; }; + /** + * returns 401 with UNAUTHORIZED error payload + */ + 401: { + error: { + code: string; + kind: string; + message: string; + next_action: string; + retry_action: string; + retryable: boolean; + }; + success: boolean; + }; }; export type ValidateFeedConfigError = ValidateFeedConfigErrors[keyof ValidateFeedConfigErrors]; diff --git a/public/openapi.yaml b/public/openapi.yaml index 37afe559..1cd05169 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -718,6 +718,12 @@ paths: post: description: Validate a refined config operationId: validateFeedConfig + parameters: + - in: header + name: Authorization + required: true + schema: + type: string requestBody: content: application/json: @@ -854,6 +860,42 @@ paths: - error type: object description: rejects channel so the token url cannot be replaced + '401': + content: + application/json: + schema: + properties: + error: + properties: + code: + type: string + kind: + type: string + message: + type: string + next_action: + type: string + retry_action: + type: string + retryable: + type: boolean + required: + - code + - message + - kind + - retryable + - next_action + - retry_action + type: object + success: + type: boolean + required: + - success + - error + type: object + description: returns 401 with UNAUTHORIZED error payload + security: + - BearerAuth: [] summary: Validate a refined config tags: - Studio diff --git a/spec/html2rss/web/api/v1_spec.rb b/spec/html2rss/web/api/v1_spec.rb index ba4f48d8..2bb97936 100644 --- a/spec/html2rss/web/api/v1_spec.rb +++ b/spec/html2rss/web/api/v1_spec.rb @@ -844,13 +844,31 @@ def relative_feed_link_header(token) describe 'POST /api/v1/feeds/validate', openapi: { summary: 'Validate a refined config', operation_id: 'validateFeedConfig', - tags: ['Studio'] + tags: ['Studio'], + security: [{ 'BearerAuth' => [] }] } do def post_validate(payload) + header 'Authorization', "Bearer #{admin_token}" header 'Content-Type', 'application/json' post '/api/v1/feeds/validate', payload.to_json end + let(:perform_request) do + lambda { + header 'Content-Type', 'application/json' + post '/api/v1/feeds/validate', { selectors: { items: { selector: 'article' } } }.to_json + } + end + + it_behaves_like 'api error contract', { + status: 401, + code: Html2rss::Web::UnauthorizedError::CODE, + kind: 'auth', + retryable: false, + next_action: 'enter_token', + retry_action: 'none' + } + it 'returns a structured report for an allowlisted selectors object', :aggregate_failures do post_validate(selectors: { items: { selector: 'article', enhance: true } }) From e378a2a96310f95c790b55b3b4503f808f4c13f7 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 19 Sep 2026 22:48:28 +0200 Subject: [PATCH 011/108] docs: document STUDIO_ENABLED and cover the disabled path `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. --- .env.example | 6 ++++++ docker-compose.yml | 2 ++ docs/README.md | 3 +++ spec/html2rss/web/api/v1_spec.rb | 17 +++++++++++++++++ 4 files changed, 28 insertions(+) diff --git a/.env.example b/.env.example index 4b6523ef..589bc727 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,12 @@ HTML2RSS_ACCESS_TOKEN= # Keep enabled to allow pasting any webpage URL to generate an RSS feed. AUTO_SOURCE_ENABLED=true +# OPTIONAL: Config refinement studio (off in production unless set to true). +# Exposes POST /api/v1/feeds/validate, /preview and /suggest_selectors. All three +# require HTML2RSS_ACCESS_TOKEN; preview and suggest_selectors also fetch the +# submitted URL live and need AUTO_SOURCE_ENABLED=true. +# STUDIO_ENABLED=false + # COMPANION SCRAPER: # URL of the companion Botasaurus scraper API service in docker-compose. BOTASAURUS_SCRAPER_URL=http://botasaurus:4010 diff --git a/docker-compose.yml b/docker-compose.yml index 2590d937..703d6356 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,8 @@ services: HTML2RSS_SECRET_KEY: ${HTML2RSS_SECRET_KEY:?set HTML2RSS_SECRET_KEY in .env (generate with `openssl rand -hex 32`)} HTML2RSS_ACCESS_TOKEN: ${HTML2RSS_ACCESS_TOKEN:?set HTML2RSS_ACCESS_TOKEN in .env (generate with `openssl rand -hex 32`)} AUTO_SOURCE_ENABLED: ${AUTO_SOURCE_ENABLED:-true} + # Config refinement studio (validate/preview/suggest_selectors); token-gated, off by default. + STUDIO_ENABLED: ${STUDIO_ENABLED:-false} HEALTH_CHECK_TOKEN: ${HEALTH_CHECK_TOKEN:-} # Web Sentry (Project A): SENTRY_DSN; logs opt-in via SENTRY_ENABLE_LOGS=true SENTRY_DSN: ${SENTRY_DSN:-} diff --git a/docs/README.md b/docs/README.md index fb3f4cd2..32439dcd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -192,6 +192,7 @@ Managed flags and environment keys: | Name | Env key | Type | Default | | --------------------------------- | --------------------------------- | -------------- | ---------------------------------------- | | `auto_source_enabled` | `AUTO_SOURCE_ENABLED` | boolean | `true` in development/test, else `false` | +| `studio_enabled` | `STUDIO_ENABLED` | boolean | `true` in development/test, else `false` | | `health_check_token` | `HEALTH_CHECK_TOKEN` | string | `nil` | | `build_tag` | `BUILD_TAG` | string | `unknown` outside production | | `git_sha` | `GIT_SHA` | string | `unknown` outside production | @@ -206,6 +207,8 @@ Rules: - `BUILD_TAG` and `GIT_SHA` are required in production so startup logs can identify the deployed build. - Add or change flags in code, tests, and this table together. +`STUDIO_ENABLED=true` exposes the config refinement studio: `POST /api/v1/feeds/validate`, `POST /api/v1/feeds/preview`, and `POST /api/v1/feeds/suggest_selectors`. All three require a valid access token and return `403 Studio is disabled` when the flag is off. `preview` and `suggest_selectors` additionally need `AUTO_SOURCE_ENABLED=true` because they fetch the caller's URL live, and they only accept URLs the caller's account is allowed to reach. Leave the flag off in production unless you intend to let token holders run live extraction against arbitrary allowed URLs. + --- ## Observability Contract diff --git a/spec/html2rss/web/api/v1_spec.rb b/spec/html2rss/web/api/v1_spec.rb index 2bb97936..2bc274b4 100644 --- a/spec/html2rss/web/api/v1_spec.rb +++ b/spec/html2rss/web/api/v1_spec.rb @@ -984,4 +984,21 @@ def post_validate(payload) expect(json.dig('data', 'enhance')).to be(false) end end + + describe 'studio POSTs with STUDIO_ENABLED=false', openapi: false do + around do |example| + ClimateControl.modify(STUDIO_ENABLED: 'false') { example.run } + end + + Html2rss::Web::Routes::ApiV1::FeedRoutes::STUDIO_POSTS.each_key do |path| + it "returns 403 for POST /api/v1/feeds/#{path}", :aggregate_failures do + header 'Authorization', "Bearer #{admin_token}" + header 'Content-Type', 'application/json' + post "/api/v1/feeds/#{path}", { url: feed_url, selectors: { items: { selector: 'article' } } }.to_json + + expect(last_response.status).to eq(403) + expect(JSON.parse(last_response.body).dig('error', 'message')).to eq('Studio is disabled') + end + end + end end From 9c85f70ead5fe391ed1c80a6e546f8736634d66e Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 19 Sep 2026 23:04:59 +0200 Subject: [PATCH 012/108] feat(frontend): add studio transport and refinement state hook 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. --- frontend/src/__tests__/mocks/server.ts | 33 ++ frontend/src/__tests__/useStudio.test.ts | 288 ++++++++++++++ frontend/src/studio/selectorDraft.ts | 476 +++++++++++++++++++++++ frontend/src/studio/studioService.ts | 274 +++++++++++++ frontend/src/studio/useStudio.ts | 283 ++++++++++++++ 5 files changed, 1354 insertions(+) create mode 100644 frontend/src/__tests__/useStudio.test.ts create mode 100644 frontend/src/studio/selectorDraft.ts create mode 100644 frontend/src/studio/studioService.ts create mode 100644 frontend/src/studio/useStudio.ts diff --git a/frontend/src/__tests__/mocks/server.ts b/frontend/src/__tests__/mocks/server.ts index 87a10337..632d098d 100644 --- a/frontend/src/__tests__/mocks/server.ts +++ b/frontend/src/__tests__/mocks/server.ts @@ -30,6 +30,39 @@ export const server = setupServer( data: { configs: [] }, meta: { total: 0, catalog_version: 2, starters: [] }, }); + }), + http.post('/api/v1/feeds/validate', () => { + return HttpResponse.json({ + success: true, + data: { + report: { success: true, issues: [] }, + selectors: { items: { selector: 'article', enhance: false } }, + yaml: 'selectors:\n items:\n selector: article\n enhance: false\n', + }, + }); + }), + http.post('/api/v1/feeds/preview', () => { + return HttpResponse.json({ + success: true, + data: { + item_count: 0, + sample_items: [], + quality_report: { warnings: [], metrics: { short_title_count: 0 } }, + failure_kind: '', + validation_issues: [], + }, + }); + }), + http.post('/api/v1/feeds/suggest_selectors', () => { + return HttpResponse.json({ + success: true, + data: { + selectors: { items: { selector: 'article', enhance: true } }, + segment_strategy: 'list', + admission_drops: { chrome: 0 }, + enhance: true, + }, + }); }) ); diff --git a/frontend/src/__tests__/useStudio.test.ts b/frontend/src/__tests__/useStudio.test.ts new file mode 100644 index 00000000..6643ee35 --- /dev/null +++ b/frontend/src/__tests__/useStudio.test.ts @@ -0,0 +1,288 @@ +import { describe, it, expect, beforeEach, afterEach, vi, type SpyInstance } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/preact'; +import { draftFromSelectors, selectorsFromDraft } from '../studio/selectorDraft'; +import { STUDIO_VALIDATE_DELAY_MS, useStudio } from '../studio/useStudio'; + +const pageUrl = 'https://example.com/articles'; +const token = 'studio-token'; +const wireNull: unknown = JSON.parse('null'); + +function validateBody(overrides: Record = {}) { + return { + success: true, + data: { + report: { success: true, issues: [] }, + selectors: { items: { selector: 'article', enhance: false } }, + yaml: 'selectors:\n items:\n selector: article\n', + ...overrides, + }, + }; +} + +describe('selector draft', () => { + it('keeps attribute selectors, post-processors, and untouched keys', () => { + const draft = draftFromSelectors({ + items: { selector: 'article', enhance: true, order: 'reverse' }, + title: { + selector: 'h2', + extractor: 'text', + post_process: [{ name: 'gsub', pattern: 'a', replacement: 'b' }], + }, + guid: ['title'], + }); + + expect(draft?.itemsSelector).toBe('article'); + expect(draft?.enhance).toBe(true); + expect(draft?.itemsPreserved).toEqual({ order: 'reverse' }); + expect(draft?.attributes[0]).toMatchObject({ + name: 'title', + selector: 'h2', + extractor: 'text', + }); + expect(draft?.attributes[0]?.postProcessors[0]).toMatchObject({ + name: 'gsub', + pattern: 'a', + replacement: 'b', + }); + + expect(draft).toBeDefined(); + if (!draft) return; + + expect(selectorsFromDraft(draft)).toEqual({ + items: { selector: 'article', enhance: true, order: 'reverse' }, + title: { + selector: 'h2', + extractor: 'text', + post_process: [{ name: 'gsub', pattern: 'a', replacement: 'b' }], + }, + guid: ['title'], + }); + }); +}); + +describe('useStudio', () => { + let fetchMock: SpyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + fetchMock = vi.spyOn(globalThis, 'fetch'); + }); + + afterEach(() => { + fetchMock.mockRestore(); + vi.useRealTimers(); + }); + + it('waits 300ms, sends the bearer token, and unwraps a valid report', async () => { + vi.useFakeTimers(); + fetchMock.mockResolvedValue(Response.json(validateBody())); + + const { result } = renderHook(() => useStudio({ url: pageUrl, token })); + expect(fetchMock).not.toHaveBeenCalled(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(STUDIO_VALIDATE_DELAY_MS - 1); + }); + expect(fetchMock).not.toHaveBeenCalled(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/v1/feeds/validate'); + const init = fetchMock.mock.calls[0]?.[1]; + expect(init?.headers).toMatchObject({ Authorization: `Bearer ${token}` }); + expect(JSON.parse(String(init?.body))).toEqual({ + selectors: { items: { enhance: false } }, + }); + + const state = result.current.state; + expect(state.phase).toBe('editing'); + if (state.phase !== 'editing') return; + expect(state.validated).toBe(true); + expect(state.yaml).toContain('article'); + expect(state.draft.itemsSelector).toBe('article'); + }); + + it('keeps returned issue codes on the invalid phase', async () => { + fetchMock.mockResolvedValue( + Response.json( + validateBody({ + report: { + success: false, + issues: [ + { + path: ['selectors', 'items', 'selector'], + code: 'missing_key', + message: 'is missing', + }, + ], + }, + selectors: { items: { enhance: false } }, + yaml: wireNull, + }) + ) + ); + + const { result } = renderHook(() => useStudio({ url: pageUrl, token, validateDelayMs: 0 })); + await waitFor(() => expect(result.current.state.phase).toBe('invalid')); + + const state = result.current.state; + if (state.phase !== 'invalid') return; + expect(state.issues).toEqual([ + { + path: ['selectors', 'items', 'selector'], + code: 'missing_key', + message: 'is missing', + }, + ]); + }); + + it('freezes builder hydration while yaml does not parse', async () => { + fetchMock.mockResolvedValueOnce(Response.json(validateBody())).mockResolvedValueOnce( + Response.json( + validateBody({ + report: { + success: false, + issues: [ + { + path: [], + code: 'parse', + message: 'could not parse', + }, + ], + }, + selectors: wireNull, + yaml: wireNull, + }) + ) + ); + + const { result } = renderHook(() => useStudio({ url: pageUrl, token, validateDelayMs: 0 })); + await waitFor(() => { + expect(result.current.state.phase).toBe('editing'); + expect(result.current.state.draft.itemsSelector).toBe('article'); + }); + + act(() => { + result.current.setYaml(':'); + }); + + await waitFor(() => expect(result.current.state.phase).toBe('invalid')); + const state = result.current.state; + if (state.phase !== 'invalid') return; + expect(state.issues[0]?.code).toBe('parse'); + expect(state.draft.itemsSelector).toBe('article'); + expect(state.yaml).toBe(':'); + expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body))).toEqual({ yaml: ':' }); + }); + + it('moves to previewed after a preview response', async () => { + fetchMock.mockImplementation((input: RequestInfo | URL) => { + const path = String(input); + if (path.endsWith('/feeds/preview')) { + return Promise.resolve( + Response.json({ + success: true, + data: { + item_count: 1, + sample_items: [{ title: 'Sample' }], + quality_report: { warnings: ['short title'], metrics: { short_title_count: 1 } }, + failure_kind: wireNull, + validation_issues: [], + }, + }) + ); + } + + return Promise.resolve(Response.json(validateBody())); + }); + + const { result } = renderHook(() => useStudio({ url: pageUrl, token, validateDelayMs: 0 })); + await waitFor(() => { + const state = result.current.state; + expect(state.phase === 'editing' && state.validated).toBe(true); + }); + + act(() => { + result.current.requestPreview(); + }); + + await waitFor(() => expect(result.current.state.phase).toBe('previewed')); + const state = result.current.state; + if (state.phase !== 'previewed') return; + expect(state.preview).toEqual({ + itemCount: 1, + titles: ['Sample'], + warnings: ['short title'], + failureKind: undefined, + }); + }); + + it('applies one capture suggestion onto the items selector', async () => { + fetchMock.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input).endsWith('/feeds/suggest_selectors')) { + return Response.json({ + success: true, + data: { + selectors: { items: { selector: '.post', enhance: true } }, + segment_strategy: 'list', + admission_drops: { chrome: 2 }, + enhance: true, + }, + }); + } + + const parsed: unknown = JSON.parse(String(init?.body)); + const selectors = + typeof parsed === 'object' && parsed && 'selectors' in parsed ? parsed.selectors : undefined; + return Response.json( + validateBody({ + selectors: selectors ?? { items: { selector: 'article', enhance: false } }, + }) + ); + }); + + const { result } = renderHook(() => useStudio({ url: pageUrl, token, validateDelayMs: 0 })); + await waitFor(() => expect(result.current.state.phase).toBe('editing')); + + act(() => { + result.current.requestSuggestion(); + }); + + await waitFor(() => expect(result.current.state.draft.itemsSelector).toBe('.post')); + expect(result.current.state.draft.enhance).toBe(true); + expect(result.current.suggestion).toEqual({ + itemsSelector: '.post', + enhance: true, + segmentStrategy: 'list', + chromeDrops: 2, + }); + }); + + it('fails closed when validate is unauthorized', async () => { + fetchMock.mockResolvedValue( + Response.json( + { + success: false, + error: { + code: 'UNAUTHORIZED', + message: 'Authentication required', + kind: 'auth', + retryable: false, + next_action: 'enter_token', + retry_action: 'none', + }, + }, + { status: 401 } + ) + ); + + const { result } = renderHook(() => useStudio({ url: pageUrl, token, validateDelayMs: 0 })); + await waitFor(() => expect(result.current.state.phase).toBe('failed')); + const state = result.current.state; + if (state.phase !== 'failed') return; + expect(state.message).toBe('Authentication required'); + }); +}); diff --git a/frontend/src/studio/selectorDraft.ts b/frontend/src/studio/selectorDraft.ts new file mode 100644 index 00000000..25c741c7 --- /dev/null +++ b/frontend/src/studio/selectorDraft.ts @@ -0,0 +1,476 @@ +/** + * Visual-builder draft for an allowlisted selectors document. + * + * The generated client types `selectors.items` only, because the published + * examples collapse `patternProperties`. This module owns the gem selector + * document the builder edits. It is not a second copy of the issue schema. + */ + +function closed(values: T): T { + return values; +} + +export const EXTRACTOR_NAMES = closed(['text', 'attribute', 'href', 'html', 'static']); +export type ExtractorName = (typeof EXTRACTOR_NAMES)[number]; + +export const POST_PROCESSOR_NAMES = closed([ + 'gsub', + 'markdown_to_html', + 'parse_time', + 'parse_uri', + 'sanitize_html', + 'substring', + 'template', +]); +export type PostProcessorName = (typeof POST_PROCESSOR_NAMES)[number]; + +export type PostProcessorDraft = + | { readonly id: string; readonly name: 'gsub'; readonly pattern: string; readonly replacement: string } + | { readonly id: string; readonly name: 'substring'; readonly start: string; readonly end: string } + | { readonly id: string; readonly name: 'template'; readonly template: string } + | { + readonly id: string; + readonly name: 'markdown_to_html' | 'parse_time' | 'parse_uri' | 'sanitize_html'; + } + | { readonly id: string; readonly name: 'unrecognized'; readonly raw: Readonly> }; + +export interface AttributeDraft { + readonly id: string; + readonly name: string; + readonly selector: string; + readonly extractor: ExtractorName; + readonly attributeName: string; + readonly staticValue: string; + readonly postProcessors: readonly PostProcessorDraft[]; + readonly preserved: Readonly>; +} + +export interface StudioDraft { + readonly itemsSelector: string; + readonly enhance: boolean; + readonly attributes: readonly AttributeDraft[]; + readonly preserved: Readonly>; + readonly itemsPreserved: Readonly>; +} + +export interface AttributePatch { + readonly name?: string; + readonly selector?: string; + readonly extractor?: ExtractorName; + readonly attributeName?: string; + readonly staticValue?: string; +} + +export function isPlainRecord(value: unknown): value is Record { + return ( + typeof value === 'object' && value instanceof Object && Object.getPrototypeOf(value) === Object.prototype + ); +} + +const mintDraftId = (() => { + let nextDraftId = 0; + return (prefix: string): string => { + nextDraftId += 1; + return `${prefix}-${nextDraftId}`; + }; +})(); + +export function emptyDraft(): StudioDraft { + return { + itemsSelector: '', + enhance: false, + attributes: [], + preserved: {}, + itemsPreserved: {}, + }; +} + +export function selectorsFromDraft(draft: StudioDraft): Record { + const names = draft.attributes.map((attribute) => attribute.name.trim()).filter((name) => name.length > 0); + const selectors = withoutKeys(draft.preserved, ['items', ...names]); + const items: Record = { ...draft.itemsPreserved, enhance: draft.enhance }; + if (draft.itemsSelector.length > 0) items.selector = draft.itemsSelector; + selectors.items = items; + + for (const attribute of draft.attributes) { + const name = attribute.name.trim(); + if (!name) continue; + selectors[name] = attributeToWire(attribute); + } + + return selectors; +} + +export function draftFromSelectors(value: unknown): StudioDraft | undefined { + if (!isPlainRecord(value)) return; + + const items = isPlainRecord(value.items) ? value.items : undefined; + const attributes: AttributeDraft[] = []; + const preserved: Record = {}; + + for (const [key, nested] of Object.entries(value)) { + if (key === 'items') continue; + const attribute = parseAttribute(key, nested); + if (attribute) { + attributes.push(attribute); + } else { + preserved[key] = nested; + } + } + + return { + itemsSelector: typeof items?.selector === 'string' ? items.selector : '', + enhance: items?.enhance === true, + attributes, + preserved, + itemsPreserved: items ? withoutKeys(items, ['selector', 'enhance']) : {}, + }; +} + +export function withItemsSelector(draft: StudioDraft, itemsSelector: string): StudioDraft { + return { ...draft, itemsSelector }; +} + +export function withEnhance(draft: StudioDraft, isEnhanced: boolean): StudioDraft { + return { ...draft, enhance: isEnhanced }; +} + +export function addAttribute(draft: StudioDraft): StudioDraft { + return { ...draft, attributes: [...draft.attributes, blankAttribute()] }; +} + +export function updateAttribute(draft: StudioDraft, attributeId: string, patch: AttributePatch): StudioDraft { + return mapAttributes(draft, (attribute) => { + if (attribute.id !== attributeId) return attribute; + return { + ...attribute, + name: patch.name ?? attribute.name, + selector: patch.selector ?? attribute.selector, + extractor: patch.extractor ?? attribute.extractor, + attributeName: patch.attributeName ?? attribute.attributeName, + staticValue: patch.staticValue ?? attribute.staticValue, + }; + }); +} + +export function removeAttribute(draft: StudioDraft, attributeId: string): StudioDraft { + return { + ...draft, + attributes: draft.attributes.filter((attribute) => attribute.id !== attributeId), + }; +} + +export function addPostProcessor(draft: StudioDraft, attributeId: string): StudioDraft { + return mapAttributes(draft, (attribute) => { + if (attribute.id !== attributeId) return attribute; + return { + ...attribute, + postProcessors: [ + ...attribute.postProcessors, + blankProcessor('sanitize_html', mintDraftId('processor')), + ], + }; + }); +} + +export function removePostProcessor( + draft: StudioDraft, + attributeId: string, + processorId: string +): StudioDraft { + return mapAttributes(draft, (attribute) => { + if (attribute.id !== attributeId) return attribute; + return { + ...attribute, + postProcessors: attribute.postProcessors.filter((processor) => processor.id !== processorId), + }; + }); +} + +export function renamePostProcessor( + draft: StudioDraft, + attributeId: string, + processorId: string, + name: PostProcessorName +): StudioDraft { + return mapAttributes(draft, (attribute) => { + if (attribute.id !== attributeId) return attribute; + return { + ...attribute, + postProcessors: attribute.postProcessors.map((processor) => + processor.id === processorId ? blankProcessor(name, processor.id) : processor + ), + }; + }); +} + +export type PostProcessorField = 'pattern' | 'replacement' | 'start' | 'end' | 'template'; + +export function updatePostProcessorField( + draft: StudioDraft, + attributeId: string, + processorId: string, + field: PostProcessorField, + value: string +): StudioDraft { + return mapAttributes(draft, (attribute) => { + if (attribute.id !== attributeId) return attribute; + return { + ...attribute, + postProcessors: attribute.postProcessors.map((processor) => + processor.id === processorId ? applyProcessorField(processor, field, value) : processor + ), + }; + }); +} + +export function applyItemsSuggestion( + draft: StudioDraft, + itemsSelector: string, + isEnhanced: boolean +): StudioDraft { + return { ...draft, itemsSelector, enhance: isEnhanced }; +} + +export function reuseDraftIds( + previous: StudioDraft, + incoming: StudioDraft, + shouldKeepBlankAttributes: boolean +): StudioDraft { + const previousByName = new Map(previous.attributes.map((attribute) => [attribute.name, attribute])); + const attributes = incoming.attributes.map((attribute) => + reuseAttributeId(attribute, previousByName.get(attribute.name)) + ); + const blanks = shouldKeepBlankAttributes + ? previous.attributes.filter((attribute) => attribute.name.trim() === '') + : []; + + return { ...incoming, attributes: [...attributes, ...blanks] }; +} + +function reuseAttributeId(attribute: AttributeDraft, prior: AttributeDraft | undefined): AttributeDraft { + if (!prior) return attribute; + return { + ...attribute, + id: prior.id, + postProcessors: attribute.postProcessors.map((processor, index) => + withProcessorId(processor, prior.postProcessors[index]?.id ?? processor.id) + ), + }; +} + +function withProcessorId(processor: PostProcessorDraft, id: string): PostProcessorDraft { + switch (processor.name) { + case 'gsub': + case 'substring': + case 'template': + case 'markdown_to_html': + case 'parse_time': + case 'parse_uri': + case 'sanitize_html': + case 'unrecognized': { + return { ...processor, id }; + } + } +} + +function applyProcessorField( + processor: PostProcessorDraft, + field: PostProcessorField, + value: string +): PostProcessorDraft { + switch (processor.name) { + case 'gsub': { + if (field === 'pattern') return { ...processor, pattern: value }; + if (field === 'replacement') return { ...processor, replacement: value }; + return processor; + } + case 'substring': { + if (field === 'start') return { ...processor, start: value }; + if (field === 'end') return { ...processor, end: value }; + return processor; + } + case 'template': { + if (field === 'template') return { ...processor, template: value }; + return processor; + } + default: { + return processor; + } + } +} + +function blankAttribute(): AttributeDraft { + return { + id: mintDraftId('attribute'), + name: '', + selector: '', + extractor: 'text', + attributeName: '', + staticValue: '', + postProcessors: [], + preserved: {}, + }; +} + +function blankProcessor(name: PostProcessorName, id: string): PostProcessorDraft { + switch (name) { + case 'gsub': { + return { id, name, pattern: '', replacement: '' }; + } + case 'substring': { + return { id, name, start: '', end: '' }; + } + case 'template': { + return { id, name, template: '' }; + } + case 'markdown_to_html': + case 'parse_time': + case 'parse_uri': + case 'sanitize_html': { + return { id, name }; + } + } +} + +function mapAttributes( + draft: StudioDraft, + update: (attribute: AttributeDraft) => AttributeDraft +): StudioDraft { + return { ...draft, attributes: draft.attributes.map((attribute) => update(attribute)) }; +} + +function attributeToWire(attribute: AttributeDraft): Record { + const wire: Record = { + ...attribute.preserved, + selector: attribute.selector, + extractor: attribute.extractor, + }; + if (attribute.extractor === 'attribute') { + wire.attribute = attribute.attributeName; + } else if (attribute.extractor === 'static') { + wire.static = attribute.staticValue; + } + if (attribute.postProcessors.length > 0) { + wire.post_process = attribute.postProcessors.map((processor) => processorToWire(processor)); + } + return wire; +} + +function processorToWire(processor: PostProcessorDraft): Record { + switch (processor.name) { + case 'gsub': { + return { name: 'gsub', pattern: processor.pattern, replacement: processor.replacement }; + } + case 'substring': { + const wire: Record = { name: 'substring' }; + const start = integerField(processor.start); + if (start !== undefined) wire.start = start; + const end = integerField(processor.end); + if (end !== undefined) wire.end = end; + return wire; + } + case 'template': { + return { name: 'template', string: processor.template }; + } + case 'markdown_to_html': + case 'parse_time': + case 'parse_uri': + case 'sanitize_html': { + return { name: processor.name }; + } + case 'unrecognized': { + return { ...processor.raw }; + } + } +} + +function parseAttribute(name: string, value: unknown): AttributeDraft | undefined { + if (!isPlainRecord(value)) return; + const extractor = knownExtractor(value.extractor); + if (value.extractor !== undefined && !extractor) return; + if (value.post_process !== undefined && !Array.isArray(value.post_process)) return; + + const postProcessors: PostProcessorDraft[] = []; + if (Array.isArray(value.post_process)) { + for (const item of value.post_process) { + const processor = parseProcessor(item); + if (!processor) return; + postProcessors.push(processor); + } + } + + return { + id: mintDraftId('attribute'), + name, + selector: typeof value.selector === 'string' ? value.selector : '', + extractor: extractor ?? 'text', + attributeName: typeof value.attribute === 'string' ? value.attribute : '', + staticValue: typeof value.static === 'string' ? value.static : '', + postProcessors, + preserved: withoutKeys(value, ['selector', 'extractor', 'attribute', 'static', 'post_process']), + }; +} + +function parseProcessor(value: unknown): PostProcessorDraft | undefined { + if (!isPlainRecord(value) || typeof value.name !== 'string') return; + const id = mintDraftId('processor'); + switch (value.name) { + case 'gsub': { + if (typeof value.pattern !== 'string' || typeof value.replacement !== 'string') { + return { id, name: 'unrecognized', raw: { ...value } }; + } + return { id, name: 'gsub', pattern: value.pattern, replacement: value.replacement }; + } + case 'substring': { + return { + id, + name: 'substring', + start: integerText(value.start), + end: integerText(value.end), + }; + } + case 'template': { + if (typeof value.string !== 'string') return { id, name: 'unrecognized', raw: { ...value } }; + return { id, name: 'template', template: value.string }; + } + case 'markdown_to_html': + case 'parse_time': + case 'parse_uri': + case 'sanitize_html': { + return { id, name: value.name }; + } + default: { + return { id, name: 'unrecognized', raw: { ...value } }; + } + } +} + +function knownExtractor(value: unknown): ExtractorName | undefined { + if (typeof value !== 'string') return; + for (const name of EXTRACTOR_NAMES) { + if (name === value) return name; + } + return; +} + +function withoutKeys( + value: Readonly>, + keys: readonly string[] +): Record { + const next: Record = {}; + for (const [key, nested] of Object.entries(value)) { + if (!keys.includes(key)) next[key] = nested; + } + return next; +} + +function integerField(value: string): number | undefined { + if (!/^-?\d+$/.test(value)) return; + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? parsed : undefined; +} + +function integerText(value: unknown): string { + return typeof value === 'number' && Number.isSafeInteger(value) ? String(value) : ''; +} diff --git a/frontend/src/studio/studioService.ts b/frontend/src/studio/studioService.ts new file mode 100644 index 00000000..85332cb6 --- /dev/null +++ b/frontend/src/studio/studioService.ts @@ -0,0 +1,274 @@ +/** + * Studio transport. The only place the `{ success, data }` envelope is unwrapped. + * + * Issue codes are the generated client's closed union. Selector documents are + * parsed into {@link StudioDraft} because the published selectors schema is the + * example shape (`items` only), not the allowlisted subtree. + */ +import type { ValidateFeedConfigResponses } from '../api/generated'; +import { COPY } from '../journey/copy'; +import { buildCreateHeaders, isAbortError, resolveApiUrl } from '../feeds/feedsService'; +import { draftFromSelectors, isPlainRecord, selectorsFromDraft, type StudioDraft } from './selectorDraft'; + +type GeneratedIssue = ValidateFeedConfigResponses[200]['data']['report']['issues'][number]; +export type StudioIssueCode = GeneratedIssue['code']; + +export interface StudioIssue { + readonly path: readonly string[]; + readonly code: StudioIssueCode; + readonly message: string; +} + +export interface StudioValidationOutcome { + readonly valid: boolean; + readonly issues: readonly StudioIssue[]; + readonly draft: StudioDraft | undefined; + readonly yaml: string | undefined; +} + +export interface StudioPreview { + readonly itemCount: number; + readonly titles: readonly string[]; + readonly warnings: readonly string[]; + readonly failureKind: string | undefined; +} + +export interface StudioSuggestion { + readonly itemsSelector: string; + readonly enhance: boolean; + readonly segmentStrategy: string; + readonly chromeDrops: number; +} + +export class StudioRequestError extends Error { + constructor(message: string) { + super(message); + this.name = 'StudioRequestError'; + } +} + +function listedIssueCodes( + codes: [StudioIssueCode] extends [T[number]] ? T : never +): T { + return codes; +} + +const STUDIO_ISSUE_CODES = listedIssueCodes([ + 'constraint', + 'invalid_value', + 'missing_key', + 'parse', + 'type_mismatch', + 'unknown_value', +]); + +export function validateStudioSelectors( + token: string, + draft: StudioDraft, + signal?: AbortSignal +): Promise { + return validateStudio(token, { selectors: selectorsFromDraft(draft) }, signal); +} + +export function validateStudioYaml( + token: string, + yaml: string, + signal?: AbortSignal +): Promise { + return validateStudio(token, { yaml }, signal); +} + +export async function previewStudioFeed( + token: string, + url: string, + draft: StudioDraft, + signal?: AbortSignal +): Promise { + const data = await postJson('feeds/preview', token, { url, selectors: selectorsFromDraft(draft) }, signal); + return parsePreview(data); +} + +export async function suggestStudioSelectors( + token: string, + url: string, + itemsSelector: string, + signal?: AbortSignal +): Promise { + const data = await postJson( + 'feeds/suggest_selectors', + token, + { url, items_selector: itemsSelector }, + signal + ); + return parseSuggestion(data); +} + +async function validateStudio( + token: string, + body: { readonly selectors: Record } | { readonly yaml: string }, + signal?: AbortSignal +): Promise { + return parseValidation(await postJson('feeds/validate', token, body, signal)); +} + +async function postJson(path: string, token: string, body: unknown, signal?: AbortSignal): Promise { + let response: Response; + try { + response = await fetch(resolveApiUrl(path), { + method: 'POST', + headers: buildCreateHeaders(token), + body: JSON.stringify(body), + signal, + }); + } catch (error) { + if (isAbortError(error)) throw error; + throw new StudioRequestError(COPY.unableToReachServer); + } + + const payload = await readPayload(response); + if (!response.ok || !isPlainRecord(payload) || payload.success !== true) { + throw new StudioRequestError(errorMessage(payload) ?? COPY.unableToCompleteCreation); + } + + return payload.data; +} + +async function readPayload(response: Response): Promise { + const text = await response.text(); + if (!text.trim()) return; + try { + return JSON.parse(text); + } catch { + return; + } +} + +function errorMessage(payload: unknown): string | undefined { + if (!isPlainRecord(payload) || !isPlainRecord(payload.error)) return; + const message = payload.error.message; + return typeof message === 'string' && message.trim() ? message : undefined; +} + +function parseValidation(data: unknown): StudioValidationOutcome { + if (!isPlainRecord(data) || !isPlainRecord(data.report) || typeof data.report.success !== 'boolean') { + throw new StudioRequestError(COPY.unableToCompleteCreation); + } + + return { + valid: data.report.success, + issues: parseIssues(data.report.issues), + draft: parseSelectorsField(data.selectors), + yaml: parseYamlField(data.yaml), + }; +} + +function parseSelectorsField(value: unknown): StudioDraft | undefined { + if (isAbsent(value)) return; + const draft = draftFromSelectors(value); + if (!draft) throw new StudioRequestError(COPY.unableToCompleteCreation); + return draft; +} + +function parseYamlField(value: unknown): string | undefined { + if (isAbsent(value)) return; + if (typeof value !== 'string') throw new StudioRequestError(COPY.unableToCompleteCreation); + return value; +} + +function parseIssues(value: unknown): readonly StudioIssue[] { + if (!Array.isArray(value)) throw new StudioRequestError(COPY.unableToCompleteCreation); + return value.map((item) => parseIssue(item)); +} + +function parseIssue(value: unknown): StudioIssue { + if (!isPlainRecord(value) || !Array.isArray(value.path) || typeof value.message !== 'string') { + throw new StudioRequestError(COPY.unableToCompleteCreation); + } + + const code = knownIssueCode(value.code); + if (!code) throw new StudioRequestError(COPY.unableToCompleteCreation); + + const path: string[] = []; + for (const segment of value.path) { + if (typeof segment !== 'string') throw new StudioRequestError(COPY.unableToCompleteCreation); + path.push(segment); + } + + return { path, code, message: value.message }; +} + +function knownIssueCode(value: unknown): StudioIssueCode | undefined { + if (typeof value !== 'string') return; + for (const code of STUDIO_ISSUE_CODES) { + if (code === value) return code; + } + return; +} + +function parsePreview(data: unknown): StudioPreview { + if (!isPlainRecord(data) || typeof data.item_count !== 'number' || !Array.isArray(data.sample_items)) { + throw new StudioRequestError(COPY.unableToCompleteCreation); + } + + const titles: string[] = []; + for (const item of data.sample_items) { + if (!isPlainRecord(item) || typeof item.title !== 'string') { + throw new StudioRequestError(COPY.unableToCompleteCreation); + } + titles.push(item.title); + } + + return { + itemCount: data.item_count, + titles, + warnings: parseWarnings(data.quality_report), + failureKind: parseFailureKind(data.failure_kind), + }; +} + +function parseWarnings(value: unknown): readonly string[] { + if (isAbsent(value)) return []; + if (!isPlainRecord(value) || !Array.isArray(value.warnings)) { + throw new StudioRequestError(COPY.unableToCompleteCreation); + } + + const warnings: string[] = []; + for (const warning of value.warnings) { + if (typeof warning !== 'string') throw new StudioRequestError(COPY.unableToCompleteCreation); + warnings.push(warning); + } + return warnings; +} + +function parseFailureKind(value: unknown): string | undefined { + if (isAbsent(value)) return; + if (typeof value !== 'string') throw new StudioRequestError(COPY.unableToCompleteCreation); + return value.length > 0 ? value : undefined; +} + +function parseSuggestion(data: unknown): StudioSuggestion { + if (!isPlainRecord(data)) throw new StudioRequestError(COPY.unableToCompleteCreation); + + const draft = isAbsent(data.selectors) ? undefined : draftFromSelectors(data.selectors); + if (data.selectors !== undefined && !isAbsent(data.selectors) && !draft) { + throw new StudioRequestError(COPY.unableToCompleteCreation); + } + + const enhance = typeof data.enhance === 'boolean' ? data.enhance : (draft?.enhance ?? false); + const segmentStrategy = typeof data.segment_strategy === 'string' ? data.segment_strategy : ''; + const chromeDrops = + isPlainRecord(data.admission_drops) && typeof data.admission_drops.chrome === 'number' + ? data.admission_drops.chrome + : 0; + + return { + itemsSelector: draft?.itemsSelector ?? '', + enhance, + segmentStrategy, + chromeDrops, + }; +} + +function isAbsent(value: unknown): boolean { + return value === undefined || value === null; +} diff --git a/frontend/src/studio/useStudio.ts b/frontend/src/studio/useStudio.ts new file mode 100644 index 00000000..c91c1d4d --- /dev/null +++ b/frontend/src/studio/useStudio.ts @@ -0,0 +1,283 @@ +import { useEffect, useRef, useState } from 'preact/hooks'; +import { isAbortError } from '../feeds/feedsService'; +import { COPY } from '../journey/copy'; +import { + previewStudioFeed, + StudioRequestError, + suggestStudioSelectors, + validateStudioSelectors, + validateStudioYaml, + type StudioIssue, + type StudioPreview, + type StudioSuggestion, + type StudioValidationOutcome, +} from './studioService'; +import { + addAttribute, + addPostProcessor, + applyItemsSuggestion, + emptyDraft, + removeAttribute, + removePostProcessor, + renamePostProcessor, + reuseDraftIds, + selectorsFromDraft, + updateAttribute, + updatePostProcessorField, + withEnhance, + withItemsSelector, + type AttributePatch, + type PostProcessorField, + type PostProcessorName, + type StudioDraft, +} from './selectorDraft'; + +export const STUDIO_VALIDATE_DELAY_MS = 300; + +interface StudioDocument { + readonly draft: StudioDraft; + readonly yaml: string; +} + +export type StudioState = + | (StudioDocument & { readonly phase: 'editing'; readonly validated: boolean }) + | (StudioDocument & { readonly phase: 'validating' }) + | (StudioDocument & { readonly phase: 'invalid'; readonly issues: readonly StudioIssue[] }) + | (StudioDocument & { readonly phase: 'previewing' }) + | (StudioDocument & { readonly phase: 'previewed'; readonly preview: StudioPreview }) + | (StudioDocument & { readonly phase: 'failed'; readonly message: string }); + +type EditSource = 'selectors' | 'yaml'; + +interface UseStudioOptions { + readonly url: string; + readonly token: string; + readonly validateDelayMs?: number; +} + +export function useStudio({ url, token, validateDelayMs = STUDIO_VALIDATE_DELAY_MS }: UseStudioOptions) { + const [state, setState] = useState({ + phase: 'editing', + draft: emptyDraft(), + yaml: '', + validated: false, + }); + const [suggestion, setSuggestion] = useState(undefined); + const [documentRevision, setDocumentRevision] = useState(0); + + const stateReference = useRef(state); + stateReference.current = state; + const editSourceReference = useRef('selectors'); + const requestIdReference = useRef(0); + const previewRequestReference = useRef(0); + const suggestRequestReference = useRef(0); + + useEffect(() => { + const requestId = ++requestIdReference.current; + const controller = new AbortController(); + const timer = setTimeout(() => { + void runValidate(requestId, controller.signal); + }, validateDelayMs); + + return () => { + clearTimeout(timer); + controller.abort(); + }; + }, [documentRevision, token, validateDelayMs]); + + async function runValidate(requestId: number, signal: AbortSignal) { + const source = editSourceReference.current; + const sentDraft = stateReference.current.draft; + const sentYaml = stateReference.current.yaml; + setState((previous) => ({ phase: 'validating', draft: previous.draft, yaml: previous.yaml })); + + try { + const outcome = + source === 'yaml' + ? await validateStudioYaml(token, sentYaml, signal) + : await validateStudioSelectors(token, sentDraft, signal); + if (signal.aborted || requestIdReference.current !== requestId) return; + commitValidation(outcome, source, sentDraft, sentYaml); + } catch (error) { + if (isAbortError(error) || signal.aborted || requestIdReference.current !== requestId) return; + setState((previous) => ({ + phase: 'failed', + draft: previous.draft, + yaml: previous.yaml, + message: error instanceof StudioRequestError ? error.message : COPY.unableToReachServer, + })); + } + } + + function commitValidation( + outcome: StudioValidationOutcome, + source: EditSource, + sentDraft: StudioDraft, + sentYaml: string + ) { + const syntaxError = outcome.issues.some((issue) => issue.code === 'parse'); + let draft = sentDraft; + let yaml = sentYaml; + + if (source === 'yaml') { + if (!syntaxError && outcome.draft) draft = reuseDraftIds(sentDraft, outcome.draft, false); + if (!syntaxError && outcome.yaml !== undefined) yaml = outcome.yaml; + } else if (outcome.draft) { + draft = reuseDraftIds(sentDraft, outcome.draft, true); + if (outcome.yaml !== undefined) yaml = outcome.yaml; + } else if (outcome.yaml !== undefined) { + yaml = outcome.yaml; + } + + if (syntaxError || !outcome.valid) { + setState({ phase: 'invalid', draft, yaml, issues: outcome.issues }); + return; + } + + setState({ phase: 'editing', draft, yaml, validated: true }); + } + + function editSelectors(update: (draft: StudioDraft) => StudioDraft) { + editSourceReference.current = 'selectors'; + previewRequestReference.current += 1; + setState((previous) => ({ + phase: 'editing', + draft: update(previous.draft), + yaml: previous.yaml, + validated: false, + })); + setDocumentRevision((revision) => revision + 1); + } + + const setItemsSelector = (itemsSelector: string) => { + editSelectors((draft) => withItemsSelector(draft, itemsSelector)); + }; + + const setEnhance = (isEnhanced: boolean) => { + editSelectors((draft) => withEnhance(draft, isEnhanced)); + }; + + const appendAttribute = () => { + editSelectors((draft) => addAttribute(draft)); + }; + + const changeAttribute = (attributeId: string, patch: AttributePatch) => { + editSelectors((draft) => updateAttribute(draft, attributeId, patch)); + }; + + const deleteAttribute = (attributeId: string) => { + editSelectors((draft) => removeAttribute(draft, attributeId)); + }; + + const appendPostProcessor = (attributeId: string) => { + editSelectors((draft) => addPostProcessor(draft, attributeId)); + }; + + const deletePostProcessor = (attributeId: string, processorId: string) => { + editSelectors((draft) => removePostProcessor(draft, attributeId, processorId)); + }; + + const changePostProcessorName = (attributeId: string, processorId: string, name: PostProcessorName) => { + editSelectors((draft) => renamePostProcessor(draft, attributeId, processorId, name)); + }; + + const changePostProcessorField = ( + attributeId: string, + processorId: string, + field: PostProcessorField, + value: string + ) => { + editSelectors((draft) => updatePostProcessorField(draft, attributeId, processorId, field, value)); + }; + + const setYaml = (yaml: string) => { + editSourceReference.current = 'yaml'; + previewRequestReference.current += 1; + setState((previous) => ({ + phase: 'editing', + draft: previous.draft, + yaml, + validated: false, + })); + setDocumentRevision((revision) => revision + 1); + }; + + async function requestPreview() { + const requestId = ++previewRequestReference.current; + const controller = new AbortController(); + setState((previous) => ({ phase: 'previewing', draft: previous.draft, yaml: previous.yaml })); + + try { + const preview = await previewStudioFeed(token, url, stateReference.current.draft, controller.signal); + if (previewRequestReference.current !== requestId) return; + setState((previous) => ({ + phase: 'previewed', + draft: previous.draft, + yaml: previous.yaml, + preview, + })); + } catch (error) { + if (isAbortError(error) || previewRequestReference.current !== requestId) return; + setState((previous) => ({ + phase: 'failed', + draft: previous.draft, + yaml: previous.yaml, + message: error instanceof StudioRequestError ? error.message : COPY.unableToReachServer, + })); + } + } + + async function requestSuggestion() { + const requestId = ++suggestRequestReference.current; + try { + const nextSuggestion = await suggestStudioSelectors( + token, + url, + stateReference.current.draft.itemsSelector + ); + if (suggestRequestReference.current !== requestId) return; + setSuggestion(nextSuggestion); + editSelectors((draft) => + applyItemsSuggestion(draft, nextSuggestion.itemsSelector, nextSuggestion.enhance) + ); + } catch (error) { + if (isAbortError(error) || suggestRequestReference.current !== requestId) return; + setState((previous) => ({ + phase: 'failed', + draft: previous.draft, + yaml: previous.yaml, + message: error instanceof StudioRequestError ? error.message : COPY.unableToReachServer, + })); + } + } + + return { + state, + suggestion, + setItemsSelector, + setEnhance, + appendAttribute, + changeAttribute, + deleteAttribute, + appendPostProcessor, + deletePostProcessor, + changePostProcessorName, + changePostProcessorField, + setYaml, + requestPreview: () => { + void requestPreview(); + }, + requestSuggestion: () => { + void requestSuggestion(); + }, + selectorsDocument: () => selectorsFromDraft(stateReference.current.draft), + }; +} + +export function studioIssues(state: StudioState): readonly StudioIssue[] { + return state.phase === 'invalid' ? state.issues : []; +} + +export function hasSyntaxError(state: StudioState): boolean { + return state.phase === 'invalid' && state.issues.some((issue) => issue.code === 'parse'); +} From 54293f0602718f08c9381a4a2fa17d32f5a65f32 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 19 Sep 2026 23:09:48 +0200 Subject: [PATCH 013/108] feat(frontend): add visual selector builder to config studio 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. --- frontend/src/__tests__/ConfigStudio.test.tsx | 119 +++++ frontend/src/__tests__/useSession.test.ts | 5 +- frontend/src/components/App.tsx | 3 +- frontend/src/components/ConfigStudio.tsx | 432 ++++++++++++++++++- frontend/src/feed/useFeedFlow.ts | 4 +- frontend/src/journey/copy.ts | 22 + 6 files changed, 576 insertions(+), 9 deletions(-) create mode 100644 frontend/src/__tests__/ConfigStudio.test.tsx diff --git a/frontend/src/__tests__/ConfigStudio.test.tsx b/frontend/src/__tests__/ConfigStudio.test.tsx new file mode 100644 index 00000000..41b0d7c4 --- /dev/null +++ b/frontend/src/__tests__/ConfigStudio.test.tsx @@ -0,0 +1,119 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/preact'; +import { http, HttpResponse } from 'msw'; +import { server } from './mocks/server'; +import { ConfigStudio } from '../components/ConfigStudio'; +import { COPY } from '../journey/copy'; + +const pageUrl = 'https://example.com/articles'; + +function echoValidate() { + server.use( + http.post('/api/v1/feeds/validate', async ({ request }) => { + const body: unknown = await request.json(); + const selectors = + typeof body === 'object' && body && 'selectors' in body + ? body.selectors + : { items: { selector: 'article', enhance: false } }; + + return HttpResponse.json({ + success: true, + data: { + report: { success: true, issues: [] }, + selectors, + yaml: 'selectors: {}', + }, + }); + }) + ); +} + +describe('ConfigStudio', () => { + it('hydrates the items selector from validate and focuses it', async () => { + render(); + + const items = screen.getByLabelText(COPY.itemsSelector); + expect(screen.getByRole('button', { name: COPY.previewExtraction })).toBeDisabled(); + await waitFor(() => expect(items).toHaveFocus()); + await waitFor(() => expect(items).toHaveValue('article')); + expect(screen.getByLabelText(COPY.enhanceItems)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: COPY.applySuggestion })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: COPY.addAttribute })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: COPY.previewExtraction })).toBeEnabled(); + }); + + it('adds an attribute, chooses an extractor, and adds then removes a post-processor', async () => { + echoValidate(); + render(); + + fireEvent.click(screen.getByRole('button', { name: COPY.addAttribute })); + fireEvent.input(screen.getByLabelText(COPY.attributeName), { target: { value: 'title' } }); + fireEvent.input(screen.getByLabelText(COPY.attributeSelector), { target: { value: 'h2' } }); + fireEvent.change(screen.getByLabelText(COPY.extractor), { target: { value: 'attribute' } }); + + expect(screen.getByLabelText(COPY.htmlAttribute)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: COPY.addPostProcessor })); + expect(screen.queryByLabelText(COPY.pattern)).not.toBeInTheDocument(); + fireEvent.change(screen.getByLabelText(COPY.postProcessors), { target: { value: 'gsub' } }); + expect(screen.getByLabelText(COPY.pattern)).toBeInTheDocument(); + expect(screen.getByLabelText(COPY.replacement)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: COPY.removeNamed('gsub') })); + expect(screen.queryByLabelText(COPY.pattern)).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: COPY.removeNamed('title') })); + expect(screen.queryByLabelText(COPY.attributeName)).not.toBeInTheDocument(); + }); + + it('applies the capture suggestion in one click', async () => { + echoValidate(); + server.use( + http.post('/api/v1/feeds/suggest_selectors', () => + HttpResponse.json({ + success: true, + data: { + selectors: { items: { selector: '.post', enhance: true } }, + segment_strategy: 'list', + admission_drops: { chrome: 2 }, + enhance: true, + }, + }) + ) + ); + + render(); + fireEvent.click(screen.getByRole('button', { name: COPY.applySuggestion })); + + await waitFor(() => expect(screen.getByLabelText(COPY.itemsSelector)).toHaveValue('.post')); + expect(screen.getByLabelText(COPY.enhanceItems)).toBeChecked(); + expect(screen.getByText(COPY.chromeDropped(2))).toBeInTheDocument(); + expect(screen.getByText(/list/)).toBeInTheDocument(); + }); + + it('previews sample titles after a successful check', async () => { + echoValidate(); + server.use( + http.post('/api/v1/feeds/preview', () => + HttpResponse.json({ + success: true, + data: { + item_count: 1, + sample_items: [{ title: 'Sample headline' }], + quality_report: { warnings: [], metrics: { short_title_count: 0 } }, + failure_kind: '', + validation_issues: [], + }, + }) + ) + ); + + render(); + const preview = screen.getByRole('button', { name: COPY.previewExtraction }); + await waitFor(() => expect(preview).toBeEnabled()); + fireEvent.click(preview); + + await waitFor(() => expect(screen.getByText('Sample headline')).toBeInTheDocument()); + expect(screen.getByText(COPY.previewItemCount(1))).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/__tests__/useSession.test.ts b/frontend/src/__tests__/useSession.test.ts index 74ff83de..f900f96f 100644 --- a/frontend/src/__tests__/useSession.test.ts +++ b/frontend/src/__tests__/useSession.test.ts @@ -84,10 +84,11 @@ describe('useSession', () => { ); const { result } = renderHook(() => useSession()); - await waitFor(() => expect(result.current.isLoading).toBe(false)); + await waitFor(() => { + expect(result.current.featuredFeeds.map((entry) => entry.id)).toEqual(['fao.org/newsroom']); + }); expect(result.current.feedCreationEnabled).toBe(true); - expect(result.current.featuredFeeds.map((entry) => entry.id)).toEqual(['fao.org/newsroom']); }); it('saves new tokens to persistent storage and does not write sessionStorage', async () => { diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index 56b29e22..3a40302f 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -65,6 +65,7 @@ export function App() { onCreateAnother, onRefine, onRetryPreview, + sourceUrl, setBookmarkletNotice, setTokenDraft, setTokenError, @@ -112,7 +113,7 @@ export function App() { /> ); } else if (viewModel.kind === 'refine') { - bodyContent = ; + bodyContent = ; } else { bodyContent = ( (null); + + useEffect(() => { + itemsReference.current?.focus(); + }, []); -/** Empty refine shell. Selector controls land in a later phase. */ -export function ConfigStudio() { return (
-

- {COPY.refineSelectors} -

+
+

+ {COPY.refineSelectors} +

+ + + + + +
+ + +
+ + {suggestion ? ( +

+ {COPY.suggestion} {suggestion.segmentStrategy}{' '} + {COPY.chromeDropped(suggestion.chromeDrops)} +

+ ) : undefined} + +
+

{COPY.attributeSelectors}

+ {state.draft.attributes.map((attribute) => ( + studio.changeAttribute(attribute.id, patch)} + onRemove={() => studio.deleteAttribute(attribute.id)} + onAddProcessor={() => studio.appendPostProcessor(attribute.id)} + onRemoveProcessor={(processorId) => studio.deletePostProcessor(attribute.id, processorId)} + onRenameProcessor={(processorId, name) => + studio.changePostProcessorName(attribute.id, processorId, name) + } + onProcessorField={(processorId, field, value) => + studio.changePostProcessorField(attribute.id, processorId, field, value) + } + /> + ))} + +
+ + + + {state.phase === 'failed' ?

{state.message}

: undefined} +
); } + +interface AttributeEditorProperties { + readonly attribute: AttributeDraft; + readonly isFrozen: boolean; + readonly onChange: (patch: AttributePatch) => void; + readonly onRemove: () => void; + readonly onAddProcessor: () => void; + readonly onRemoveProcessor: (processorId: string) => void; + readonly onRenameProcessor: (processorId: string, name: PostProcessorName) => void; + readonly onProcessorField: (processorId: string, field: PostProcessorField, value: string) => void; +} + +function AttributeEditor({ + attribute, + isFrozen, + onChange, + onRemove, + onAddProcessor, + onRemoveProcessor, + onRenameProcessor, + onProcessorField, +}: AttributeEditorProperties) { + const nameId = `studio-attribute-name-${attribute.id}`; + const selectorId = `studio-attribute-selector-${attribute.id}`; + const extractorId = `studio-extractor-${attribute.id}`; + + return ( +
+ + + + {attribute.extractor === 'attribute' ? ( + + ) : undefined} + {attribute.extractor === 'static' ? ( + + ) : undefined} + +
+

{COPY.postProcessors}

+ {attribute.postProcessors.map((processor) => ( + onRenameProcessor(processor.id, name)} + onField={(field, value) => onProcessorField(processor.id, field, value)} + onRemove={() => onRemoveProcessor(processor.id)} + /> + ))} + +
+ + +
+ ); +} + +interface ProcessorEditorProperties { + readonly processor: PostProcessorDraft; + readonly isFrozen: boolean; + readonly onRename: (name: PostProcessorName) => void; + readonly onField: (field: PostProcessorField, value: string) => void; + readonly onRemove: () => void; +} + +function ProcessorEditor({ processor, isFrozen, onRename, onField, onRemove }: ProcessorEditorProperties) { + const selectId = `studio-processor-${processor.id}`; + + return ( +
+ {processor.name === 'unrecognized' ? ( +

{processorCaption(processor)}

+ ) : ( + + )} + + +
+ ); +} + +function ProcessorFields({ + processor, + isFrozen, + onField, +}: { + readonly processor: PostProcessorDraft; + readonly isFrozen: boolean; + readonly onField: (field: PostProcessorField, value: string) => void; +}) { + switch (processor.name) { + case 'gsub': { + return ( + <> + onField('pattern', value)} + /> + onField('replacement', value)} + /> + + ); + } + case 'substring': { + return ( + <> + onField('start', value)} + /> + onField('end', value)} + /> + + ); + } + case 'template': { + return ( + onField('template', value)} + /> + ); + } + default: { + return; + } + } +} + +function ProcessorInput({ + id, + label, + value, + isFrozen, + onInput, +}: { + readonly id: string; + readonly label: string; + readonly value: string; + readonly isFrozen: boolean; + readonly onInput: (value: string) => void; +}) { + return ( + + ); +} + +function PreviewReadout({ state }: { readonly state: StudioState }) { + if (state.phase === 'previewing') { + return

{COPY.previewChecking}

; + } + + if (state.phase !== 'previewed') return; + + return ( +
+

{COPY.previewItemCount(state.preview.itemCount)}

+ {state.preview.titles.length > 0 ? ( +
    + {state.preview.titles.map((title, index) => ( +
  • + +
  • + ))} +
+ ) : undefined} + {state.preview.warnings.map((warning) => ( +

{warning}

+ ))} +
+ ); +} + +function inputValue(event: JSX.TargetedEvent): string { + return event.currentTarget.value; +} + +function isInputChecked(event: JSX.TargetedEvent): boolean { + return event.currentTarget.checked; +} + +function isExtractorName(value: string): value is ExtractorName { + for (const name of EXTRACTOR_NAMES) { + if (name === value) return true; + } + return false; +} + +function isPostProcessorName(value: string): value is PostProcessorName { + for (const name of POST_PROCESSOR_NAMES) { + if (name === value) return true; + } + return false; +} + +function processorCaption(processor: PostProcessorDraft): string { + if (processor.name !== 'unrecognized') return processor.name; + const rawName = processor.raw.name; + return typeof rawName === 'string' ? rawName : processor.name; +} diff --git a/frontend/src/feed/useFeedFlow.ts b/frontend/src/feed/useFeedFlow.ts index 54e13f8d..23cf8105 100644 --- a/frontend/src/feed/useFeedFlow.ts +++ b/frontend/src/feed/useFeedFlow.ts @@ -240,12 +240,13 @@ export function useFeedFlow({ setFocusCreateComposerKey((current) => current + 1); }, [clearError, clearResult, createEntryKey, feedFieldErrors.form, route]); + const sourceUrl = inMemorySourceUrl(feedFormData.url, result?.feed.url); const viewModel = decideJourney({ creationError, feedFieldErrors, isCreating, route, - sourceUrl: inMemorySourceUrl(feedFormData.url, result?.feed.url), + sourceUrl, tokenError, result, }); @@ -289,6 +290,7 @@ export function useFeedFlow({ navigate({ kind: 'refine' }); }, onRetryPreview: retryPreviewFetch, + sourceUrl, setBookmarkletNotice, setTokenDraft, setTokenError, diff --git a/frontend/src/journey/copy.ts b/frontend/src/journey/copy.ts index 41bc3a5a..90b964f7 100644 --- a/frontend/src/journey/copy.ts +++ b/frontend/src/journey/copy.ts @@ -68,4 +68,26 @@ export const COPY = { footerNav: 'Footer navigation', previewStatus: 'Feed preview status', previewRegion: 'Feed preview', + itemsSelector: 'Items selector', + enhanceItems: 'Enhance items', + applySuggestion: 'Apply suggestion', + suggestion: 'Suggestion', + chromeDropped: (count: number) => `${count} chrome dropped`, + attributeSelectors: 'Attributes', + attributeName: 'Name', + attributeSelector: 'Selector', + extractor: 'Extractor', + htmlAttribute: 'HTML attribute', + staticValue: 'Static value', + addAttribute: 'Add attribute', + removeAttribute: 'Remove', + removeNamed: (name: string) => `Remove ${name}`, + postProcessors: 'Post-processors', + addPostProcessor: 'Add post-processor', + previewExtraction: 'Preview', + pattern: 'Pattern', + replacement: 'Replacement', + rangeStart: 'Start', + rangeEnd: 'End', + templateString: 'Template', } as const; From b9bcc16af01c5a5c2a66988e6edeb295f0504d26 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 19 Sep 2026 23:11:02 +0200 Subject: [PATCH 014/108] feat(frontend): add synchronized yaml panel with validation issues 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. --- frontend/src/__tests__/YamlPanel.test.tsx | 52 +++++++++++++++++++++++ frontend/src/components/ConfigStudio.tsx | 10 ++++- frontend/src/components/YamlPanel.tsx | 45 ++++++++++++++++++++ frontend/src/journey/copy.ts | 2 + frontend/src/styles/main.css | 4 ++ 5 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 frontend/src/__tests__/YamlPanel.test.tsx create mode 100644 frontend/src/components/YamlPanel.tsx diff --git a/frontend/src/__tests__/YamlPanel.test.tsx b/frontend/src/__tests__/YamlPanel.test.tsx new file mode 100644 index 00000000..3e3f1538 --- /dev/null +++ b/frontend/src/__tests__/YamlPanel.test.tsx @@ -0,0 +1,52 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/preact'; +import { YamlPanel } from '../components/YamlPanel'; +import { COPY } from '../journey/copy'; +import type { StudioIssue } from '../studio/studioService'; + +const constraintIssue: StudioIssue = { + path: ['selectors', 'items', 'selector'], + code: 'constraint', + message: 'is too short', +}; + +const parseIssue: StudioIssue = { + path: [], + code: 'parse', + message: 'found unexpected end', +}; + +describe('YamlPanel', () => { + it('lists validation issues as path, code, and message', () => { + render( + + ); + + expect(screen.getByLabelText(COPY.yamlConfig)).toHaveValue('selectors: {}'); + expect(screen.getByText('selectors.items.selector')).toBeInTheDocument(); + expect(screen.getByText('constraint')).toBeInTheDocument(); + expect(screen.getByText('is too short')).toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + it('shows a syntax notice when the yaml does not parse', () => { + render(); + + expect(screen.getByRole('status')).toHaveTextContent(COPY.yamlSyntax); + expect(screen.getByText('parse')).toBeInTheDocument(); + }); + + it('forwards textarea edits', () => { + const onYamlInput = vi.fn<(yaml: string) => void>(); + render(); + + fireEvent.input(screen.getByLabelText(COPY.yamlConfig), { target: { value: 'selectors:\n' } }); + + expect(onYamlInput).toHaveBeenCalledWith('selectors:\n'); + }); +}); diff --git a/frontend/src/components/ConfigStudio.tsx b/frontend/src/components/ConfigStudio.tsx index 26359000..9cc7c006 100644 --- a/frontend/src/components/ConfigStudio.tsx +++ b/frontend/src/components/ConfigStudio.tsx @@ -2,6 +2,7 @@ import type { JSX } from 'preact'; import { useEffect, useRef } from 'preact/hooks'; import { COPY } from '../journey/copy'; import { PreviewItem } from './PreviewItem'; +import { YamlPanel } from './YamlPanel'; import { EXTRACTOR_NAMES, POST_PROCESSOR_NAMES, @@ -12,7 +13,7 @@ import { type PostProcessorField, type PostProcessorName, } from '../studio/selectorDraft'; -import { hasSyntaxError, useStudio, type StudioState } from '../studio/useStudio'; +import { hasSyntaxError, studioIssues, useStudio, type StudioState } from '../studio/useStudio'; interface ConfigStudioProperties { readonly url: string; @@ -105,6 +106,13 @@ export function ConfigStudio({ url, token }: ConfigStudioProperties) { + studio.setYaml(yaml)} + /> + {state.phase === 'failed' ?

{state.message}

: undefined} diff --git a/frontend/src/components/YamlPanel.tsx b/frontend/src/components/YamlPanel.tsx new file mode 100644 index 00000000..4e0eadcf --- /dev/null +++ b/frontend/src/components/YamlPanel.tsx @@ -0,0 +1,45 @@ +import type { JSX } from 'preact'; +import { COPY } from '../journey/copy'; +import type { StudioIssue } from '../studio/studioService'; + +interface YamlPanelProperties { + readonly yaml: string; + readonly issues: readonly StudioIssue[]; + readonly hasSyntaxError: boolean; + readonly onYamlInput: (yaml: string) => void; +} + +export function YamlPanel({ yaml, issues, hasSyntaxError, onYamlInput }: YamlPanelProperties) { + return ( +
+