* 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>
120 lines
4.4 KiB
TypeScript
120 lines
4.4 KiB
TypeScript
import { getSql } from "@/lib/db";
|
|
import { computeEstimate, type EntityType, type Frequency } from "@/lib/data";
|
|
import { serviceById } from "@/lib/services";
|
|
|
|
export interface OrderConfig {
|
|
markets?: string[];
|
|
type?: EntityType;
|
|
fieldIds?: string[];
|
|
enrichmentIds?: string[];
|
|
volume?: number;
|
|
frequency?: Frequency;
|
|
confidence?: number;
|
|
formats?: string[];
|
|
service_type?: string;
|
|
}
|
|
|
|
export interface Order {
|
|
order_id: string;
|
|
flow: string;
|
|
status: string;
|
|
config: OrderConfig;
|
|
total_ore: number;
|
|
deposit_ore: number;
|
|
balance_ore: number;
|
|
est_days: number;
|
|
est_confidence: number;
|
|
created_at: string;
|
|
}
|
|
|
|
// Non-durable fallback used only until a database is provisioned.
|
|
const mem = new Map<string, Order>();
|
|
|
|
// postgres.js (pinned v3.4.9) returns bigint (total_ore/deposit_ore/balance_ore)
|
|
// and numeric (est_confidence) columns as strings by default, not JS numbers.
|
|
// Coerce only these four fields on rows read back from SQL -- not the shared
|
|
// `sql` client's global type map, which would silently affect every other
|
|
// table's bigint/numeric columns too (e.g. jobs.pct, deliveries.avg_confidence).
|
|
function coerceOrder(row: Order): Order {
|
|
return {
|
|
...row,
|
|
total_ore: Number(row.total_ore),
|
|
deposit_ore: Number(row.deposit_ore),
|
|
balance_ore: Number(row.balance_ore),
|
|
est_confidence: Number(row.est_confidence),
|
|
};
|
|
}
|
|
|
|
function estimateFor(config: OrderConfig) {
|
|
return computeEstimate({
|
|
markets: config.markets ?? [],
|
|
type: config.type ?? "Bolag",
|
|
fieldIds: config.fieldIds ?? [],
|
|
enrichmentIds: config.enrichmentIds ?? [],
|
|
volume: config.volume ?? 1000,
|
|
frequency: config.frequency ?? "Engångs",
|
|
confidence: config.confidence ?? 92,
|
|
serviceMult: serviceById(config.service_type ?? "")?.mult ?? 1,
|
|
});
|
|
}
|
|
|
|
export async function createOrder(flow: string, config: OrderConfig): Promise<Order> {
|
|
const est = estimateFor(config);
|
|
const sql = getSql();
|
|
if (sql) {
|
|
const [row] = await sql<{ order_id: string; created_at: string }[]>`
|
|
INSERT INTO orders (flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence)
|
|
VALUES (${flow}, 'draft', ${sql.json(config as Parameters<typeof sql.json>[0])}, ${est.price * 100}, ${est.deposit * 100},
|
|
${est.remainder * 100}, ${est.timelineDays}, ${est.estConfidence / 100})
|
|
RETURNING order_id, created_at`;
|
|
return {
|
|
order_id: row.order_id, flow, status: "draft", config,
|
|
total_ore: est.price * 100, deposit_ore: est.deposit * 100, balance_ore: est.remainder * 100,
|
|
est_days: est.timelineDays, est_confidence: est.estConfidence / 100, created_at: row.created_at,
|
|
};
|
|
}
|
|
const id = (globalThis.crypto?.randomUUID?.() ?? `SX-${Math.floor(1000 + Math.random() * 9000)}`);
|
|
const order: Order = {
|
|
order_id: id, flow, status: "draft", config,
|
|
total_ore: est.price * 100, deposit_ore: est.deposit * 100, balance_ore: est.remainder * 100,
|
|
est_days: est.timelineDays, est_confidence: est.estConfidence / 100, created_at: new Date().toISOString(),
|
|
};
|
|
mem.set(id, order);
|
|
return order;
|
|
}
|
|
|
|
export async function getOrder(id: string): Promise<Order | null> {
|
|
const sql = getSql();
|
|
if (sql) {
|
|
const rows = await sql<Order[]>`
|
|
SELECT order_id, flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, created_at
|
|
FROM orders WHERE order_id::text = ${id} LIMIT 1`;
|
|
return rows[0] ? coerceOrder(rows[0]) : null;
|
|
}
|
|
return mem.get(id) ?? null;
|
|
}
|
|
|
|
export async function listOrders(): Promise<Order[]> {
|
|
const sql = getSql();
|
|
if (sql) {
|
|
const rows = await sql<Order[]>`
|
|
SELECT order_id, flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, created_at
|
|
FROM orders ORDER BY created_at DESC LIMIT 100`;
|
|
return rows.map(coerceOrder);
|
|
}
|
|
return [...mem.values()].sort((a, b) => b.created_at.localeCompare(a.created_at));
|
|
}
|
|
|
|
export async function setStatus(id: string, status: string): Promise<Order | null> {
|
|
const sql = getSql();
|
|
if (sql) {
|
|
const rows = await sql<Order[]>`
|
|
UPDATE orders SET status = ${status}, updated_at = now() WHERE order_id::text = ${id}
|
|
RETURNING order_id, flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, created_at`;
|
|
return rows[0] ? coerceOrder(rows[0]) : null;
|
|
}
|
|
const o = mem.get(id);
|
|
if (o) { o.status = status; mem.set(id, o); }
|
|
return o ?? null;
|
|
}
|