Skip to content
Draft
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
8b2da81
feat(spa): add route-driven flow and session recovery
gildesmarais Apr 5, 2026
e6c9c95
feat(spa): add explicit workflow states and error taxonomy
gildesmarais Apr 5, 2026
1f70400
test(spa): expand route smoke coverage
gildesmarais Apr 5, 2026
a3db589
chore(spa): finalize router polish and gate fixes
gildesmarais Apr 5, 2026
09e058a
test(e2e): align token-route history navigation assertion
gildesmarais Apr 5, 2026
60dd9d8
security(frontend): harden CSP and session token handling
gildesmarais Apr 5, 2026
b55ee50
docs: remove preact-router planning note
gildesmarais Apr 5, 2026
f3f28eb
dev-routing: make 4000 API-only and keep SPA on 4001
gildesmarais Apr 5, 2026
07304bc
Simplify create UX with auto strategy and resilient retry recovery
gildesmarais Apr 5, 2026
755eef1
Improve mobile footer flow and degraded-feed messaging
gildesmarais Apr 5, 2026
a05c774
Harden create-flow retry recovery with explicit retry actions
gildesmarais Apr 5, 2026
500103f
Add DSN-gated Sentry breadcrumbs and triage runbook
gildesmarais Apr 5, 2026
74867f3
Move retry classification upstream
gildesmarais Apr 5, 2026
76570ba
Make empty extraction a 422 failure and harden bookmarklet routing
gildesmarais Apr 5, 2026
0b59322
feat(api): structured conversion failure and feed status contracts
gildesmarais Apr 5, 2026
3ea0aa2
refactor(frontend): consume structured metadata and remove flow heuri…
gildesmarais Apr 5, 2026
c3a9fc6
chore(api): regenerate openapi and frontend generated client
gildesmarais Apr 5, 2026
ef5fe59
fix(build): make rubocop invocation deterministic in make ready
gildesmarais Apr 5, 2026
0e4fd76
Fix OpenAPI status path determinism and smoke contract
gildesmarais Apr 5, 2026
4a98d17
Harden conversion hydration and snapshot restore handling
gildesmarais Apr 5, 2026
c05985b
Reorder footer actions and add CI parity gate guidance
gildesmarais Apr 5, 2026
d5ea2ac
fix PR review findings for status contract and storage utils
gildesmarais Apr 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ This document defines execution constraints for AI agents. For general contribut
## Agent-Specific Verification Rules

- Always run Dev Container smoke + `make ready` for changes.
- For frontend changes or API contract/spec changes, run `make ci-ready` to mirror CI parity checks.
- For frontend changes, also verify in `chrome-devtools` MCP at `http://127.0.0.1:4001/` while the Dev Container is running.
- Capture a quick state check for all affected UI states (e.g., guest/member/result) to enforce state parity and avoid duplicate actions.

Expand Down
13 changes: 11 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# frozen_string_literal: true

.PHONY: help test lint lint-js lint-ruby lintfix lintfix-js lintfix-ruby setup dev clean frontend-setup check-frontend quick-check ready yard-verify-public-docs openapi openapi-verify openapi-client openapi-client-verify openapi-lint openapi-lint-redocly openapi-lint-spectral openai-lint-spectral test-frontend-e2e
.PHONY: help test lint lint-js lint-ruby lintfix lintfix-js lintfix-ruby setup dev clean frontend-setup check-frontend quick-check ready ci-ready yard-verify-public-docs openapi openapi-verify openapi-client openapi-client-verify openapi-lint openapi-lint-redocly openapi-lint-spectral openai-lint-spectral test-frontend-e2e

RUBOCOP_FLAGS ?= --cache false

# Default target
help: ## Show this help message
Expand Down Expand Up @@ -60,7 +62,7 @@ lint: lint-ruby lint-js ## Run all linters (Ruby + Frontend) - errors when issue

