Files
adminandClaude Opus 4.8 e6d3101501 fix: 17 confirmed bugs from full-repo audit (edge + Python engine) (#5)
* docs: correct stale quality-check-service.ts comment references

Two source comments still cited src/services/quality-check-service.ts as an
existing precedent. It doesn't exist anywhere in this repo's history (git
log --all: zero commits, any branch, ever) -- confirmed during the earlier
full-doc-corpus accuracy pass. Points each comment at the real precedent
instead (document-extraction-service.ts / iceberg-write-service.ts).

Applied from task_05ec36aa, spun off during that pass.

* fix: 17 confirmed bugs from full-repo audit (edge + Python engine)

Found by an 8-finder bug hunt across app/api, src/services, src/clients,
frontend, the Python engine, the data layer, config/infra, and tests, then
independently adversarially verified per finding (32 raw findings -> 27
confirmed real bugs; 5 refuted). This commit applies the 17 confirmed as
safe, mechanical fixes -- no architecture/design decision required. The
other 10 need a decision and are reported separately, not touched here.

Most consequential, app/api/payments/[order_id]/deposit/route.ts:
the client-supplied Origin request header was trusted unsanitized both as
a server-side fetch target (SSRF) and as Stripe Checkout success/cancel
redirect URLs (open redirect tied to a real payment flow), and a failed
order lookup silently fell through to a hardcoded deposit amount instead
of 404. Fixed by reading the order directly via lib/orders-store.ts
(mirroring the sibling release/route.ts, which already did this correctly)
instead of both trusting the Origin header and self-issuing an HTTP fetch.

Also fixed:
- 5 routes (orders, orders/[id], estimates, subscriptions, ai/parse) that
  500'd instead of 400ing on a literal JSON `null` body, since req.json()
  resolves (not rejects) for that input and bypasses the try/catch guard.
- app/api/0pt/lakehouse/mappings/{,[mappingId]/{,writes/}}route.ts each
  instantiated their own private service instances, so a mapping created
  via POST was invisible to the sibling GET and writes routes even before
  the separately-tracked dataset-registration gap. Extracted a shared
  singleton module, src/services/lakehouse-mapping-registry.ts.
- app/api/0pt/documents/[documentId]/extraction/route.ts collapsed every
  error (not-configured, policy-denied, policy-approval-required,
  not-found) into a blanket 400; now status-mapped like the sibling
  sources/[sourceId]/query route.
- app/api/vehicles/route.ts: plate query param wasn't URL-encoded before
  being concatenated into the outbound RDW request, allowing extra query
  parameters to be injected; added a strict allowlist + encodeURIComponent.
- app/api/uploads/route.ts: POST returned a fake 200 success with
  put_url: null even when "configured", instead of continuing to report
  501 until presign generation is actually implemented.
- src/adapters/document-intelligence-adapter.ts: the result-polling loop
  never checked response.ok, so a non-2xx poll response (auth expiry,
  429, 5xx) was silently retried until a generic timeout masked the real
  error.
- app/order/[id]/page.tsx: finish() never cleared the production-simulation
  setInterval, so it kept firing (and re-rendering) forever after delivery.
- app/admin/page.tsx: the metrics fetch had no response/shape validation
  (unlike the adjacent orders fetch), rendering literal "undefined" stat
  tiles on a malformed response instead of falling back to <ErrorState/>.
- services/api/workers/pipeline.py: `config.get("seeds") or DEMO_SEEDS`
  used truthy-or, so an explicit empty seeds dict was silently replaced
  with hardcoded demo org numbers instead of being respected as empty.
- services/api/workers/resolve.py: corroboration (confidence/agreed_sources
  bump) no longer fires for two hits from the same source under fuzzy
  match -- only a genuinely independent source now counts, per docs/06's
  own ">=2 independent sources agree" definition -- and a merged record's
  provenance array now actually includes the corroborating source's
  fields instead of only a bare source-name string.
- services/api/orchestrator/main.py: create_order's int(...) on
  unvalidated client volume/confidence fields raised on any
  decimal-formatted input (int("92.5") -> ValueError); now coerced via
  float() first with a safe default. /vehicles/{plate} and /screen also
  now catch connector exceptions and degrade gracefully, matching
  pipeline.py's existing pattern for the same connectors.
- lib/orders-store.ts: postgres.js (pinned v3.4.9) returns bigint/numeric
  columns as strings by default; getOrder/listOrders/setStatus were
  silently handing callers stringified money fields. Added a narrow
  per-row coercion instead of touching the shared client's global type
  map (which would affect unrelated tables' bigint/numeric columns too).
- lib/subscriptions-store.ts: the in-memory fallback returned
  oldest-first while the Postgres branch explicitly orders newest-first;
  now consistent, mirroring the identical fix already in orders-store.ts.
- app/api/auth/login/route.ts: DEV_LOGIN_KEY was compared with a plain
  !==, a non-constant-time comparison; replaced with crypto.timingSafeEqual.

Verified before commit: tsc --noEmit clean; vitest run 88 passed, 1 todo,
0 failures (13 files, unchanged from before this commit -- no existing
test's assertions needed to change). services/api/workers/resolve.py's
source-novelty and provenance-merge logic additionally verified by direct
execution against synthetic same-source/cross-source inputs (no Python
test suite exists in this repo to add to). services/api/workers/pipeline.py
and services/api/orchestrator/main.py verified with python -m py_compile
(no httpx/fastapi installed in this environment to run them directly).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(engine): treat explicit seeds:null as missing to avoid 500 in run_job

Four-eyes follow-up to this PR's own seeds change. Switching from
`config.get('seeds') or DEMO_SEEDS` to `config['seeds'] if 'seeds' in config`
correctly stopped an explicit empty {} from being backfilled with DEMO_SEEDS,
but regressed on client-controlled `{"seeds": null}`: seeds became None and
the downstream `seeds.get(market, [])` raised AttributeError -> 500. That path
is reachable (POST /orders stores raw body.config; POST /orders/{id}/run passes
it straight to run_job). Using `config.get('seeds') is not None` keeps the
intended behaviour (explicit {} honoured, never silently seeded with demo data)
while restoring the None-tolerance the old idiom had.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 18:52:19 +02:00

60 lines
3.0 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { engineFetch } from "@/lib/engine";
// POST /api/ai/parse (docs/04) — natural-language need -> prefilled order config.
// Uses the engine's LLM when available; otherwise a transparent keyword heuristic.
const MARKETS: [RegExp, string][] = [
[/sverige|svensk|\bse\b/i, "SE"], [/norge|norsk|\bno\b/i, "NO"], [/danmark|dansk|\bdk\b/i, "DK"],
[/finland|finsk|\bfi\b/i, "FI"], [/tyskland|\bde\b/i, "DE"], [/storbritannien|\buk\b|\bgb\b/i, "GB"],
];
const SERVICE: [RegExp, string][] = [
[/skrap|scrap/i, "scraping"], [/mappa|matcha|match|koppla/i, "mapping"],
[/normaliser|städ|dedup|tvätta/i, "normalization"], [/konsolider|crm|erp|hubspot|salesforce|dynamics|pipedrive|fivecrm/i, "consolidation"],
[/dokument|ocr|pdf|scann/i, "documents"], [/berik|enrich/i, "enrichment"],
[/\bicp\b|leads?|målgrupp|beslutsfattare/i, "icp"], [/\bml\b|modell|scoring|prognos|kluster/i, "ml"],
[/\bbi\b|dashboard|analys|marknadsanalys/i, "bi"], [/flöde|feed|övervak|freshness/i, "feeds"],
];
const FIELDS: [RegExp, string][] = [
[/e-?post|email|mejl|kontakt|telefon/i, "contact"], [/omsättning|finans|bokslut|resultat|nyckeltal/i, "financials"],
[/lei|cross-?border/i, "lei"], [/huvudman|ubo|ägar/i, "ubo"], [/intent|köpsignal|signal/i, "intent"],
];
const DEST: [RegExp, string][] = [
[/hubspot/i, "HubSpot"], [/salesforce/i, "Salesforce"], [/dynamics/i, "Microsoft Dynamics"],
[/pipedrive/i, "Pipedrive"], [/fivecrm/i, "FiveCRM"], [/sheets/i, "Google Sheets"],
];
export async function POST(req: NextRequest) {
const parsed = await req.json().catch(() => ({ text: "" }));
const body = parsed && typeof parsed === "object" ? parsed : { text: "" };
const { text } = body as { text?: string };
if (!text) return NextResponse.json({ error: { code: "bad_request", message: "text krävs" } }, { status: 400 });
const eng = await engineFetch("/ai/parse", { method: "POST", body: JSON.stringify({ text }) });
if (eng && eng.ok) return NextResponse.json(await eng.json());
const pick = (table: [RegExp, string][]) => table.filter(([re]) => re.test(text)).map(([, v]) => v);
const markets = [...new Set(pick(MARKETS))];
const service = pick(SERVICE)[0] ?? "data";
const fields = [...new Set(pick(FIELDS))];
const destination = pick(DEST)[0] ?? null;
const volMatch = text.replace(/\s/g, "").match(/(\d{3,})/);
const volume = volMatch ? Math.min(1_000_000, parseInt(volMatch[1], 10)) : 5000;
return NextResponse.json({
config: {
service_type: service,
markets: markets.length ? markets : ["SE"],
type: "Bolag",
fieldIds: fields.length ? fields : ["lei"],
enrichmentIds: [],
volume,
frequency: service === "feeds" || service === "enrichment" ? "Veckovis" : "Engångs",
confidence: 92,
destination,
description: text,
},
rationale: `Tolkat som ${service}${markets.length ? ` · ${markets.join(", ")}` : ""}${destination ? ` · ${destination}` : ""}. Justera fritt.`,
heuristic: true,
});
}