Add architect remediation implementation plan

5 tasks: fix syntheses list contract (frontend aligns to backend preview
fields), fix admin rate-limits path (provider_name not id), SSRF redirect
per-hop validation with custom reqwest policy, shared typed test fixtures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
master
oabrivard 3 months ago
parent d20fa20ed2
commit e1c37b520b

@ -0,0 +1,432 @@
# Architect Remediation Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix 2 API contract breaks, 1 SSRF redirect vulnerability, and 1 test mock drift issue identified in the architect assessment.
**Architecture:** Frontend types and components are updated to match backend response shapes. Backend scraper gets a custom reqwest redirect policy with per-hop SSRF validation. A shared test fixtures file prevents future mock drift.
**Tech Stack:** Rust (reqwest redirect policy, std::net::ToSocketAddrs), SolidJS/TypeScript
**Spec:** `docs/superpowers/specs/2026-03-22-architect-remediation-design.md`
---
## File Map
### Modified Files
- `frontend/src/types.ts:122-128` — fix `SynthesisListItem` to match backend
- `frontend/src/pages/Home.tsx:179-192` — use preview fields instead of `sections`
- `frontend/src/api/admin.ts:38-39` — pass `providerName` not `id`
- `frontend/src/pages/admin/RateLimits.tsx:70` — pass `provider_name` to API
- `backend/src/services/scraper.rs:53-65` — custom redirect policy with SSRF checks
- `frontend/src/__tests__/pages/home.test.tsx:24-47` — use shared fixtures
### New Files
- `frontend/src/__tests__/fixtures.ts` — shared typed test mock data
---
## Task 1: Fix `/syntheses` list contract
**Files:**
- Modify: `frontend/src/types.ts:122-128`
- Modify: `frontend/src/pages/Home.tsx:179-192`
- Modify: `frontend/src/i18n/fr.ts` (may need new key for preview format)
- [ ] **Step 1: Update SynthesisListItem type**
In `frontend/src/types.ts`, replace lines 122-128:
```typescript
// Before:
export interface SynthesisListItem {
id: string;
week: string;
sections: NewsSection[];
status: string;
created_at: string;
}
// After:
export interface SynthesisListItem {
id: string;
week: string;
status: string;
created_at: string;
first_section_title: string | null;
first_section_item_count: number;
}
```
- [ ] **Step 2: Run TypeScript to see what breaks**
Run: `cd frontend && npx tsc --noEmit 2>&1 | head -30`
Expected: errors in `Home.tsx` and test files referencing `synth.sections`
- [ ] **Step 3: Update Home.tsx card preview**
Replace lines 179-192 in `frontend/src/pages/Home.tsx`:
```tsx
{/* Before: accesses synth.sections[0].items */}
{/* After: uses preview metadata from backend */}
<div class="text-sm text-gray-600 space-y-1.5">
<Show
when={synth.first_section_title}
fallback={
<p>{t('home.noPreview')}</p>
}
>
<p class="font-medium text-gray-700">{synth.first_section_title}</p>
<p class="text-gray-500">
{t('home.previewCount', { count: String(synth.first_section_item_count) })}
</p>
</Show>
</div>
```
- [ ] **Step 4: Add i18n key if needed**
In `frontend/src/i18n/fr.ts`, add:
```typescript
'home.previewCount': '{count} articles',
```
- [ ] **Step 5: Verify compilation and tests**
Run: `cd frontend && npx tsc --noEmit`
Expected: errors only in test files (home.test.tsx mocks still use old shape — fixed in Task 4)
Run: `cd frontend && npx vitest run src/__tests__/pages/home.test.tsx 2>&1 | tail -5`
Expected: may fail due to mock shape — that's OK, Task 4 fixes it
- [ ] **Step 6: Commit**
```bash
cd /Users/oabrivard/Projects/rust/ai_synth
git add frontend/src/types.ts frontend/src/pages/Home.tsx frontend/src/i18n/fr.ts
git commit -m "fix: align SynthesisListItem with backend response (preview fields, not sections)"
```
---
## Task 2: Fix admin rate-limits update path
**Files:**
- Modify: `frontend/src/api/admin.ts:38-39`
- Modify: `frontend/src/pages/admin/RateLimits.tsx:70`
- [ ] **Step 1: Update API client**
In `frontend/src/api/admin.ts`, replace lines 38-39:
```typescript
// Before:
update: (id: string, data: UpdateRateLimitRequest): Promise<AdminRateLimit> =>
api.put<AdminRateLimit>(`/admin/rate-limits/${id}`, data),
// After:
/** PUT /admin/rate-limits/:provider_name -- update rate limit for a specific provider. */
update: (providerName: string, data: UpdateRateLimitRequest): Promise<AdminRateLimit> =>
api.put<AdminRateLimit>(`/admin/rate-limits/${providerName}`, data),
```
- [ ] **Step 2: Update RateLimits page save handler**
In `frontend/src/pages/admin/RateLimits.tsx`, change ONLY line 70:
```typescript
// Before:
await adminRateLimitsApi.update(limit.id, {
// After:
await adminRateLimitsApi.update(limit.provider_name, {
```
Do NOT change any other usage of `limit.id` — it's still used for UI state (`savingId`, `updateLocal`).
- [ ] **Step 3: Verify compilation**
Run: `cd frontend && npx tsc --noEmit`
Expected: no errors (or only the home.test.tsx mock shape errors from Task 1)
- [ ] **Step 4: Commit**
```bash
git add frontend/src/api/admin.ts frontend/src/pages/admin/RateLimits.tsx
git commit -m "fix: admin rate-limits API passes provider_name instead of id"
```
---
## Task 3: SSRF redirect validation per hop
**Files:**
- Modify: `backend/src/services/scraper.rs:53-65`
- [ ] **Step 1: Read the current build_scraper_client function**
Read `backend/src/services/scraper.rs` lines 53-65.
- [ ] **Step 2: Replace with custom redirect policy**
Replace the `build_scraper_client()` function. The new version uses `reqwest::redirect::Policy::custom()` with per-hop DNS resolution and `is_private_ip()` checks:
```rust
/// Build a `reqwest::Client` configured for scraping.
///
/// Uses a custom redirect policy that validates each hop against private/internal
/// IP addresses (SSRF prevention). DNS is resolved synchronously in the redirect
/// handler via `std::net::ToSocketAddrs`. Max 3 redirects, only http/https schemes.
///
/// **Residual risk**: There is a theoretical TOCTOU gap between the DNS check in
/// the redirect policy and reqwest's actual TCP connection. DNS rebinding could
/// bypass the check. This is accepted as a known limitation.
pub fn build_scraper_client() -> Result<reqwest::Client, AppError> {
use std::net::ToSocketAddrs;
let redirect_policy = reqwest::redirect::Policy::custom(|attempt| {
if attempt.previous().len() >= 3 {
return attempt.error("Too many redirects");
}
let url = attempt.url();
if url.scheme() != "http" && url.scheme() != "https" {
return attempt.error("Blocked redirect to non-HTTP scheme");
}
if let Some(host) = url.host_str() {
let port = url.port().unwrap_or(if url.scheme() == "https" { 443 } else { 80 });
let addr_str = format!("{}:{}", host, port);
match addr_str.to_socket_addrs() {
Ok(addrs) => {
for addr in addrs {
if is_private_ip(addr.ip()) {
return attempt.error("Blocked redirect to private/internal IP");
}
}
}
Err(_) => {
return attempt.error("DNS resolution failed for redirect target");
}
}
}
attempt.follow()
});
reqwest::Client::builder()
.user_agent(USER_AGENT)
.connect_timeout(std::time::Duration::from_secs(5))
.timeout(std::time::Duration::from_secs(15))
.redirect(redirect_policy)
.build()
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to build scraper client: {}", e)))
}
```
- [ ] **Step 3: Verify compilation and tests**
Run: `cd backend && cargo check && cargo test --lib scraper`
Expected: compiles, all scraper tests pass (existing `is_private_ip` tests still work, redirect policy is exercised at runtime not in unit tests)
- [ ] **Step 4: Run full backend tests**
Run: `cd backend && cargo test --lib`
Expected: all pass
- [ ] **Step 5: Commit**
```bash
cd /Users/oabrivard/Projects/rust/ai_synth
git add backend/src/services/scraper.rs
git commit -m "security: SSRF redirect validation per hop with custom reqwest policy"
```
---
## Task 4: Shared test fixtures + fix mock drift
**Files:**
- Create: `frontend/src/__tests__/fixtures.ts`
- Modify: `frontend/src/__tests__/pages/home.test.tsx`
- Modify: `frontend/src/__tests__/pages/settings.test.tsx`
- Modify: `frontend/src/__tests__/pages/sources.test.tsx`
- Modify: `frontend/src/__tests__/pages/generate.test.tsx`
- [ ] **Step 1: Create shared fixtures file**
Create `frontend/src/__tests__/fixtures.ts`:
```typescript
/**
* Shared test fixtures typed against actual API response interfaces.
* All page tests import from here instead of defining inline mocks.
* If a type changes in types.ts, fixtures fail to compile — catching drift.
*/
import type {
SynthesisListItem,
Synthesis,
NewsSection,
Source,
UserSettings,
ProviderConfig,
AdminProvider,
AdminRateLimit,
GenerateResponse,
} from '~/types';
import { DEFAULT_SETTINGS } from '~/types';
// ---- Syntheses (list view — matches backend SynthesisListItem) ----
export const MOCK_SYNTHESIS_LIST_ITEM: SynthesisListItem = {
id: 'test-synth-1',
week: '2026-W12',
status: 'completed',
created_at: '2026-03-21T10:00:00Z',
first_section_title: 'Annonces majeures',
first_section_item_count: 3,
};
export const MOCK_SYNTHESIS_LIST: SynthesisListItem[] = [
MOCK_SYNTHESIS_LIST_ITEM,
{
...MOCK_SYNTHESIS_LIST_ITEM,
id: 'test-synth-2',
week: '2026-W11',
first_section_title: null,
first_section_item_count: 0,
},
];
export const MOCK_SYNTHESIS_IN_PROGRESS: SynthesisListItem = {
...MOCK_SYNTHESIS_LIST_ITEM,
id: 'test-synth-progress',
status: 'in_progress',
};
// ---- Syntheses (detail view — has full sections) ----
export const MOCK_SECTIONS: NewsSection[] = [
{
title: 'Annonces majeures',
items: [
{ title: 'GPT-5 Released', url: 'https://example.com/1', summary: 'Summary 1' },
{ title: 'New Chip Launch', url: 'https://example.com/2', summary: 'Summary 2' },
],
},
];
export const MOCK_SYNTHESIS_DETAIL: Synthesis = {
id: 'test-synth-1',
user_id: 'test-user-1',
week: '2026-W12',
sections: MOCK_SECTIONS,
status: 'completed',
created_at: '2026-03-21T10:00:00Z',
};
// ---- Sources ----
export const MOCK_SOURCE: Source = {
id: 'src-1',
user_id: 'u1',
title: 'Test Blog',
url: 'https://test.example.com/blog',
created_at: '2026-03-21T10:00:00Z',
};
export const MOCK_SOURCES: Source[] = [
MOCK_SOURCE,
{ ...MOCK_SOURCE, id: 'src-2', title: 'News Site', url: 'https://news.example.com' },
];
// ---- Settings ----
export const MOCK_SETTINGS: UserSettings = {
...DEFAULT_SETTINGS,
theme: 'Intelligence Artificielle',
ai_provider: 'gemini',
ai_model: 'gemini-2.5-flash',
};
// ---- Providers ----
export const MOCK_PROVIDER_CONFIG: ProviderConfig = {
provider_name: 'gemini',
display_name: 'Google Gemini',
models: [
{ model_id: 'gemini-2.5-flash', display_name: 'Gemini 2.5 Flash' },
{ model_id: 'gemini-2.5-pro', display_name: 'Gemini 2.5 Pro' },
],
};
export const MOCK_PROVIDER_CONFIGS: ProviderConfig[] = [MOCK_PROVIDER_CONFIG];
// ---- Generate ----
export const MOCK_GENERATE_RESPONSE: GenerateResponse = {
job_id: 'job-test-1',
};
```
- [ ] **Step 2: Update home.test.tsx to use fixtures**
In `frontend/src/__tests__/pages/home.test.tsx`:
- Replace the inline `mockSyntheses` data with imports from fixtures
- Update assertions to use `first_section_title` and `first_section_item_count` instead of `sections`
- The mock for `synthesesApi.list` should return `MOCK_SYNTHESIS_LIST`
- For the in-progress test, include `MOCK_SYNTHESIS_IN_PROGRESS` in the list
- [ ] **Step 3: Update settings.test.tsx to use fixtures**
Import `MOCK_SETTINGS`, `MOCK_PROVIDER_CONFIGS` from fixtures. Replace inline mock data.
- [ ] **Step 4: Update sources.test.tsx to use fixtures**
Import `MOCK_SOURCES`, `MOCK_SOURCE` from fixtures. Replace inline mock data.
- [ ] **Step 5: Update generate.test.tsx to use fixtures**
Import `MOCK_SETTINGS`, `MOCK_PROVIDER_CONFIGS`, `MOCK_GENERATE_RESPONSE` from fixtures. Replace inline mock data.
- [ ] **Step 6: Verify everything passes**
Run: `cd frontend && npx tsc --noEmit && npx vitest run`
Expected: all tests pass with no TypeScript errors
- [ ] **Step 7: Verify production build**
Run: `cd frontend && npx vite build`
Expected: builds successfully
- [ ] **Step 8: Commit**
```bash
cd /Users/oabrivard/Projects/rust/ai_synth
git add frontend/src/__tests__/fixtures.ts frontend/src/__tests__/pages/
git commit -m "test: shared typed fixtures to prevent mock drift from backend contracts"
```
---
## Task 5: Final verification
- [ ] **Step 1: Full backend check**
Run: `cd backend && cargo check && cargo test --lib`
Expected: compiles, all tests pass
- [ ] **Step 2: Full frontend check**
Run: `cd frontend && npx tsc --noEmit && npx vitest run && npx vite build`
Expected: type-checks, all tests pass, builds
- [ ] **Step 3: Commit if any remaining changes**
```bash
git status
# Only commit if there are uncommitted changes
```
Loading…
Cancel
Save