lint-ruby: ## Run Ruby linter (RuboCop) - errors when issues found
@echo "Running RuboCop linting..."
bundle exec rubocop
bundle exec rubocop $(RUBOCOP_FLAGS)
@echo "Running Zeitwerk eager-load check..."
bundle exec rake zeitwerk:verify
@echo "Running YARD public-method docs check..."
Expand Down Expand Up @@ -105,6 +107,13 @@ 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)
@echo "Running CI parity checks..."
$(MAKE) ready
$(MAKE) openapi-verify
$(MAKE) test-frontend-e2e
@echo "CI parity checks complete!"

yard-verify-public-docs: ## Verify essential YARD docs for all public methods in app/
bundle exec rake yard:verify_public_docs

Expand Down
56 changes: 40 additions & 16 deletions app.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,21 @@

module Html2rss
module Web
DEFAULT_HEADERS = {
'X-Content-Type-Options' => 'nosniff',
'X-XSS-Protection' => '1; mode=block',
'X-Frame-Options' => 'SAMEORIGIN',
'X-Permitted-Cross-Domain-Policies' => 'none',
'Referrer-Policy' => 'strict-origin-when-cross-origin',
'Permissions-Policy' => 'geolocation=(), microphone=(), camera=()',
'Strict-Transport-Security' => 'max-age=31536000; includeSubDomains; preload',
'Cross-Origin-Embedder-Policy' => 'require-corp',
'Cross-Origin-Opener-Policy' => 'same-origin',
'Cross-Origin-Resource-Policy' => 'same-origin',
'X-DNS-Prefetch-Control' => 'off',
'X-Download-Options' => 'noopen'
}.freeze

##
# Roda app serving RSS feeds via html2rss
class App < Roda
Expand Down Expand Up @@ -41,9 +56,14 @@ def development? = self.class.development?
use Rack::Cache, metastore: 'file:./tmp/rack-cache-meta', entitystore: 'file:./tmp/rack-cache-body',
verbose: development?

# rubocop:disable Metrics/BlockLength
plugin :content_security_policy do |csp|
csp.default_src :none
csp.style_src :self, "'unsafe-inline'"
if development?
csp.style_src :self, "'unsafe-inline'"
else
csp.style_src :self
end
csp.script_src :self
csp.connect_src :self
csp.img_src :self
Expand All @@ -65,21 +85,9 @@ def development? = self.class.development?
csp.block_all_mixed_content
csp.upgrade_insecure_requests
end
# rubocop:enable Metrics/BlockLength

plugin :default_headers, {
'X-Content-Type-Options' => 'nosniff',
'X-XSS-Protection' => '1; mode=block',
'X-Frame-Options' => 'SAMEORIGIN',
'X-Permitted-Cross-Domain-Policies' => 'none',
'Referrer-Policy' => 'strict-origin-when-cross-origin',
'Permissions-Policy' => 'geolocation=(), microphone=(), camera=()',
'Strict-Transport-Security' => 'max-age=31536000; includeSubDomains; preload',
'Cross-Origin-Embedder-Policy' => 'require-corp',
'Cross-Origin-Opener-Policy' => 'same-origin',
'Cross-Origin-Resource-Policy' => 'same-origin',
'X-DNS-Prefetch-Control' => 'off',
'X-Download-Options' => 'noopen'
}
plugin :default_headers, DEFAULT_HEADERS

plugin :json_parser
plugin :static,
Expand All @@ -98,9 +106,20 @@ def development? = self.class.development?

route do |r|
r.public
r.root do
if development?
render_development_api_landing(r)
else
render_index_page(r)
end
end

Routes::ApiV1.call(r) ||
Routes::FeedPages.call(r, index_renderer: ->(router_ctx) { render_index_page(router_ctx) })
Routes::FeedPages.call(
r,
index_renderer: ->(router_ctx) { render_index_page(router_ctx) },
serve_spa: !development?
)
end

private
Expand All @@ -109,6 +128,11 @@ def render_index_page(router)
router.response['Content-Type'] = 'text/html'
File.exist?(FRONTEND_INDEX_PATH) ? File.read(FRONTEND_INDEX_PATH) : FALLBACK_HTML
end

