diff --git a/.github/workflows/ai.yml b/.github/workflows/ai.yml index 61a6e120..82667219 100644 --- a/.github/workflows/ai.yml +++ b/.github/workflows/ai.yml @@ -33,6 +33,7 @@ jobs: AI_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/grandflow_ai LOG_LEVEL: INFO PYTHONPATH: ${{ github.workspace }}/services/ai:${{ github.workspace }} + AI_PROVIDER_APPROVED_ORIGINS: '[{"origin": "http://ollama:11434", "allow_private": true}]' steps: - uses: actions/checkout@v4 diff --git a/docker-compose.local.yml b/docker-compose.local.yml index f350721d..1132d4a6 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -144,6 +144,8 @@ services: - ./services/ai/.env.ai.private.local environment: - PYTHONPATH=/app + extra_hosts: + - "host.docker.internal:host-gateway" depends_on: grandflow-db: condition: service_healthy diff --git a/frontend-typescript/src/api/aiSettingsApi.ts b/frontend-typescript/src/api/aiSettingsApi.ts index 539ddfdb..d849c89f 100644 --- a/frontend-typescript/src/api/aiSettingsApi.ts +++ b/frontend-typescript/src/api/aiSettingsApi.ts @@ -10,9 +10,15 @@ export interface ProviderKeyConfig { is_default: boolean; } +export interface ApprovedEndpoint { + origin: string; + label: string; +} + export interface AiSettings { configs: ProviderKeyConfig[]; platform_fallback_enabled: boolean; + approved_endpoints: ApprovedEndpoint[]; } export const getAiSettings = async (): Promise => { diff --git a/frontend-typescript/src/pages/Settings/components/AiIntegrationsSection.test.tsx b/frontend-typescript/src/pages/Settings/components/AiIntegrationsSection.test.tsx new file mode 100644 index 00000000..06574cf2 --- /dev/null +++ b/frontend-typescript/src/pages/Settings/components/AiIntegrationsSection.test.tsx @@ -0,0 +1,119 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { vi, type Mock } from "vitest"; +import { AiIntegrationsSection } from "./AiIntegrationsSection"; +import * as aiSettingsApi from "@/api/aiSettingsApi"; +import * as authContext from "@/context/AuthContext"; + +vi.mock("@/api/aiSettingsApi", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getAiSettings: vi.fn(), + createAiKey: vi.fn(), + }; +}); + +vi.mock("@/context/AuthContext", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useAuth: vi.fn(), + }; +}); + +const getAiSettingsMock = aiSettingsApi.getAiSettings as unknown as Mock; +const createAiKeyMock = aiSettingsApi.createAiKey as unknown as Mock; +const useAuthMock = authContext.useAuth as unknown as Mock; + +function renderSection() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return render( + + + , + ); +} + +async function openAddKeyModalOnOllama() { + const user = userEvent.setup(); + await user.click(await screen.findByRole("button", { name: "Add key" })); + await user.click(screen.getByRole("button", { name: "Ollama (Local)" })); + return user; +} + +function submitButton() { + return screen.getAllByRole("button", { name: "Add key" })[1]; +} + +describe("AiIntegrationsSection", () => { + beforeEach(() => { + vi.clearAllMocks(); + useAuthMock.mockReturnValue({ isSuperuser: false }); + createAiKeyMock.mockResolvedValue({ + configs: [], + platform_fallback_enabled: false, + approved_endpoints: [], + }); + }); + + it("offers the approved endpoints as a picker instead of a free-text URL", async () => { + getAiSettingsMock.mockResolvedValue({ + configs: [], + platform_fallback_enabled: false, + approved_endpoints: [ + { origin: "http://ollama:11434", label: "Local Ollama" }, + { + origin: "http://host.docker.internal:11434", + label: "http://host.docker.internal:11434", + }, + ], + }); + renderSection(); + + await openAddKeyModalOnOllama(); + + expect(screen.queryByPlaceholderText("http://localhost:11434")).not.toBeInTheDocument(); + expect(screen.getByText("Local Ollama", { selector: "option" })).toBeInTheDocument(); + expect( + screen.getByText("http://host.docker.internal:11434", { selector: "option" }), + ).toBeInTheDocument(); + }); + + it("preselects the only approved endpoint and submits its origin as base_url", async () => { + getAiSettingsMock.mockResolvedValue({ + configs: [], + platform_fallback_enabled: false, + approved_endpoints: [{ origin: "http://ollama:11434", label: "Local Ollama" }], + }); + renderSection(); + const user = await openAddKeyModalOnOllama(); + + await user.click(submitButton()); + + await waitFor(() => + expect(createAiKeyMock).toHaveBeenCalledWith( + expect.objectContaining({ base_url: "http://ollama:11434" }), + ), + ); + }); + + it("shows a message and disables Save when no endpoints are approved", async () => { + getAiSettingsMock.mockResolvedValue({ + configs: [], + platform_fallback_enabled: false, + approved_endpoints: [], + }); + renderSection(); + + await openAddKeyModalOnOllama(); + + expect( + screen.getByText("No approved endpoints are configured. Contact your operator."), + ).toBeInTheDocument(); + expect(submitButton()).toBeDisabled(); + }); +}); diff --git a/frontend-typescript/src/pages/Settings/components/AiIntegrationsSection.tsx b/frontend-typescript/src/pages/Settings/components/AiIntegrationsSection.tsx index 6c23b588..274cb5df 100644 --- a/frontend-typescript/src/pages/Settings/components/AiIntegrationsSection.tsx +++ b/frontend-typescript/src/pages/Settings/components/AiIntegrationsSection.tsx @@ -7,6 +7,7 @@ import { setDefaultAiKey, deleteAiKey, setPlatformFallback, + ApprovedEndpoint, ProviderKeyConfig, } from "@/api/aiSettingsApi"; import { useAuth } from "@/context/AuthContext"; @@ -69,10 +70,12 @@ function AddKeyModal({ isOpen, onClose, isFirstKey, + approvedEndpoints, }: { isOpen: boolean; onClose: () => void; isFirstKey: boolean; + approvedEndpoints: ApprovedEndpoint[]; }) { const queryClient = useQueryClient(); const [provider, setProvider] = useState(PROVIDERS[0].name); @@ -80,12 +83,15 @@ function AddKeyModal({ const [label, setLabel] = useState(""); const [keyInput, setKeyInput] = useState(""); const [showKey, setShowKey] = useState(false); - const [urlInput, setUrlInput] = useState("http://localhost:11434"); + const [selectedOrigin, setSelectedOrigin] = useState(approvedEndpoints[0]?.origin ?? ""); const [makeDefault, setMakeDefault] = useState(isFirstKey); useEffect(() => { - if (isOpen) setMakeDefault(isFirstKey); - }, [isOpen, isFirstKey]); + if (isOpen) { + setMakeDefault(isFirstKey); + setSelectedOrigin(approvedEndpoints[0]?.origin ?? ""); + } + }, [isOpen, isFirstKey, approvedEndpoints]); const providerMeta = providerMetaFor(provider); const availableModels = MODELS_BY_PROVIDER[provider] ?? []; @@ -102,20 +108,22 @@ function AddKeyModal({ label: label.trim() || null, key: providerMeta.requires_key ? keyInput : null, model, - base_url: providerMeta.requires_key ? null : urlInput || null, + base_url: providerMeta.requires_key ? null : selectedOrigin || null, is_default: makeDefault, }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["ai-settings"] }); setLabel(""); setKeyInput(""); - setUrlInput("http://localhost:11434"); + setSelectedOrigin(approvedEndpoints[0]?.origin ?? ""); setMakeDefault(false); onClose(); }, }); - const canSave = providerMeta.requires_key ? keyInput.trim().length > 0 : true; + const canSave = providerMeta.requires_key + ? keyInput.trim().length > 0 + : selectedOrigin.length > 0; const help = PROVIDER_HELP[provider]; return ( @@ -196,14 +204,24 @@ function AddKeyModal({ ) : (
- - setUrlInput(e.target.value)} - placeholder="http://localhost:11434" - className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" - /> + + {approvedEndpoints.length > 0 ? ( + + ) : ( +

+ No approved endpoints are configured. Contact your operator. +

+ )}
)} @@ -489,6 +507,7 @@ export function AiIntegrationsSection() { isOpen={addModalOpen} onClose={() => setAddModalOpen(false)} isFirstKey={(data?.configs.length ?? 0) === 0} + approvedEndpoints={data?.approved_endpoints ?? []} /> {data && deletingConfig && ( diff --git a/openspec/changes/ai-feat-site-assistant-feedback-release-gates/.openspec.yaml b/openspec/changes/ai-feat-site-assistant-feedback-release-gates/.openspec.yaml new file mode 100644 index 00000000..71cbd3e3 --- /dev/null +++ b/openspec/changes/ai-feat-site-assistant-feedback-release-gates/.openspec.yaml @@ -0,0 +1,6 @@ +schema: spec-driven +created: 2026-09-29 +author: Norair Arutshyan +priority: low +depends_on: + - ai-feat-site-assistant-grounded-answers diff --git a/openspec/changes/ai-feat-site-assistant-feedback-release-gates/proposal.md b/openspec/changes/ai-feat-site-assistant-feedback-release-gates/proposal.md new file mode 100644 index 00000000..720a6896 --- /dev/null +++ b/openspec/changes/ai-feat-site-assistant-feedback-release-gates/proposal.md @@ -0,0 +1,40 @@ +## Why + +After launch, the assistant will fail in ways the golden dataset didn't predict. This change closes the loop: production failure → review → new eval case → fix → permanent regression test. It also makes prompt or model changes a measured release decision (quality × latency × cost) instead of a config edit. + +> **Status: skeleton.** The open questions below must be resolved before specs, design and tasks are written. + +## What Changes + +- **Feedback capture:** thumbs up/down on each answer, with optional reasons (incorrect, didn't answer, outdated, missing information, other). The stored record holds the question (redacted), retrieved IDs, answer, prompt version, model and feedback. It has a retention TTL and is purged automatically. +- **Review → dataset path:** a superuser-only way to list negative feedback and promote a record into the golden dataset as a new eval case, with its expected sources. +- **Candidate vs production comparison:** the eval harness runs two configurations (prompt version and/or model) over the same dataset. It reports groundedness, answer correctness, refusal correctness, hallucination rate, P95 latency, tokens and cost side by side. +- **LLM-as-judge:** only for semantic dimensions that code can't check (groundedness, completeness), with a pinned judge model and prompt version. Everything deterministic stays deterministic. +- **Release gates:** answer-level evals run nightly and on a PR label (they cost money and results vary run to run). A tolerance-band gate blocks promotion of a candidate prompt version or model that regresses. +- **Content reindex pipeline:** a content change triggers extract → chunk → index → retrieval evals → answer evals before the new index goes live. + +## Capabilities + +### New Capabilities +- `site-assistant-feedback`: capture, storage, retention and review of visitor feedback. +- `ai-release-evaluation`: candidate-vs-production comparison, judge usage rules and release gate semantics. This is written to be reusable by other AI features, not only the site assistant. + +### Modified Capabilities +- `site-assistant-ui`: feedback controls on answers. +- `retrieval-evaluation`: the dataset gains production-sourced cases. + +## Impact + +- **Depends on:** `ai-feat-site-assistant-grounded-answers` (hard). +- **Related:** `gdpr-iso27001-priority-2`, whose `data-retention-policy` should own the feedback purge job if it has landed; otherwise this change adds its own Celery beat task and registers it in the worker's `include=[]`. Also related: `platform-chore-328-compliance-guardrails` (the PII-in-logs rule applies to stored questions). +- **ai service:** feedback model and migration, a superuser review endpoint, eval-runner extensions. +- **CI:** a new scheduled workflow for nightly answer evals, needing the platform Anthropic key as a GitHub secret. +- **Python dependencies:** none expected. Judge calls reuse `pydantic-ai`. + +## Open Questions + +1. **Retention period** for stored questions and feedback: 30 days to match the AI-session job, or 90 days to allow meaningful review? +2. **Redaction approach:** regex for emails, phones and names, or no raw question storage at all (store only the question embedding or a hash)? +3. **Where review happens:** inside the planned superuser admin console (`superuser-admin-console`), or a script/CLI for now? +4. **Nightly eval budget:** the maximum spend per run and per month on the platform key. +5. **Judge model:** same provider as generation (cheaper, correlated blind spots) or a different one (for example the Mistral BYOK path, which adds cost and a key)? diff --git a/openspec/changes/ai-feat-site-assistant-grounded-answers/.openspec.yaml b/openspec/changes/ai-feat-site-assistant-grounded-answers/.openspec.yaml new file mode 100644 index 00000000..bd57d64d --- /dev/null +++ b/openspec/changes/ai-feat-site-assistant-grounded-answers/.openspec.yaml @@ -0,0 +1,7 @@ +schema: spec-driven +created: 2026-09-29 +author: Norair Arutshyan +priority: medium +depends_on: + - ai-feat-site-assistant-retrieval-evals + - frontend-feat-public-site-content diff --git a/openspec/changes/ai-feat-site-assistant-grounded-answers/proposal.md b/openspec/changes/ai-feat-site-assistant-grounded-answers/proposal.md new file mode 100644 index 00000000..d6c9b9d5 --- /dev/null +++ b/openspec/changes/ai-feat-site-assistant-grounded-answers/proposal.md @@ -0,0 +1,41 @@ +## Why + +Once retrieval is measured, visitors can ask questions on the landing page and get grounded, cited answers. This is the first **anonymous, internet-facing** AI endpoint in the platform, funded by the platform's own key. Abuse resistance, output handling and grounding are therefore requirements, not polish. + +> **Status: skeleton.** The open questions below must be resolved before specs, design and tasks are written. + +## What Changes + +- **Endpoint:** `POST /api/v1/ai/site-assistant/ask` in the ai service. It is unauthenticated and behind a feature flag (off by default, so self-hosters opt in). It follows a fixed workflow: query → retrieve (`site-retrieval`) → generate → cite. There are no tools, planner or loop. It stays deliberately separate from the chat service, which is an authenticated agent host with write tools. +- **Model:** the platform-funded Haiku path (`resolve_platform_funded_model`). The system prompt is versioned in `ai_prompts`. +- **Structured output:** `{answer, cited_chunk_ids[], answerable: bool}`. The model **never emits URLs or titles**. The server resolves citations from `site_chunks` and drops any ID that was not in the retrieved set. That makes citation correctness and URL allow-listing a deterministic check. +- **Grounding policy:** answer only from the provided context, never invent pricing, partners, endorsements, guarantees or capabilities, and state plainly when information is unavailable. Retrieved text is wrapped and labelled as untrusted data, never as instructions. +- **Abuse controls:** per-IP and global daily request caps (the existing Redis limiter with new scopes), a question length limit, and off-topic refusal. When caps are exhausted it fails closed to "browse instead" links. +- **Observability:** OTEL GenAI span attributes (prompt version, model, retrieved IDs and scores, token usage, per-stage latency). Raw question text is **not** put on spans. +- **UI:** the redesign's assistant box on Home (persona toggle, answer with source links, "browse instead" fallback). +- **Evals:** deterministic checks (schema, citation subset, allow-listed URLs, refusal on the unanswerable set), plus an opt-in live suite: hallucination probes ("revenue last year", "government endorsement", "guaranteed funding") and injection fixtures planted in a test corpus. + +## Capabilities + +### New Capabilities +- `site-assistant`: the ask endpoint, grounding, citation resolution, refusal and abuse-control behaviour. +- `site-assistant-ui`: the landing-page assistant box. + +### Modified Capabilities +- `public-site`: Home gains the assistant box. + +## Impact + +- **Depends on:** `ai-feat-site-assistant-retrieval-evals` (hard), `frontend-feat-public-site-content` (hard, via Home). +- **Related:** `chat-fix-prompt-injection-provenance` (same untrusted-data principle and eval layout); `ai-fix-361-provider-ssrf` (egress policy must permit the platform Anthropic origin). +- **Gateway:** covered by the existing `/api/v1/ai/` routes in `nginx.conf`, `nginx-dev.conf` and the Caddyfile. The per-IP limit needs a trusted `X-Forwarded-For` from Caddy. +- **Python dependencies:** none new expected (`pydantic-ai-slim[anthropic]` is already present). +- **Docs:** `docs/security/subprocessors.md`: visitor questions go to Anthropic. The privacy policy needs a line about the assistant. + +## Open Questions + +1. **Cap values:** per-IP per day, global per day, and a monthly spend ceiling. What is the maximum acceptable monthly cost? +2. **Bot protection:** is Cloudflare Turnstile (or similar) required from day one, or are caps enough until abuse shows up? +3. **Streaming:** is a non-streaming response acceptable for v1? Structured output with citations is simpler without streaming. +4. **Conversation memory:** single-turn only (recommended for v1), or short follow-ups? +5. **Legal copy:** does the privacy policy wording need review before launch, given that anonymous questions are sent to a US subprocessor? diff --git a/openspec/changes/ai-feat-site-assistant-retrieval-evals/.openspec.yaml b/openspec/changes/ai-feat-site-assistant-retrieval-evals/.openspec.yaml new file mode 100644 index 00000000..caf86712 --- /dev/null +++ b/openspec/changes/ai-feat-site-assistant-retrieval-evals/.openspec.yaml @@ -0,0 +1,6 @@ +schema: spec-driven +created: 2026-09-29 +author: Norair Arutshyan +priority: medium +depends_on: + - frontend-feat-public-site-content diff --git a/openspec/changes/ai-feat-site-assistant-retrieval-evals/proposal.md b/openspec/changes/ai-feat-site-assistant-retrieval-evals/proposal.md new file mode 100644 index 00000000..e62be2be --- /dev/null +++ b/openspec/changes/ai-feat-site-assistant-retrieval-evals/proposal.md @@ -0,0 +1,41 @@ +## Why + +The site assistant must answer from Open Grant Flow's own pages. Answer quality can't exceed what retrieval returns, so retrieval is built and measured on its own, before any LLM is involved. This change adds ingestion, lexical retrieval and a deterministic evaluation harness that gates CI. Retrieval regressions then become test failures instead of subjective impressions. + +> **Status: skeleton.** The open questions below must be resolved before specs, design and tasks are written. + +## What Changes + +- **Ingestion:** reads the `site-content` section files (published only) plus `docs/user-guide/*.md` and `docs/PRODUCT.md`. It writes one row per section into a new `site_chunks` table in the ai database with `content_id`, `url`, `page_title`, `heading`, `personas`, `text`, `content_version`, and a generated `tsvector`. Guide docs have no frontmatter, so their chunks come from headings. +- **Retrieval:** Postgres full-text search ranked with `ts_rank_cd`. There is an optional persona boost (grantee / funder / technical, matching the redesign's toggle). No LLM and no embeddings. +- **Golden dataset:** about 50 realistic questions, each with its expected `content_id`(s), tagged by persona and category, including unanswerable questions that expect no confident match. +- **Eval harness:** reports Recall@1/3/5 and MRR per category. It includes a **full-context baseline**: how many tokens the whole corpus costs versus top-k, so retrieval has to justify itself. +- **CI gate:** retrieval evals run on every PR that touches content, the retriever or the dataset. The build fails when a metric drops below the committed baseline minus a tolerance. +- **Embeddings / hybrid search:** designed in as a later experiment inside this harness (a sibling `chunk_embeddings(chunk_id, embedding_model, embedding)` table so models can be compared side by side), not built here. + +## Capabilities + +### New Capabilities +- `site-knowledge-index`: ingestion contract, chunk provenance and index freshness. +- `site-retrieval`: query → ranked sections, with persona boost. +- `retrieval-evaluation`: golden dataset format, metrics, baseline file and CI gate semantics. + +### Modified Capabilities +(none expected) + +## Impact + +- **Depends on:** `frontend-feat-public-site-content` (hard; it is the content contract). +- **Related:** `chat-fix-prompt-injection-provenance`, whose opt-in live-eval layout should be reused so there is one eval-harness shape across the repo. +- **ai service:** new `site_chunks` model and Alembic migration, an ingestion command, a retrieval service, and `tests/evals/`. `Dockerfile` must `COPY` the content and docs directories. +- **Python dependencies:** `pyyaml` (frontmatter). No vector dependency yet. +- **Infra:** none for lexical search. The later `pgvector/pgvector:pg15` image swap is recorded, not done. +- **CI:** `ai.yml` already runs Postgres, so retrieval evals need no new service. The workflow needs path triggers for `frontend-typescript/src/content/site/**` and `docs/user-guide/**`. + +## Open Questions + +1. **When does reindexing run in prod?** `deploy.yml` ignores `**/*.md`, `docs/**` and `frontend-typescript/**`, so a content-only merge never redeploys the ai service. Options: index on ai startup plus a manual trigger; a dedicated workflow on content paths; or a Celery task. Leaning toward index on startup plus a content-path workflow. +2. **Guide-doc chunking:** split by H2 or H3, and what canonical URL do guide chunks cite? They have no public page yet. Should they be published, e.g. `/guides/`, which would extend `public-site`? +3. **Who writes the golden dataset?** You alone, or should some questions be collected from real prospects or pilot partners, to avoid an author-biased set? +4. **Gate tolerance:** an absolute floor (e.g. Recall@3 ≥ 0.9), a delta from baseline (e.g. −3 pts), or both? +5. **Should unanswerable questions be scored here** (via a retrieval-score threshold) or only in the generation change? diff --git a/openspec/changes/ai-fix-361-provider-ssrf/tasks.md b/openspec/changes/ai-fix-361-provider-ssrf/tasks.md index e86ad6c9..79f076fd 100644 --- a/openspec/changes/ai-fix-361-provider-ssrf/tasks.md +++ b/openspec/changes/ai-fix-361-provider-ssrf/tasks.md @@ -17,18 +17,18 @@ _Generated by `scripts/flow.py sync ai-fix-361-provider-ssrf` — do not edit by ## 1. Approved-origin policy at save and use time — Issue #362 -- [ ] 1.0 Run `scripts/flow.py start ai-fix-provider-ssrf 1` to move this group to In Progress and create its branch before starting any other work in this group. +- [x] 1.0 Run `scripts/flow.py start ai-fix-provider-ssrf 1` to move this group to In Progress and create its branch before starting any other work in this group. - [ ] 1.1 Draft a read-only SQL count of `user_provider_keys.base_url` grouped by origin and hand it to the user to run on prod; record which origins should be approved before merging. -- [ ] 1.2 Add `AI_PROVIDER_APPROVED_ORIGINS` to AI settings and an `egress_policy` module (parse, normalize, `is_approved(url) -> ApprovedOrigin | None`, reject non-http(s) schemes and userinfo); verify with unit tests covering case, default ports, trailing slashes, userinfo, and `file://`/`gopher://` schemes. -- [ ] 1.3 Enforce it in `POST /ai/settings/keys` (422 for unapproved origins or a `base_url` on providers that don't take one) and replace `_DEFAULT_OLLAMA_BASE_URL` with the configured default; verify with route tests for `http://users:8000`, `http://169.254.169.254`, and an approved origin. -- [ ] 1.4 Enforce it in `OllamaAdapter.build` (return `None` and log `ai_egress_denied`) and pass an `httpx.AsyncClient(follow_redirects=False)` via `http_client`; verify with tests that a stored unapproved row resolves to "no provider" and, using `respx`, that a 302 to an internal address is not followed. +- [x] 1.2 Add `AI_PROVIDER_APPROVED_ORIGINS` to AI settings and an `egress_policy` module (parse, normalize, `is_approved(url) -> ApprovedOrigin | None`, reject non-http(s) schemes and userinfo); verify with unit tests covering case, default ports, trailing slashes, userinfo, and `file://`/`gopher://` schemes. +- [x] 1.3 Enforce it in `POST /ai/settings/keys` (422 for unapproved origins or a `base_url` on providers that don't take one) and replace `_DEFAULT_OLLAMA_BASE_URL` with the configured default; verify with route tests for `http://users:8000`, `http://169.254.169.254`, and an approved origin. +- [x] 1.4 Enforce it in `OllamaAdapter.build` (return `None` and log `ai_egress_denied`) and pass an `httpx.AsyncClient(follow_redirects=False)` via `http_client`; verify with tests that a stored unapproved row resolves to "no provider" and, using `respx`, that a 302 to an internal address is not followed. - [ ] 1.5 Ship per-deployment-mode defaults for `AI_PROVIDER_APPROVED_ORIGINS`, all `allow_private: true`: `http://localhost:11434` for `dev.sh` (host-run services); `http://host.docker.internal:11434` for `docker-compose.local.yml`, adding `extra_hosts: ["host.docker.internal:host-gateway"]` to its `ai` service; the operator tailnet origin for prod (tailnet `100.64/10` addresses fail the public-only check). Hand the env-file values to the user to set (private env files are not read directly); verify by saving an Ollama config and chatting in both local modes. - [ ] 1.6 Add the variable to CI. Run `pytest services/ai` and `flake8 --max-line-length=100` clean; PR merged. ## 2. Connect-time address pinning and endpoint picker — ticket depends on 1 — Issue #363 - [ ] 2.0 Run `scripts/flow.py start ai-fix-provider-ssrf 2` to move this group to In Progress and create its branch before starting any other work in this group. -- [ ] 2.1 Implement the pinned-address transport for public-only origins (async resolve, `ipaddress` validation including CGNAT, link-local, ULA and IPv4-mapped addresses, connect to the validated IP while keeping Host/SNI); verify with mocked-resolver tests for a private answer, mixed public and private answers, and a rebinding sequence (public, then private). -- [ ] 2.2 Wire it into `OllamaAdapter` for origins without `allow_private`; verify that an approved private-allowed origin still works against a local stub server. -- [ ] 2.3 Expose `approved_endpoints` in the provider catalog response and replace the free-text URL input in `AiIntegrationsSection.tsx` with a picker; verify with the component test and a manual check on local dev. +- [x] 2.1 Implement the pinned-address transport for public-only origins (async resolve, `ipaddress` validation including CGNAT, link-local, ULA and IPv4-mapped addresses, connect to the validated IP while keeping Host/SNI); verify with mocked-resolver tests for a private answer, mixed public and private answers, and a rebinding sequence (public, then private). +- [x] 2.2 Wire it into `OllamaAdapter` for origins without `allow_private`; verify that an approved private-allowed origin still works against a local stub server. +- [x] 2.3 Expose `approved_endpoints` in the provider catalog response and replace the free-text URL input in `AiIntegrationsSection.tsx` with a picker; verify with the component test and a manual check on local dev. - [ ] 2.4 Run `pytest services/ai`, `flake8 --max-line-length=100`, and frontend `npm test`/`npm run lint` clean; PR merged. diff --git a/openspec/changes/frontend-feat-public-site-content/.openspec.yaml b/openspec/changes/frontend-feat-public-site-content/.openspec.yaml new file mode 100644 index 00000000..c04bf02e --- /dev/null +++ b/openspec/changes/frontend-feat-public-site-content/.openspec.yaml @@ -0,0 +1,4 @@ +schema: spec-driven +created: 2026-09-29 +author: Norair Arutshyan +priority: medium diff --git a/openspec/changes/frontend-feat-public-site-content/design.md b/openspec/changes/frontend-feat-public-site-content/design.md new file mode 100644 index 00000000..fb87cf94 --- /dev/null +++ b/openspec/changes/frontend-feat-public-site-content/design.md @@ -0,0 +1,81 @@ +# Design + +## Context + +- `frontend-typescript/src/pages/LandingPage.tsx` renders one scrolling page. Its copy lives in JSX and in module-level constants (`SECTOR_QUOTES`, `WORKFLOW_STEPS`, `NAV_LINKS`). Nav links are `#anchor` hrefs, and the nav is `hidden sm:flex`, so mobile visitors get no links at all. +- `App.tsx` registers `/` and `/legal` as public routes, and a `*` catch-all redirects to `/dashboard`. `LandingPage` redirects authenticated users to `/dashboard` itself. +- The frontend is deployed two ways, and both treat `frontend-typescript/` as the root: + - Vercel: `vercel.json` rewrites every path to `index.html`. + - Docker: the `Dockerfile` does `COPY . .` inside `frontend-typescript/`, and `nginx.frontend.conf` uses `try_files`. +- The ai service image is built with context `.` (the repo root) in `docker-compose.prod.yml`, so it can read files anywhere in the repo. +- The contact form posts to web3forms directly from the browser. +- The source of truth for copy is the redesign draft (five pages, with TBC-tagged items). See proposal.md. + +## Goals / Non-Goals + +**Goals:** +- Content files are the only place copy lives. Their format is simple enough to parse from both TypeScript and Python. +- Section-level granularity, so a section can later be one retrieval chunk with its own citation URL. +- No new build step, Vite plugin or backend. + +**Non-Goals:** +- A CMS, or content editing outside git. +- Server-side rendering or prerendering for SEO. Worth revisiting later; it does not affect the content contract. +- The assistant UI or any AI behaviour. +- Moving `/legal` copy into content files. It has its own spec (`privacy-policy`) and is not in the assistant's planned corpus. + +## Decisions + +### D1. Content lives in `frontend-typescript/src/content/site//.md` +Keeping it inside the frontend root means both the Vercel and Docker frontend builds see it with no configuration change. The ai service reaches it through its repo-root build context (`COPY frontend-typescript/src/content/site ...`), which is what `ai-feat-site-assistant-retrieval-evals` will do. +- *Alternative, repo-root `content/site/`:* conceptually cleaner, since two consumers share it. Rejected because Vercel's root-directory setting would have to allow files outside the root, and the Docker frontend build context would have to widen to the repo root. That is a deploy change for no user-visible gain. + +### D2. One file per section, not one file per page +A section is the smallest thing that has its own heading, URL fragment and citation. One file per section means: +- frontmatter maps 1:1 to provenance metadata; +- `git log` on a file is that section's history; +- the downstream chunker can start with "one section = one chunk" as its baseline. +- *Alternative, one file per page with heading-delimited sections:* fewer files, but IDs and anchors would come from parsing headings, which is fragile, and structured items would need a second syntax. + +### D3. Frontmatter for structure, markdown body for prose +Card grids, the five steps, quotes and FAQs go in a frontmatter `items` list, and prose goes in the body. Page components pick the layout. +- *Alternative, a generic page renderer driven by a `layout` field:* rejected. The redesign's layouts vary a lot, and a layout DSL would be a second design system to maintain. + +### D4. Loader: `import.meta.glob` + a `yaml` frontmatter split, validated at load +`src/lib/siteContent.ts`: +- eagerly globs `?raw` markdown; +- splits on the leading `---` fence and parses it with `yaml`; +- checks the contract (required fields, allowed `page`/`personas`/`status` values, unique `id`, unique `anchor` per page); +- exposes `getSection(id)`, which throws on an unknown ID. +The corpus is a few KB, so eager bundling costs nothing. Because validation throws at import, dev, tests and CI all fail on the first violation. `gray-matter` is avoided because it depends on Node `Buffer`. + +### D5. Missing-section detection through page render tests +Each public page has a render test, and `getSection` throws on unknown IDs, so rendering every page in CI checks every reference. That covers the spec's "page references a deleted section" scenario without a separate registry of IDs. + +### D6. Drafts are filtered in the loader via `import.meta.env.PROD` +In production builds, `getSection` returns `null` for drafts and pages skip them. In other builds, drafts render inside a `TbcMarker` wrapper. Draft text still ships in the JS bundle, which is acceptable because the repository is public anyway. The guarantee is "not rendered", not "secret". + +### D7. `react-markdown` with defaults, plus a link override +`react-markdown` does not render raw HTML unless `rehype-raw` is added, and we won't add it. A `components.a` override sends internal links (`/…`) through React Router's `Link` and gives external links `rel="noopener noreferrer"`. + +### D8. Shared `PublicLayout` with hash scrolling +A layout route wraps the five pages and renders the header (with a mobile menu toggle), the footer and an effect that scrolls to `location.hash` after navigation. React Router doesn't do that on its own, and `/how-it-works#five-steps` has to land on the section because later changes will use it as a citation target. Page titles use React 19's native `` hoisting, so no helmet dependency is needed. + +### D9. Legacy anchor redirects on Home +Existing inbound links such as `/#contact` and `/#about` would otherwise land at the top of Home. Home maps the known legacy hashes to their new pages: `#contact`→`/contact`, `#about` and `#vision`→`/about`, `#platform` and `#problem`→`/how-it-works`, `#founding-partners`→`/contact#pilot`. + +## Risks / Trade-offs + +- [Rewriting copy loses the current spec'd content, such as the anecdote] → The `landing-page` delta keeps the sector-validation requirement, including the anecdote, now placed on How it works. The page tests assert it. +- [Authors break an ID or anchor that later changes cite] → The spec treats that as a breaking content change. Once the retrieval change lands, its golden dataset will fail on a dangling ID, so the loss is caught by CI, not by visitors. +- [Draft text is visible in the bundle] → Accepted (D6). The repo is public. +- [No prerendering hurts SEO for five new URLs] → Same as today's single page. Deferred. + +## Migration Plan + +Frontend only. Merging to `main` deploys through Vercel as today. Rollback is a revert. There is no data or API migration. The legacy-hash redirects (D9) keep old inbound links working. + +## Open Questions + +- Placement of the pilot-programme section: the Contact page (assumed) or its own block on About. Only a section file's `page` field changes. +- The TBC copy items listed in proposal.md. Each ships as `draft` and flips to `published` in a later copy-only PR. diff --git a/openspec/changes/frontend-feat-public-site-content/proposal.md b/openspec/changes/frontend-feat-public-site-content/proposal.md new file mode 100644 index 00000000..297b5fac --- /dev/null +++ b/openspec/changes/frontend-feat-public-site-content/proposal.md @@ -0,0 +1,31 @@ +## Why + +The public site is one 861-line `LandingPage.tsx`, and all its copy is hard-coded in JSX. A reviewed redesign draft splits it into five pages (Home, How it works, Security & data, About, Contact). The planned site assistant (`ai-feat-site-assistant-retrieval-evals` and later) needs that same copy as addressable, versioned content, with an ID, URL, page and heading for every section. Content files give both one source of truth. + +## What Changes + +- Split the landing page into five public routes: `/`, `/how-it-works`, `/security`, `/about`, `/contact`. They share a header (with a mobile menu), a footer and per-page `<title>`s. `/legal` is unchanged. +- Move all public-page copy into markdown files with YAML frontmatter under `frontend-typescript/src/content/site/`, **one file per section**. Frontmatter carries a stable `id`, `page`, `anchor`, `title`, optional `personas`, a `status` (`published` | `draft`) and optional structured `items` (cards, steps, quotes, FAQs). +- Page components own the layout. Copy comes from the content loader by section ID, so a copy edit never touches TSX. +- `draft` sections render only in non-production builds, with a visible "TBC" marker. They are never rendered in production. This is how the redesign's unresolved TBC items ship safely. +- A content validation test in the existing frontend CI job fails on duplicate IDs, missing fields, or sections that pages reference but that don't exist. +- The "Request Demo" CTA and all contact links point to `/contact`. The contact form moves there and gains the organisation-type select and the pilot-interest checkbox from the redesign. It still submits through web3forms. +- The "Founding Design Partner" programme copy is renamed "Pilot Partner", following the redesign. +- **Out of scope:** the assistant input box shown in the redesign. It lands with `ai-feat-site-assistant-grounded-answers`, and no non-functional box ships before then. + +## Capabilities + +### New Capabilities +- `site-content`: the content-file contract (fields, ID and anchor stability, draft/published lifecycle, validation). Later changes ingest content through this contract. +- `public-site`: the multi-page public site (routes, shared navigation including mobile, footer, per-page titles, contact page). + +### Modified Capabilities +- `landing-page`: the Request Demo CTA now targets `/contact` instead of `#contact`, and the sector-validation evidence moves from the home page's Problem section to the How it works page. + +## Impact + +- **Frontend:** `LandingPage.tsx` is replaced by `src/pages/site/*`, `src/content/site/**` and `src/lib/siteContent.ts`. `App.tsx` gains public routes, registered before the `*` → `/dashboard` catch-all. `LandingPage.test.tsx` is replaced by per-page tests and the content validation test. +- **New npm dependencies:** `react-markdown` (renders section bodies with no raw-HTML passthrough) and `yaml` (a browser-safe frontmatter parser). `gray-matter` is avoided because it needs Node `Buffer` polyfills under Vite. +- **Hosting:** Vercel (`vercel.json`) already rewrites every path to `index.html`, and so does `nginx.frontend.conf` (`try_files`), so no gateway change is needed. +- **Downstream:** `ai-feat-site-assistant-retrieval-evals` depends on this change and reads the same content files. +- **Content owner input:** the redesign's TBC items (hosting region, GDPR wording, CIC registration status, pilot month details and time expectations) ship as `draft` until confirmed. None of them blocks merging. diff --git a/openspec/changes/frontend-feat-public-site-content/specs/landing-page/spec.md b/openspec/changes/frontend-feat-public-site-content/specs/landing-page/spec.md new file mode 100644 index 00000000..5cbb949f --- /dev/null +++ b/openspec/changes/frontend-feat-public-site-content/specs/landing-page/spec.md @@ -0,0 +1,24 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: Nav offers a Request Demo call-to-action +The top navigation bar SHALL include a "Request Demo" call-to-action, visually distinct from the plain text nav links (e.g. rendered as a button), that links to the Contact page (`/contact`). + +#### Scenario: Visitor wants a demo from anywhere on the page +- **WHEN** a visitor views the top navigation bar +- **THEN** a "Request Demo" button is visible in the nav +- **AND** activating it navigates to the Contact page (`/contact`) + +### Requirement: Problem section includes sector-validation evidence +The How it works page SHALL include a block of sector-validation content consisting of at least three anonymized pull-quotes attributed by role only (no invented names or organizations), one paraphrased first-hand anecdote visually distinguished from the verbatim quotes, and a closing statement summarizing the market opportunity. + +#### Scenario: Visitor reads the problem section +- **WHEN** a visitor scrolls to the sector-validation block on the How it works page +- **THEN** they see role-attributed pull-quotes describing sector pain points +- **AND** they see a paraphrased anecdote presented without quotation marks and visually distinguished from the verbatim quotes +- **AND** they see a closing statement about the market opportunity + +#### Scenario: No fabricated attribution +- **WHEN** the sector-validation block is rendered +- **THEN** no quote is attributed to a specific named person or organization that was not actually provided as source material diff --git a/openspec/changes/frontend-feat-public-site-content/specs/public-site/spec.md b/openspec/changes/frontend-feat-public-site-content/specs/public-site/spec.md new file mode 100644 index 00000000..51994b1b --- /dev/null +++ b/openspec/changes/frontend-feat-public-site-content/specs/public-site/spec.md @@ -0,0 +1,62 @@ +# Spec Delta + +## Purpose + +Defines the multi-page public website that unauthenticated visitors see (routes, shared navigation, footer, page titles and the contact page). It replaces the single scrolling landing page. + +## ADDED Requirements + +### Requirement: Public pages are served at dedicated routes +The public site SHALL serve these pages to unauthenticated visitors: Home at `/`, How it works at `/how-it-works`, Security & data at `/security`, About at `/about`, and Contact at `/contact`. Each route SHALL survive a hard reload or direct link. It SHALL NOT be redirected to `/dashboard` by the catch-all route. + +#### Scenario: Direct link to a public page +- **WHEN** an unauthenticated visitor opens `/how-it-works` directly +- **THEN** the How it works page renders + +#### Scenario: Authenticated visitor on home +- **WHEN** an authenticated user opens `/` +- **THEN** they are redirected to `/dashboard`, as today + +#### Scenario: Authenticated visitor on an inner public page +- **WHEN** an authenticated user opens `/security` +- **THEN** the Security & data page renders without redirect + +### Requirement: Shared navigation on every public page +Every public page SHALL show the same header. It SHALL contain links to How it works, Security, About and Contact, plus a visually distinct "Request Demo" button linking to `/contact`. On viewports narrower than the small breakpoint, the links SHALL be reachable through a menu toggle rather than hidden. + +#### Scenario: Mobile navigation +- **WHEN** a visitor on a narrow viewport opens the menu toggle +- **THEN** all four page links and "Request Demo" are visible and navigable + +### Requirement: Shared footer on every public page +Every public page SHALL show a footer with links to Home, How it works, Security, About, Contact, GitHub, Privacy Policy (`/legal#privacy`) and Terms (`/legal#terms`). + +#### Scenario: Visitor reaches the privacy policy from any page +- **WHEN** a visitor activates "Privacy Policy" in the footer of any public page +- **THEN** they arrive at the privacy section of `/legal` + +### Requirement: Each public page has a distinct document title +Each public page SHALL set a document title that names the page, e.g. "How it works · Open Grant Flow". + +#### Scenario: Browser tab title +- **WHEN** a visitor navigates from Home to About +- **THEN** the document title changes to the About page title + +### Requirement: Contact page collects demo requests +The Contact page SHALL provide a demo-request form with name, work email, organisation and organisation type (Nonprofit or NGO, Foundation, Institutional donor, Other) as required fields. It SHALL also have an optional free-text question about reporting time and an optional pilot-interest checkbox. The form SHALL link to the privacy policy. It SHALL show success or failure after submission. + +#### Scenario: Successful demo request +- **WHEN** a visitor submits the form with all required fields +- **THEN** the request is sent, including the organisation type and pilot-interest value +- **AND** a success message replaces or accompanies the form + +#### Scenario: Missing required field +- **WHEN** a visitor submits without a work email +- **THEN** the form is not sent and the field is flagged + +### Requirement: Product demo embed remains on the home page +The Home page SHALL keep the existing interactive product demo embed. + +#### Scenario: Visitor views the demo +- **WHEN** a visitor loads `/` +- **THEN** the product demo iframe is present diff --git a/openspec/changes/frontend-feat-public-site-content/specs/site-content/spec.md b/openspec/changes/frontend-feat-public-site-content/specs/site-content/spec.md new file mode 100644 index 00000000..544cc2b6 --- /dev/null +++ b/openspec/changes/frontend-feat-public-site-content/specs/site-content/spec.md @@ -0,0 +1,71 @@ +# Spec Delta + +## Purpose + +Defines how public-site copy is authored: as versioned, individually addressable section files. The rendered pages and downstream consumers such as the site assistant's retrieval index read the same content through one contract. + +## ADDED Requirements + +### Requirement: Public-site copy is stored as one content file per section +All copy on the public pages (Home, How it works, Security & data, About, Contact) SHALL be stored in repository content files, one file per page section. It SHALL NOT be embedded in page component source. Each file SHALL consist of YAML frontmatter followed by an optional markdown body. + +#### Scenario: Copy edit without code change +- **WHEN** an author changes the wording of a public-page section +- **THEN** the change is confined to that section's content file +- **AND** no page component source file is modified + +#### Scenario: Section without prose body +- **WHEN** a section consists only of structured items (for example a list of cards) +- **THEN** its content file MAY have an empty markdown body and carry the items in frontmatter + +### Requirement: Section frontmatter carries provenance metadata +Every section file's frontmatter SHALL include: +- `id`: a globally unique, stable identifier in the form `<page>.<slug>` +- `page`: one of `home`, `how-it-works`, `security`, `about`, `contact` +- `anchor`: a URL fragment, unique within its page +- `title`: the section heading as shown to visitors +- `status`: `published` or `draft` + +It MAY include `personas`, a subset of `grantee`, `funder` and `technical`, and `items`, a list of structured entries. Each item SHALL have a `title` or `quote` and a `body` or `attribution`. Each section's canonical URL SHALL be derivable as `<page route>#<anchor>`. + +#### Scenario: Canonical URL derivation +- **WHEN** a section has `page: how-it-works` and `anchor: five-steps` +- **THEN** its canonical URL is `/how-it-works#five-steps` +- **AND** visiting that URL scrolls to the rendered section + +#### Scenario: Missing required field +- **WHEN** a section file omits `id`, `page`, `anchor`, `title` or `status` +- **THEN** content validation fails and names the file and the missing field + +### Requirement: Section IDs and anchors are stable +A section's `id` and `anchor` SHALL NOT change when only its copy changes. Renaming or removing an `id` or `anchor` SHALL be treated as a breaking content change, because downstream consumers cite sections by ID and URL. + +#### Scenario: Duplicate identifier +- **WHEN** two section files declare the same `id`, or the same `anchor` on the same page +- **THEN** content validation fails and names both files + +### Requirement: Draft content never reaches production +Sections with `status: draft` SHALL NOT be rendered in a production build. In non-production builds they SHALL render with a visible "TBC" marker that distinguishes them from published copy. Consumers other than the page renderer SHALL be able to exclude draft sections using the same field. + +#### Scenario: Unconfirmed claim in production +- **WHEN** a section containing unconfirmed wording is marked `status: draft` +- **AND** the site is built for production +- **THEN** that section does not appear anywhere on the rendered page + +#### Scenario: Reviewing drafts locally +- **WHEN** the site runs as a development or preview build +- **THEN** draft sections appear with a visible "TBC" marker + +### Requirement: Content is validated in CI +Content validation SHALL run in the frontend CI workflow and fail the build on any contract violation, including a section ID that a page references but that does not exist. + +#### Scenario: Page references a deleted section +- **WHEN** a page renders section `about.mission` and no file declares that ID +- **THEN** the frontend CI job fails + +### Requirement: Section bodies render without raw HTML +Markdown section bodies SHALL be rendered with raw HTML disabled. HTML in a content file SHALL be displayed as text or dropped, never injected into the page. + +#### Scenario: HTML in content body +- **WHEN** a section body contains `<script>` or other raw HTML +- **THEN** the rendered page contains no corresponding HTML element diff --git a/openspec/changes/frontend-feat-public-site-content/tasks.md b/openspec/changes/frontend-feat-public-site-content/tasks.md new file mode 100644 index 00000000..b9a87f46 --- /dev/null +++ b/openspec/changes/frontend-feat-public-site-content/tasks.md @@ -0,0 +1,34 @@ +# Tasks + +Workflow rule: one task group = one GitHub sub-issue (of this change's parent issue) = one PR, merged before the next group starts. + +## 1. Content contract, loader and first content-driven pages (How it works, Security & data) + +- [ ] 1.0 Run `scripts/flow.py start frontend-feat-public-site-content 1` to move this group to In Progress and create its branch before starting any other work in this group. +- [ ] 1.1 Add `react-markdown` and `yaml` to `frontend-typescript/package.json`; verify `npm ci` and `npx vitest run` still pass. +- [ ] 1.2 Implement `src/lib/siteContent.ts` (D4, D6): eager `?raw` glob of `src/content/site/**/*.md`, frontmatter split, contract validation, `getSection(id)` throwing on unknown IDs, and draft filtering by `import.meta.env.PROD`. Verify with `siteContent.test.ts` covering: missing field, bad `page`/`status`/`personas` value, duplicate `id`, duplicate anchor on the same page, draft hidden when PROD is stubbed true and shown when false. +- [ ] 1.3 Add a `SiteMarkdown` component (D7) and a `TbcMarker` wrapper. Verify with tests that raw `<script>`/HTML in a body produces no element, internal links render as router links, and external links get `rel="noopener noreferrer"`. +- [ ] 1.4 Add `PublicLayout` (D8): shared header with the mobile menu toggle, footer (spec links including `/legal#privacy` and `/legal#terms`), and hash scrolling. Verify with tests that the mobile toggle exposes all nav links and that `/how-it-works#five-steps` scrolls to that section (`scrollIntoView` spy). +- [ ] 1.5 Author the How it works content files from the redesign: intro, five steps, survey evidence, roadmap, "who it's for" (grantee and funder items), and the sector-validation block (≥3 role-attributed quotes, the existing paraphrased anecdote carried over from `LandingPage.tsx`, the closing statement). Build `src/pages/site/HowItWorks.tsx` on it. Verify with a render test covering the `landing-page` sector-validation scenarios. +- [ ] 1.6 Author the Security & data content files, with TBC items (GDPR wording, data location) as `status: draft`, and build `src/pages/site/Security.tsx`. Verify with a render test that draft sections are absent under a PROD stub. +- [ ] 1.7 Register `/how-it-works` and `/security` under `PublicLayout` in `App.tsx`, before the `*` catch-all, with per-page `<title>`s. Verify with a routing test that a direct visit renders each page for both an anonymous and an authenticated user, and that each title is set. +- [ ] 1.8 Add a short author guide at `src/content/site/README.md` (fields, ID/anchor stability rule, draft lifecycle). Verify it matches the `site-content` spec fields. +- [ ] 1.9 Run `npx vitest run`, `npm run lint` and `npx tsc --noEmit -p tsconfig.app.json` in `frontend-typescript` clean; PR merged. + +## 2. About and Contact pages — ticket depends on 1 + +- [ ] 2.0 Run `scripts/flow.py start frontend-feat-public-site-content 2` to move this group to In Progress and create its branch before starting any other work in this group. +- [ ] 2.1 Author the About content files (mission, origin story, values, "how we are set up" with the CIC status as `draft`, open-code link) and build `src/pages/site/About.tsx`. Verify with a render test. +- [ ] 2.2 Author the Contact content files: pilot programme (`anchor: pilot`, with month details as `draft`), what you receive and what we ask, demo-request intro, and FAQ items (the time-expectations answer as `draft`). +- [ ] 2.3 Build `src/pages/site/Contact.tsx` with the demo-request form moved out of `LandingPage.tsx`. Add the organisation-type select, the optional reporting-time question and the pilot-interest checkbox, all sent through the existing web3forms submission. Verify with tests for the successful-submit (mocked `fetch`, payload includes the new fields) and missing-work-email scenarios. +- [ ] 2.4 Register `/about` and `/contact` in `App.tsx` with titles. Point every "Request Demo" CTA, including the current landing nav, to `/contact`. Verify with the routing test and a nav test asserting `href="/contact"`. +- [ ] 2.5 Run `npx vitest run`, `npm run lint` and `npx tsc --noEmit -p tsconfig.app.json` in `frontend-typescript` clean; PR merged. + +## 3. Home page from content and retirement of the single-page landing — ticket depends on 1, 2 + +- [ ] 3.0 Run `scripts/flow.py start frontend-feat-public-site-content 3` to move this group to In Progress and create its branch before starting any other work in this group. +- [ ] 3.1 Author the Home content files (hero, "browse instead" links, what you get back, values summary, pilot teaser) and build `src/pages/site/Home.tsx` under `PublicLayout`. Keep the product demo embed and the authenticated redirect to `/dashboard`. Verify with render tests for the demo iframe, the absence of any in-development status pill, and the authenticated redirect. +- [ ] 3.2 Add the legacy hash redirects on Home (D9). Verify with a test per legacy hash (`#contact`, `#about`, `#vision`, `#platform`, `#problem`, `#founding-partners`). +- [ ] 3.3 Delete `LandingPage.tsx` and `LandingPage.test.tsx`, and move every assertion still relevant into the new page tests. Verify with `grep -rn "LandingPage" frontend-typescript/src` returning nothing, and confirm no copy string remains hard-coded in `src/pages/site/*.tsx` (only section IDs). +- [ ] 3.4 Run a production build check (`npx vite build`, then `npx vite preview`). Manually verify all five routes and a hard reload on `/security#…`, confirm no TBC marker or draft copy is visible, and confirm the mobile menu works at phone width. +- [ ] 3.5 Run `npx vitest run`, `npm run lint` and `npx tsc --noEmit -p tsconfig.app.json` in `frontend-typescript` clean; PR merged. diff --git a/services/ai/.env.ai.private.dev b/services/ai/.env.ai.private.dev index 0a745d04..1f6d6c30 100644 --- a/services/ai/.env.ai.private.dev +++ b/services/ai/.env.ai.private.dev @@ -14,3 +14,4 @@ OLLAMA_MODEL=llama3.2 AI_RATE_LIMIT_PER_HOUR=100 REDIS_URL=redis://localhost:6379 LOG_LEVEL=INFO +AI_PROVIDER_APPROVED_ORIGINS='[{"origin": "http://localhost:11434", "allow_private": true, "label": "Local Ollama"}]' diff --git a/services/ai/app/api/settings_routes.py b/services/ai/app/api/settings_routes.py index 82b38fef..e5d1100b 100644 --- a/services/ai/app/api/settings_routes.py +++ b/services/ai/app/api/settings_routes.py @@ -8,6 +8,7 @@ from app.crud.customer_ai_defaults import get as get_customer_ai_defaults, set_platform_fallback from app.crud.user_provider_key import create, delete, list_for_customer, set_default from app.db.session import AsyncSessionLocal +from app.services import egress_policy from app.services.provider import _REGISTRY from app.utils.encryption import decrypt, encrypt from app.utils.security import get_validated_user, resolve_customer_id @@ -15,7 +16,6 @@ router = APIRouter(prefix="/ai/settings", tags=["AI Settings"]) _ADMIN_ROLES = {"superuser", "admin"} -_DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434" async def get_db(): @@ -49,9 +49,15 @@ class ProviderKeyConfig(BaseModel): is_default: bool +class ApprovedEndpoint(BaseModel): + origin: str + label: str + + class SettingsResponse(BaseModel): configs: list[ProviderKeyConfig] platform_fallback_enabled: bool + approved_endpoints: list[ApprovedEndpoint] class CreateKeyRequest(BaseModel): @@ -71,6 +77,23 @@ class PlatformFallbackRequest(BaseModel): enabled: bool +def _resolve_and_validate_base_url(provider, requested_base_url: str | None) -> str | None: + """Enforce the operator's approved-origin policy for key-less providers + (e.g. Ollama); reject a base_url on providers that require a key.""" + if provider.key_prefix: + if requested_base_url: + raise HTTPException( + status_code=422, detail=f"{provider.name} does not accept a base_url" + ) + return None + base_url = requested_base_url or settings.OLLAMA_URL + if base_url: + approved = egress_policy.parse_approved_origins(settings.AI_PROVIDER_APPROVED_ORIGINS) + if egress_policy.is_approved(base_url, approved) is None: + raise HTTPException(status_code=422, detail="base_url is not an approved endpoint") + return base_url + + async def _validate_key_with_provider( provider_name: str, key_prefix: str | None, key: str | None ) -> None: @@ -108,9 +131,13 @@ async def _build_settings_response(customer_id: str, db: AsyncSession) -> Settin ) ) defaults = await get_customer_ai_defaults(customer_id, db) + approved = egress_policy.parse_approved_origins(settings.AI_PROVIDER_APPROVED_ORIGINS) return SettingsResponse( configs=items, platform_fallback_enabled=bool(defaults and defaults.platform_fallback_enabled), + approved_endpoints=[ + ApprovedEndpoint(origin=o.origin, label=o.label or o.origin) for o in approved + ], ) @@ -141,9 +168,7 @@ async def create_ai_key( ) await _validate_key_with_provider(provider.name, provider.key_prefix, body.key) encrypted = encrypt(body.key, settings.ENCRYPTION_KEY) if body.key else None - base_url = body.base_url - if not provider.key_prefix and not base_url: - base_url = _DEFAULT_OLLAMA_BASE_URL + base_url = _resolve_and_validate_base_url(provider, body.base_url) user_id = str(valid_user["user_id"]) customer_id = resolve_customer_id(valid_user) await create( diff --git a/services/ai/app/core/config.py b/services/ai/app/core/config.py index 1d965541..7f26d2de 100644 --- a/services/ai/app/core/config.py +++ b/services/ai/app/core/config.py @@ -1,4 +1,5 @@ import os +from pydantic import field_validator from pydantic_settings import BaseSettings, SettingsConfigDict from pathlib import Path @@ -23,6 +24,9 @@ class Settings(BaseSettings): ENCRYPTION_KEY: str # 32-byte base64-encoded secret for AES-256-GCM OLLAMA_URL: str | None = None OLLAMA_MODEL: str = "llama3.2" + # JSON list of {"origin": "http://host:port", "allow_private": bool} — see + # app/services/egress_policy.py. Operator-set only, never tenant-facing. + AI_PROVIDER_APPROVED_ORIGINS: str | None = None AI_RATE_LIMIT_PER_HOUR: int = 100 # Separate, much tighter cap on the GrantFlow-funded Excel-import # fallback specifically (see budget-export-from-excel design.md Decision @@ -31,6 +35,15 @@ class Settings(BaseSettings): # yet informed by real usage data. AI_EXCEL_IMPORT_PLATFORM_RATE_LIMIT_PER_HOUR: int = 10 + @field_validator("AI_PROVIDER_APPROVED_ORIGINS") + @classmethod + def _validate_approved_origins(cls, raw: str | None) -> str | None: + # Fail at startup rather than 500 on every request. + from app.services.egress_policy import parse_approved_origins + + parse_approved_origins(raw) + return raw + model_config = SettingsConfigDict(env_file=ENV_FILE, case_sensitive=False, extra="ignore") diff --git a/services/ai/app/services/adapters/ollama.py b/services/ai/app/services/adapters/ollama.py index 7567cec5..23767ee7 100644 --- a/services/ai/app/services/adapters/ollama.py +++ b/services/ai/app/services/adapters/ollama.py @@ -1,23 +1,53 @@ +import httpx from pydantic_ai.models.openai import OpenAIChatModel from pydantic_ai.providers.openai import OpenAIProvider +from app.core.logging import get_logger +from app.services.pinned_transport import build_public_only_transport from app.services.provider import ProviderAdapter, ResolvedModel, register +logger = get_logger(__name__) + +_http_clients: dict[bool, httpx.AsyncClient] = {} + + +def _shared_http_client(allow_private: bool) -> httpx.AsyncClient: + # One pooled client per egress mode; base_url is applied by OpenAIProvider. + client = _http_clients.get(allow_private) + if client is None or client.is_closed: + transport = None if allow_private else build_public_only_transport() + client = httpx.AsyncClient(transport=transport, follow_redirects=False) + _http_clients[allow_private] = client + return client + @register("ollama") class OllamaAdapter(ProviderAdapter): def build(self, user_key) -> ResolvedModel | None: from app.core.config import settings + from app.services import egress_policy base_url = user_key.base_url or settings.OLLAMA_URL if not base_url: return None + + approved = egress_policy.parse_approved_origins(settings.AI_PROVIDER_APPROVED_ORIGINS) + origin = egress_policy.is_approved(base_url, approved) + if origin is None: + logger.warning( + "ai_egress_denied", + customer_id=str(user_key.customer_id), + origin=egress_policy.normalize_origin(base_url), + ) + return None + return ResolvedModel( model=OpenAIChatModel( user_key.model_name, provider=OpenAIProvider( base_url=f"{base_url.rstrip('/')}/v1", api_key="ollama", + http_client=_shared_http_client(origin.allow_private), ), ), provider_name="ollama", diff --git a/services/ai/app/services/egress_policy.py b/services/ai/app/services/egress_policy.py new file mode 100644 index 00000000..fac4a56e --- /dev/null +++ b/services/ai/app/services/egress_policy.py @@ -0,0 +1,67 @@ +import json +from dataclasses import dataclass +from urllib.parse import urlsplit + +_DEFAULT_PORTS = {"http": 80, "https": 443} + + +@dataclass(frozen=True) +class ApprovedOrigin: + origin: str + allow_private: bool + label: str | None = None + + +def normalize_origin(url: str) -> str | None: + """Return "scheme://host[:port]" for a plain http(s) URL, or None if the + URL is malformed, isn't http(s), or carries embedded credentials.""" + try: + parts = urlsplit(url) + port = parts.port + except ValueError: + return None + if parts.scheme not in ("http", "https"): + return None + if parts.username or parts.password: + return None + host = parts.hostname + if not host: + return None + host = host.lower() + if ":" in host: + host = f"[{host}]" + if port is None or port == _DEFAULT_PORTS[parts.scheme]: + return f"{parts.scheme}://{host}" + return f"{parts.scheme}://{host}:{port}" + + +def parse_approved_origins(raw: str | None) -> list[ApprovedOrigin]: + """Parse AI_PROVIDER_APPROVED_ORIGINS: a JSON list of + {"origin", "allow_private", "label"?}; any malformed value raises ValueError.""" + if not raw: + return [] + entries = json.loads(raw) + if not isinstance(entries, list): + raise ValueError("AI_PROVIDER_APPROVED_ORIGINS: expected a JSON list") + parsed = [] + for entry in entries: + if not isinstance(entry, dict) or not isinstance(entry.get("origin"), str): + raise ValueError(f"AI_PROVIDER_APPROVED_ORIGINS: entry needs an origin: {entry!r}") + normalized = normalize_origin(entry["origin"]) + if normalized is None: + raise ValueError(f"AI_PROVIDER_APPROVED_ORIGINS: invalid origin {entry['origin']!r}") + parsed.append( + ApprovedOrigin( + origin=normalized, + allow_private=bool(entry.get("allow_private")), + label=entry.get("label"), + ) + ) + return parsed + + +def is_approved(url: str, approved: list[ApprovedOrigin]) -> ApprovedOrigin | None: + normalized = normalize_origin(url) + if normalized is None: + return None + return next((entry for entry in approved if entry.origin == normalized), None) diff --git a/services/ai/app/services/pinned_transport.py b/services/ai/app/services/pinned_transport.py new file mode 100644 index 00000000..9efc6023 --- /dev/null +++ b/services/ai/app/services/pinned_transport.py @@ -0,0 +1,51 @@ +import asyncio +import ipaddress +import socket + +import httpcore +import httpx + +_CGNAT = ipaddress.ip_network("100.64.0.0/10") +_ULA = ipaddress.ip_network("fc00::/7") + + +def _is_public(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: + mapped = getattr(ip, "ipv4_mapped", None) + if mapped is not None: + ip = mapped + if isinstance(ip, ipaddress.IPv4Address) and ip in _CGNAT: + return False + if isinstance(ip, ipaddress.IPv6Address) and ip in _ULA: + return False + return ip.is_global + + +async def _resolve_public_address(host: str, port: int) -> str: + loop = asyncio.get_running_loop() + infos = await loop.getaddrinfo(host, port, type=socket.SOCK_STREAM) + for info in infos: + candidate = ipaddress.ip_address(info[4][0]) + if _is_public(candidate): + return str(candidate) + raise httpcore.ConnectError(f"no public address resolved for {host!r}") + + +class _PinnedAddressBackend(httpcore.AnyIOBackend): + # Connects to the address we validated, not the hostname, so a later + # DNS answer can't redirect an already-approved connection. + async def connect_tcp(self, host, port, timeout=None, local_address=None, socket_options=None): + address = await _resolve_public_address(host, port) + return await super().connect_tcp( + address, + port, + timeout=timeout, + local_address=local_address, + socket_options=socket_options, + ) + + +def build_public_only_transport() -> httpx.AsyncHTTPTransport: + # httpx has no public constructor arg for a custom network backend. + transport = httpx.AsyncHTTPTransport() + transport._pool._network_backend = _PinnedAddressBackend() + return transport diff --git a/services/ai/requirements.txt b/services/ai/requirements.txt index 0de3fe50..3c40c143 100644 --- a/services/ai/requirements.txt +++ b/services/ai/requirements.txt @@ -38,6 +38,7 @@ pydantic-settings>=2.10.1 pytest==8.4.2 python-jose==3.5.0 redis==5.2.1 +respx==0.23.1 SQLAlchemy==2.0.41 starlette==1.3.1 structlog==24.1.0 diff --git a/services/ai/tests/test_egress_policy.py b/services/ai/tests/test_egress_policy.py new file mode 100644 index 00000000..e68dc945 --- /dev/null +++ b/services/ai/tests/test_egress_policy.py @@ -0,0 +1,127 @@ +import pytest + +from app.services.egress_policy import ( + ApprovedOrigin, + is_approved, + normalize_origin, + parse_approved_origins, +) + + +class TestNormalizeOrigin: + def test_lowercases_scheme_and_host(self): + assert normalize_origin("HTTP://Ollama:11434") == "http://ollama:11434" + + def test_strips_default_http_port(self): + assert normalize_origin("http://ollama:80") == "http://ollama" + + def test_strips_default_https_port(self): + assert normalize_origin("https://ollama:443") == "https://ollama" + + def test_keeps_non_default_port(self): + assert normalize_origin("http://ollama:11434") == "http://ollama:11434" + + def test_ignores_path_and_trailing_slash(self): + assert normalize_origin("http://ollama:11434/v1/chat/") == "http://ollama:11434" + + def test_rejects_userinfo(self): + assert normalize_origin("http://user:pass@ollama:11434") is None + + def test_rejects_file_scheme(self): + assert normalize_origin("file:///etc/passwd") is None + + def test_rejects_gopher_scheme(self): + assert normalize_origin("gopher://ollama:11434") is None + + def test_rejects_url_with_no_host(self): + assert normalize_origin("http://") is None + + @pytest.mark.parametrize("url", [ + "http://[::1", "http://ollama:bad", "http://ollama:65536", + ]) + def test_rejects_malformed_url(self, url): + assert normalize_origin(url) is None + assert is_approved(url, []) is None + + @pytest.mark.parametrize("url, expected", [ + ("http://[::1]:11434", "http://[::1]:11434"), + ("http://[::1]:80", "http://[::1]"), + ("https://[2001:DB8::1]:443/path", "https://[2001:db8::1]"), + ]) + def test_ipv6_endpoint_roundtrip(self, url, expected): + approved = parse_approved_origins( + '[{"origin": "' + url + '", "allow_private": true}]' + ) + assert approved[0].origin == expected + assert normalize_origin(expected) == expected + assert is_approved(expected, approved) == approved[0] + + +class TestParseApprovedOrigins: + def test_none_returns_empty_list(self): + assert parse_approved_origins(None) == [] + + def test_empty_string_returns_empty_list(self): + assert parse_approved_origins("") == [] + + def test_parses_origin_and_allow_private(self): + raw = '[{"origin": "http://ollama:11434", "allow_private": true}]' + assert parse_approved_origins(raw) == [ + ApprovedOrigin(origin="http://ollama:11434", allow_private=True) + ] + + def test_defaults_allow_private_to_false(self): + raw = '[{"origin": "https://api.example.com"}]' + assert parse_approved_origins(raw) == [ + ApprovedOrigin(origin="https://api.example.com", allow_private=False) + ] + + def test_parses_optional_label(self): + raw = '[{"origin": "http://ollama:11434", "allow_private": true, "label": "Local Ollama"}]' + assert parse_approved_origins(raw)[0].label == "Local Ollama" + + def test_label_defaults_to_none(self): + raw = '[{"origin": "http://ollama:11434", "allow_private": true}]' + assert parse_approved_origins(raw)[0].label is None + + def test_normalizes_entries(self): + raw = '[{"origin": "HTTP://Ollama:80", "allow_private": true}]' + assert parse_approved_origins(raw)[0].origin == "http://ollama" + + def test_rejects_invalid_origin(self): + with pytest.raises(ValueError): + parse_approved_origins('[{"origin": "file:///etc/passwd"}]') + + @pytest.mark.parametrize( + "raw", ["not json", '{"origin": "http://ollama"}', '["http://ollama"]', '[{"label": "x"}]'] + ) + def test_malformed_config_raises_value_error(self, raw): + with pytest.raises(ValueError): + parse_approved_origins(raw) + + def test_settings_reject_malformed_config_at_load(self, monkeypatch): + from pydantic import ValidationError + + from app.core.config import Settings + + monkeypatch.setenv("AI_PROVIDER_APPROVED_ORIGINS", '[{"label": "no origin"}]') + with pytest.raises(ValidationError): + Settings() + + +class TestIsApproved: + def _approved(self): + return [ + ApprovedOrigin(origin="http://ollama:11434", allow_private=True), + ApprovedOrigin(origin="https://api.example.com", allow_private=False), + ] + + def test_matches_normalized_origin(self): + result = is_approved("HTTP://Ollama:11434/v1/", self._approved()) + assert result == ApprovedOrigin(origin="http://ollama:11434", allow_private=True) + + def test_no_match_returns_none(self): + assert is_approved("http://users:8000", self._approved()) is None + + def test_disallowed_scheme_returns_none(self): + assert is_approved("file:///etc/passwd", self._approved()) is None diff --git a/services/ai/tests/test_ollama_provider.py b/services/ai/tests/test_ollama_provider.py index 87eb9ddb..eb029c76 100644 --- a/services/ai/tests/test_ollama_provider.py +++ b/services/ai/tests/test_ollama_provider.py @@ -3,10 +3,27 @@ OllamaProvider was removed in Phase 8. Ollama is now wired through PydanticAI's OpenAIModel with an Ollama-compatible base_url. """ +import contextlib +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer from unittest.mock import MagicMock, patch +import httpx +import pytest +import respx + +from app.services.adapters import ollama as ollama_adapter from app.services.provider import ResolvedModel, resolve_model +_APPROVED_ORIGINS = '[{"origin": "http://ollama:11434", "allow_private": true}]' + + +@pytest.fixture(autouse=True) +def _reset_shared_clients(): + ollama_adapter._http_clients.clear() + yield + ollama_adapter._http_clients.clear() + def _make_ollama_key(model_name="llama3.2", base_url="http://ollama:11434"): user_key = MagicMock() @@ -14,20 +31,34 @@ def _make_ollama_key(model_name="llama3.2", base_url="http://ollama:11434"): user_key.model_name = model_name user_key.encrypted_key = None user_key.base_url = base_url + user_key.customer_id = "cccccccc-0000-0000-0000-000000000003" return user_key class TestResolveModelOllama: def test_ollama_key_returns_resolved_model(self): user_key = _make_ollama_key() - resolved = resolve_model(user_key=user_key) + with patch("app.core.config.settings") as mock_settings: + mock_settings.AI_PROVIDER_APPROVED_ORIGINS = _APPROVED_ORIGINS + resolved = resolve_model(user_key=user_key) assert isinstance(resolved, ResolvedModel) assert resolved.provider_name == "ollama" assert resolved.model_name == "llama3.2" + def test_ollama_reuses_one_http_client_per_egress_mode(self): + with patch("app.core.config.settings") as mock_settings: + mock_settings.AI_PROVIDER_APPROVED_ORIGINS = _APPROVED_ORIGINS + first = resolve_model(user_key=_make_ollama_key()) + second = resolve_model(user_key=_make_ollama_key()) + assert first.model.client._client is second.model.client._client + def test_ollama_uses_base_url_from_key(self): user_key = _make_ollama_key(base_url="http://custom-ollama:11434") - resolved = resolve_model(user_key=user_key) + with patch("app.core.config.settings") as mock_settings: + mock_settings.AI_PROVIDER_APPROVED_ORIGINS = ( + '[{"origin": "http://custom-ollama:11434", "allow_private": true}]' + ) + resolved = resolve_model(user_key=user_key) assert resolved is not None def test_ollama_falls_back_to_settings_url_when_no_base_url(self): @@ -36,6 +67,9 @@ def test_ollama_falls_back_to_settings_url_when_no_base_url(self): with patch("app.core.config.settings") as mock_settings: mock_settings.OLLAMA_URL = "http://settings-ollama:11434" mock_settings.ENCRYPTION_KEY = "key" + mock_settings.AI_PROVIDER_APPROVED_ORIGINS = ( + '[{"origin": "http://settings-ollama:11434", "allow_private": true}]' + ) resolved = resolve_model(user_key=user_key) assert resolved is not None assert resolved.provider_name == "ollama" @@ -48,3 +82,99 @@ def test_ollama_returns_none_when_no_url_available(self): mock_settings.ENCRYPTION_KEY = "key" resolved = resolve_model(user_key=user_key) assert resolved is None + + def test_stored_unapproved_origin_resolves_to_no_provider(self): + """A row saved before this policy existed, or whose approval was later + revoked, must fail closed rather than reach the stored host.""" + user_key = _make_ollama_key(base_url="http://users:8000") + with patch("app.core.config.settings") as mock_settings: + mock_settings.AI_PROVIDER_APPROVED_ORIGINS = _APPROVED_ORIGINS + resolved = resolve_model(user_key=user_key) + assert resolved is None + + def test_client_does_not_follow_redirects(self): + user_key = _make_ollama_key() + with patch("app.core.config.settings") as mock_settings: + mock_settings.AI_PROVIDER_APPROVED_ORIGINS = _APPROVED_ORIGINS + resolved = resolve_model(user_key=user_key) + http_client = resolved.model.client._client + assert isinstance(http_client, httpx.AsyncClient) + assert http_client.follow_redirects is False + + +@pytest.mark.anyio +class TestOllamaRedirectNotFollowed: + async def test_redirect_to_internal_address_is_not_followed(self): + user_key = _make_ollama_key() + with patch("app.core.config.settings") as mock_settings: + mock_settings.AI_PROVIDER_APPROVED_ORIGINS = _APPROVED_ORIGINS + resolved = resolve_model(user_key=user_key) + http_client = resolved.model.client._client + + with respx.mock(assert_all_called=False) as mock: + origin_route = mock.get("http://ollama:11434/probe").mock( + return_value=httpx.Response( + 302, headers={"Location": "http://169.254.169.254/latest/meta-data/"} + ) + ) + internal_route = mock.get("http://169.254.169.254/latest/meta-data/").mock( + return_value=httpx.Response(200, text="should never be reached") + ) + response = await http_client.get("http://ollama:11434/probe") + + assert response.status_code == 302 + assert origin_route.called + assert not internal_route.called + + +class _StubHandler(BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.end_headers() + self.wfile.write(b"ok") + + def log_message(self, *args): + pass + + +@contextlib.contextmanager +def _local_stub_server(): + server = HTTPServer(("127.0.0.1", 0), _StubHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield server.server_port + finally: + server.shutdown() + thread.join() + + +@pytest.mark.anyio +class TestOllamaPrivateAllowedOrigin: + async def test_private_allowed_origin_reaches_local_stub_server(self): + """allow_private origins skip address pinning, so a loopback stub + (normally rejected as non-public) is reachable when approved.""" + with _local_stub_server() as port: + base_url = f"http://127.0.0.1:{port}" + user_key = _make_ollama_key(base_url=base_url) + with patch("app.core.config.settings") as mock_settings: + mock_settings.AI_PROVIDER_APPROVED_ORIGINS = ( + f'[{{"origin": "{base_url}", "allow_private": true}}]' + ) + resolved = resolve_model(user_key=user_key) + http_client = resolved.model.client._client + response = await http_client.get(f"{base_url}/health") + assert response.status_code == 200 + + async def test_public_only_origin_rejects_the_same_loopback_stub(self): + with _local_stub_server() as port: + base_url = f"http://127.0.0.1:{port}" + user_key = _make_ollama_key(base_url=base_url) + with patch("app.core.config.settings") as mock_settings: + mock_settings.AI_PROVIDER_APPROVED_ORIGINS = ( + f'[{{"origin": "{base_url}", "allow_private": false}}]' + ) + resolved = resolve_model(user_key=user_key) + http_client = resolved.model.client._client + with pytest.raises(httpx.ConnectError): + await http_client.get(f"{base_url}/health") diff --git a/services/ai/tests/test_pinned_transport.py b/services/ai/tests/test_pinned_transport.py new file mode 100644 index 00000000..55f1cea1 --- /dev/null +++ b/services/ai/tests/test_pinned_transport.py @@ -0,0 +1,114 @@ +import socket +from unittest.mock import AsyncMock, MagicMock, patch + +import httpcore +import pytest + +from app.services.pinned_transport import ( + _PinnedAddressBackend, + _is_public, + _resolve_public_address, + build_public_only_transport, +) + + +def _addrinfo(ip: str, port: int = 11434): + family = socket.AF_INET6 if ":" in ip else socket.AF_INET + sockaddr = (ip, port, 0, 0) if family == socket.AF_INET6 else (ip, port) + return (family, socket.SOCK_STREAM, 6, "", sockaddr) + + +def _fake_loop(*result_lists): + loop = MagicMock() + if len(result_lists) == 1: + loop.getaddrinfo = AsyncMock(return_value=result_lists[0]) + else: + loop.getaddrinfo = AsyncMock(side_effect=list(result_lists)) + return loop + + +class TestIsPublic: + def test_public_ipv4_is_public(self): + import ipaddress + + assert _is_public(ipaddress.ip_address("8.8.8.8")) is True + + def test_rfc1918_is_not_public(self): + import ipaddress + + assert _is_public(ipaddress.ip_address("10.0.0.5")) is False + + def test_link_local_is_not_public(self): + import ipaddress + + assert _is_public(ipaddress.ip_address("169.254.169.254")) is False + + def test_cgnat_is_not_public(self): + import ipaddress + + assert _is_public(ipaddress.ip_address("100.64.0.1")) is False + + def test_ula_is_not_public(self): + import ipaddress + + assert _is_public(ipaddress.ip_address("fd00::1")) is False + + def test_ipv4_mapped_private_is_not_public(self): + import ipaddress + + assert _is_public(ipaddress.ip_address("::ffff:10.0.0.1")) is False + + def test_ipv4_mapped_public_is_public(self): + import ipaddress + + assert _is_public(ipaddress.ip_address("::ffff:8.8.8.8")) is True + + +@pytest.mark.anyio +class TestResolvePublicAddress: + async def test_rejects_private_only_answer(self): + with patch("asyncio.get_running_loop", return_value=_fake_loop([_addrinfo("10.0.0.5")])): + with pytest.raises(httpcore.ConnectError): + await _resolve_public_address("internal.example", 11434) + + async def test_picks_public_among_mixed_answers(self): + infos = [_addrinfo("10.0.0.5"), _addrinfo("8.8.8.8")] + with patch("asyncio.get_running_loop", return_value=_fake_loop(infos)): + address = await _resolve_public_address("mixed.example", 11434) + assert address == "8.8.8.8" + + async def test_rebinding_sequence_rejects_second_resolution(self): + loop = _fake_loop([_addrinfo("8.8.8.8")], [_addrinfo("10.0.0.5")]) + with patch("asyncio.get_running_loop", return_value=loop): + first = await _resolve_public_address("rebind.example", 11434) + assert first == "8.8.8.8" + with pytest.raises(httpcore.ConnectError): + await _resolve_public_address("rebind.example", 11434) + + +@pytest.mark.anyio +class TestPinnedAddressBackend: + async def test_connect_tcp_targets_resolved_address_not_hostname(self): + backend = _PinnedAddressBackend() + with ( + patch("asyncio.get_running_loop", return_value=_fake_loop([_addrinfo("8.8.8.8")])), + patch.object( + httpcore.AnyIOBackend, "connect_tcp", new=AsyncMock(return_value="stream") + ) as mock_super, + ): + result = await backend.connect_tcp("public.example.com", 443) + assert result == "stream" + mock_super.assert_awaited_once_with( + "8.8.8.8", 443, timeout=None, local_address=None, socket_options=None + ) + + async def test_connect_tcp_raises_when_no_public_address(self): + backend = _PinnedAddressBackend() + with patch("asyncio.get_running_loop", return_value=_fake_loop([_addrinfo("10.0.0.5")])): + with pytest.raises(httpcore.ConnectError): + await backend.connect_tcp("internal.example", 80) + + +def test_build_public_only_transport_installs_pinned_backend(): + transport = build_public_only_transport() + assert isinstance(transport._pool._network_backend, _PinnedAddressBackend) diff --git a/services/ai/tests/test_provider.py b/services/ai/tests/test_provider.py index 4ce6fdf5..51edc6ba 100644 --- a/services/ai/tests/test_provider.py +++ b/services/ai/tests/test_provider.py @@ -48,6 +48,9 @@ def test_ollama_key_returns_resolved_model(self): with patch("app.core.config.settings") as mock_settings: mock_settings.OLLAMA_URL = "http://localhost:11434" mock_settings.ENCRYPTION_KEY = "key" + mock_settings.AI_PROVIDER_APPROVED_ORIGINS = ( + '[{"origin": "http://localhost:11434", "allow_private": true}]' + ) resolved = resolve_model(user_key=user_key) assert resolved is not None assert resolved.provider_name == "ollama" diff --git a/services/ai/tests/test_settings_routes.py b/services/ai/tests/test_settings_routes.py index 9bb9cc2b..a536a8a9 100644 --- a/services/ai/tests/test_settings_routes.py +++ b/services/ai/tests/test_settings_routes.py @@ -108,6 +108,35 @@ def test_user_role_forbidden(self): response = client.get("/api/v1/ai/settings") assert response.status_code == 403 + def test_exposes_approved_endpoints_with_label_fallback(self): + with ( + patch(_LIST_FOR_CUSTOMER, new=AsyncMock(return_value=[])), + patch(_GET_CUSTOMER_AI_DEFAULTS, new=AsyncMock(return_value=None)), + patch( + "app.api.settings_routes.settings.AI_PROVIDER_APPROVED_ORIGINS", + '[{"origin": "http://ollama:11434", "allow_private": true, "label": "Local"},' + ' {"origin": "http://host.docker.internal:11434", "allow_private": true}]', + ), + ): + response = client.get("/api/v1/ai/settings") + endpoints = response.json()["approved_endpoints"] + assert endpoints == [ + {"origin": "http://ollama:11434", "label": "Local"}, + { + "origin": "http://host.docker.internal:11434", + "label": "http://host.docker.internal:11434", + }, + ] + + def test_no_approved_origins_returns_empty_endpoints(self): + with ( + patch("app.api.settings_routes.settings.AI_PROVIDER_APPROVED_ORIGINS", None), + patch(_LIST_FOR_CUSTOMER, new=AsyncMock(return_value=[])), + patch(_GET_CUSTOMER_AI_DEFAULTS, new=AsyncMock(return_value=None)), + ): + response = client.get("/api/v1/ai/settings") + assert response.json()["approved_endpoints"] == [] + class TestCreateAiKey: def setup_method(self): @@ -141,7 +170,7 @@ def test_second_config_for_same_provider_succeeds(self): assert response.status_code == 200 mock_create.assert_awaited_once() - def test_ollama_key_without_base_url_defaults_to_localhost(self): + def test_ollama_key_without_base_url_defaults_to_configured_default(self): ollama_provider = _make_provider("ollama", has_key_prefix=False) with ( patch(_GET_BY_NAME, new=AsyncMock(return_value=ollama_provider)), @@ -150,13 +179,95 @@ def test_ollama_key_without_base_url_defaults_to_localhost(self): patch(_CREATE, new=AsyncMock()) as mock_create, patch(_LIST_FOR_CUSTOMER, new=AsyncMock(return_value=[])), patch(_GET_CUSTOMER_AI_DEFAULTS, new=AsyncMock(return_value=None)), + patch("app.api.settings_routes.settings.OLLAMA_URL", "http://ollama:11434"), + patch( + "app.api.settings_routes.settings.AI_PROVIDER_APPROVED_ORIGINS", + '[{"origin": "http://ollama:11434", "allow_private": true}]', + ), ): response = client.post( "/api/v1/ai/settings/keys", json={"provider": "ollama", "model": "llama3.2"}, ) assert response.status_code == 200 - assert mock_create.await_args.kwargs["base_url"] == "http://localhost:11434" + assert mock_create.await_args.kwargs["base_url"] == "http://ollama:11434" + + def test_ollama_key_with_unapproved_base_url_rejected(self): + ollama_provider = _make_provider("ollama", has_key_prefix=False) + with ( + patch(_GET_BY_NAME, new=AsyncMock(return_value=ollama_provider)), + patch(_MODEL_EXISTS, new=AsyncMock(return_value=True)), + patch( + "app.api.settings_routes.settings.AI_PROVIDER_APPROVED_ORIGINS", + '[{"origin": "http://ollama:11434", "allow_private": true}]', + ), + ): + response = client.post( + "/api/v1/ai/settings/keys", + json={"provider": "ollama", "model": "llama3.2", "base_url": "http://users:8000"}, + ) + assert response.status_code == 422 + + def test_ollama_key_with_metadata_endpoint_rejected(self): + ollama_provider = _make_provider("ollama", has_key_prefix=False) + with ( + patch(_GET_BY_NAME, new=AsyncMock(return_value=ollama_provider)), + patch(_MODEL_EXISTS, new=AsyncMock(return_value=True)), + patch( + "app.api.settings_routes.settings.AI_PROVIDER_APPROVED_ORIGINS", + '[{"origin": "http://ollama:11434", "allow_private": true}]', + ), + ): + response = client.post( + "/api/v1/ai/settings/keys", + json={ + "provider": "ollama", + "model": "llama3.2", + "base_url": "http://169.254.169.254", + }, + ) + assert response.status_code == 422 + + def test_ollama_key_with_approved_base_url_succeeds(self): + ollama_provider = _make_provider("ollama", has_key_prefix=False) + with ( + patch(_GET_BY_NAME, new=AsyncMock(return_value=ollama_provider)), + patch(_MODEL_EXISTS, new=AsyncMock(return_value=True)), + patch(_VALIDATE, new=AsyncMock()), + patch(_CREATE, new=AsyncMock()) as mock_create, + patch(_LIST_FOR_CUSTOMER, new=AsyncMock(return_value=[])), + patch(_GET_CUSTOMER_AI_DEFAULTS, new=AsyncMock(return_value=None)), + patch( + "app.api.settings_routes.settings.AI_PROVIDER_APPROVED_ORIGINS", + '[{"origin": "http://ollama:11434", "allow_private": true}]', + ), + ): + response = client.post( + "/api/v1/ai/settings/keys", + json={ + "provider": "ollama", + "model": "llama3.2", + "base_url": "http://ollama:11434", + }, + ) + assert response.status_code == 200 + assert mock_create.await_args.kwargs["base_url"] == "http://ollama:11434" + + def test_base_url_rejected_for_key_requiring_provider(self): + with ( + patch(_GET_BY_NAME, new=AsyncMock(return_value=_make_provider())), + patch(_MODEL_EXISTS, new=AsyncMock(return_value=True)), + ): + response = client.post( + "/api/v1/ai/settings/keys", + json={ + "provider": "anthropic", + "key": "sk-ant-api03-x", + "model": "claude-sonnet-4-6", + "base_url": "http://ollama:11434", + }, + ) + assert response.status_code == 422 def test_unknown_provider_returns_404(self): with patch(_GET_BY_NAME, new=AsyncMock(return_value=None)):