def render_development_api_landing(router)
router.response['Content-Type'] = 'text/html'
DevelopmentLandingPage::HTML
end
end
end
end
143 changes: 142 additions & 1 deletion app/web/api/v1/contract.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,158 @@ module Html2rss
module Web
module Api
module V1
module Contract
##
# Shared API v1 contract constants and payload builders.
module Contract # rubocop:disable Metrics/ModuleLength
CODES = {
unauthorized: Html2rss::Web::UnauthorizedError::CODE,
forbidden: Html2rss::Web::ForbiddenError::CODE,
internal_server_error: Html2rss::Web::InternalServerError::CODE
}.freeze

ERROR_KINDS = {
auth: 'auth',
input: 'input',
network: 'network',
server: 'server'
}.freeze

NEXT_ACTIONS = {
enter_token: 'enter_token',
correct_input: 'correct_input',
retry: 'retry',
wait: 'wait',
none: 'none'
}.freeze

RETRY_ACTIONS = {
alternate: 'alternate',
primary: 'primary',
none: 'none'
}.freeze

READINESS_PHASES = {
link_created: 'link_created',
feed_ready: 'feed_ready',
feed_not_ready_yet: 'feed_not_ready_yet',
preview_unavailable: 'preview_unavailable'
}.freeze

PREVIEW_STATUSES = {
pending: 'pending',
ready: 'ready',
degraded: 'degraded',
unavailable: 'unavailable'
}.freeze

MESSAGES = {
auto_source_disabled: 'Auto source feature is disabled',
health_check_failed: 'Health check failed'
}.freeze

class << self
# Builds the structured API error envelope used by JSON API routes.
#
# @param error [StandardError]
# @return [Hash{Symbol=>Object}] structured API error details.
def failure_payload(error)
metadata = failure_metadata(error)
base = {
message: client_message_for(error),
code: error_code_for(error),
kind: metadata[:kind],
retryable: metadata[:retryable],
next_action: metadata[:next_action],
retry_action: metadata[:retry_action]
}
metadata[:next_strategy] ? base.merge(next_strategy: metadata[:next_strategy]) : base
end

# Builds a warning entry for readiness/status responses.
#
# @param code [String]
# @param message [String]
# @param retryable [Boolean]
# @param next_action [String]
# @return [Hash{Symbol=>Object}] structured warning payload.
def warning(code:, message:, retryable:, next_action:)
{
code: code,
message: message,
retryable: retryable,
next_action: next_action
}
end

private

# @param error [StandardError]
# @return [Hash{Symbol=>Object}]
def failure_metadata(error)
case error
when Html2rss::Web::AutoSourceDisabledError, Html2rss::Web::HealthCheckFailedError
non_retryable_server_failure_metadata
when Html2rss::Web::UnauthorizedError then auth_failure_metadata
when Html2rss::Web::BadRequestError, Html2rss::Web::ForbiddenError then input_failure_metadata
else
generic_failure_metadata(error)
end
end

# @param error [StandardError]
# @return [Hash{Symbol=>Object}]
def generic_failure_metadata(error)
kind = Html2rss::Web::ErrorClassification.network_error?(error) ? :network : :server
{
kind: ERROR_KINDS[kind],
retryable: true,
next_action: NEXT_ACTIONS[:retry],
retry_action: RETRY_ACTIONS[:primary]
}
end

# @return [Hash{Symbol=>Object}]
def auth_failure_metadata
{
kind: ERROR_KINDS[:auth],
retryable: false,
next_action: NEXT_ACTIONS[:enter_token],
retry_action: RETRY_ACTIONS[:none]
}
end

# @return [Hash{Symbol=>Object}]
def input_failure_metadata
{
kind: ERROR_KINDS[:input],
retryable: false,
next_action: NEXT_ACTIONS[:correct_input],
retry_action: RETRY_ACTIONS[:none]
}
end

# @return [Hash{Symbol=>Object}]
def non_retryable_server_failure_metadata
{
kind: ERROR_KINDS[:server],
retryable: false,
next_action: NEXT_ACTIONS[:none],
retry_action: RETRY_ACTIONS[:none]
}
end

# @param error [StandardError]
# @return [String]
def client_message_for(error)
error.is_a?(Html2rss::Web::HttpError) ? error.message : Html2rss::Web::HttpError::DEFAULT_MESSAGE
end

# @param error [StandardError]
# @return [String]
def error_code_for(error)
error.respond_to?(:code) ? error.code : CODES[:internal_server_error]
end
end
end
end
end
Expand Down
45 changes: 10 additions & 35 deletions app/web/api/v1/create_feed.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,21 @@ module V1
# Creates stable feed records from authenticated API requests.
module CreateFeed # rubocop:disable Metrics/ModuleLength
FEED_ATTRIBUTE_KEYS =
%i[id name url strategy feed_token public_url json_public_url created_at updated_at].freeze
%i[id name url feed_token public_url json_public_url created_at updated_at].freeze
class << self # rubocop:disable Metrics/ClassLength
# Creates a feed and returns a normalized API success payload.
#
# @param request [Rack::Request] HTTP request with auth context.
# @return [Hash{Symbol=>Object}] API response payload.
def call(request)
def call(request) # rubocop:disable Metrics/MethodLength
params, feed_data = build_feed_from_request(request)
emit_create_success(params)
Response.success(response: request.response,
status: 201,
data: { feed: feed_attributes(feed_data) },
data: {
feed: feed_attributes(feed_data),
conversion: FeedStatus.initial_conversion
},
meta: { created: true })
rescue StandardError => error
emit_create_failure(error)
Expand All @@ -33,7 +36,7 @@ def call(request)

# @return [void]
def ensure_auto_source_enabled!
raise Html2rss::Web::ForbiddenError, Contract::MESSAGES[:auto_source_disabled] unless AutoSource.enabled?
raise Html2rss::Web::AutoSourceDisabledError unless AutoSource.enabled?
end

# @param request [Rack::Request]
Expand All @@ -52,8 +55,7 @@ def build_create_params(params, account)
url = validated_url(params['url'], account)
FeedMetadata::CreateParams.new(
url: url,
name: FeedMetadata.site_title_for(url),
strategy: normalize_strategy(params['strategy'])
name: FeedMetadata.site_title_for(url)
)
end

Expand Down Expand Up @@ -101,33 +103,6 @@ def hostname_input?(url)
}ix.match?(url)
end

# @param raw_strategy [String, nil]
# @return [String]
def normalize_strategy(raw_strategy)
strategy = raw_strategy.to_s.strip
strategy = default_strategy if strategy.empty?

raise Html2rss::Web::BadRequestError, 'Unsupported strategy' unless supported_strategy?(strategy)

strategy
end

# @return [Array<String>] supported strategy identifiers.
def supported_strategies
Html2rss::RequestService.strategy_names.map(&:to_s)
end

# @param strategy [String]
# @return [Boolean]
def supported_strategy?(strategy)
supported_strategies.include?(strategy)
end

# @return [String] default strategy identifier.
def default_strategy
Html2rss::RequestService.default_strategy_name.to_s
end

# @param feed_data [Hash, Html2rss::Web::Api::V1::FeedMetadata::Metadata]
# @return [Hash{Symbol=>Object}]
def feed_attributes(feed_data)
Expand Down Expand Up @@ -167,7 +142,7 @@ def build_feed_from_request(request)
ensure_auto_source_enabled!
params = build_create_params(request_params(request), account)

feed_data = AutoSource.create_stable_feed(params.name, params.url, account, params.strategy)
feed_data = AutoSource.create_stable_feed(params.name, params.url, account)
raise Html2rss::Web::InternalServerError, 'Failed to create feed' unless feed_data

[params, feed_data]
Expand All @@ -179,7 +154,7 @@ def emit_create_success(params)
Observability.emit(
event_name: 'feed.create',
outcome: 'success',
details: { strategy: params.strategy, url: params.url },
details: { url: params.url },
level: :info
)
end
Expand Down
Loading
Loading