Files
0pt/lib/orders-store.ts
T
Simon AxelssonandClaude Sonnet 5 dc6aaa0e10
CI (SIAX Cloud) / quality (push) Successful in 51s
Security Scan (SIAX Cloud) / security (push) Successful in 26s
CI (SIAX Cloud) / security (push) Successful in 28s
CI (SIAX Cloud) / integration (push) Successful in 1m1s
CI (SIAX Cloud) / integration (pull_request) Successful in 30s
CI (SIAX Cloud) / security (pull_request) Successful in 24s
CI (SIAX Cloud) / quality (pull_request) Successful in 48s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 22s
fix(data-os): P014 — close tenant-isolation + idempotency gaps, execute real restore drill
Tenant isolation (security-critical): every app/api/0pt/** route reading
tenant_id from client-controlled query/body never cross-checked it against
the token middleware.ts already verified (x-0pt-verified-tenant). Adds a
shared assertTenantMatchesVerified guard and wires it into all 20+ affected
routes. Also closes two full-leak-by-omission bugs found in this pass
(documents/capture and lakehouse/mappings GETs returned every tenant's rows
when tenant_id was simply omitted) and two "no tenant check at all" reads
(rate-policies/[id], lakehouse/mappings/[id]) reachable by ID alone.

Duplicate-delivery idempotency (correctness-critical): POST /api/orders now
honors Idempotency-Key (sql/006, replay-or-409-on-conflict, Postgres +
in-memory); the Python orchestrator's POST /orders/{id}/run — the actual
delivery trigger — is now idempotent under an asyncio lock, closing a real
concurrent-request race, not just the sequential-retry case.

Engine-duplication resolution: adaptive_router.py's docstring wrongly
invited a bypass ("see b0ts for scraping") contradicting the product's own
compliance stance; corrected, and given real (additive, not escalating)
block-signal tracking so a rate-limited/blocked source is excluded from the
plan instead of hammered. Also fixes a previously-undiscovered production
bug: the Python engine's only call into the TS quality bridge
(X-Internal-Token) was unconditionally 401'd by middleware.ts, which never
checked that credential — the advisory quality bridge was unreachable in any
environment enforcing auth.

Restore drill: executed for real against throwaway Docker Postgres
containers (not simulated). Found and fixed two real blockers along the way:
sql/004 referenced opt_datasets, which no migration ever created (chain was
never actually runnable end to end); lib/db.ts hardcoded ssl:"require",
making any non-TLS Postgres (incl. compose.dev.yaml's own local-dev
container) unreachable. After both fixes: full migration chain applies
cleanly, pg_dump/pg_restore round-trip is byte-identical (row counts +
content checksums), and real application code (lib/orders-store.ts,
SourceRegistryService's Postgres repository) reads the restored data back
correctly, including cross-tenant isolation still holding.

82 new tests (55 TS + 27 Python), all passing; full existing suite (291 TS +
55 Python) still green; typecheck clean on every changed/added file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 12:00:43 +02:00

205 lines
8.0 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>();
// idempotency_key -> order_id, mirroring the Postgres unique index added by
// sql/006_orders_idempotency.sql for the in-memory fallback path.
const memIdempotencyIndex = new Map<string, string>();
/**
* Thrown by {@link createOrder} when a caller reuses an `Idempotency-Key`
* for a semantically different request (different `flow` and/or `config`)
* than the one that key was first used with. A route handler should map
* this to HTTP 409 -- silently returning either order would be wrong (the
* caller asked for the SAME operation to be safely retried, not for two
* different operations to collapse into one).
*/
export class IdempotencyKeyConflictError extends Error {
constructor(public readonly idempotencyKey: string) {
super(`Idempotency-Key "${idempotencyKey}" was already used to create a different order (different flow/config)`);
this.name = "IdempotencyKeyConflictError";
}
}
/** Postgres error code 23505 = unique_violation (postgres.js surfaces it as `.code`). */
function isUniqueViolation(err: unknown): boolean {
return typeof err === "object" && err !== null && (err as { code?: unknown }).code === "23505";
}
function sameSubmission(a: Order, flow: string, config: OrderConfig): boolean {
return a.flow === flow && JSON.stringify(a.config) === JSON.stringify(config);
}
export async function findOrderByIdempotencyKey(key: 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 idempotency_key = ${key} LIMIT 1`;
return rows[0] ? coerceOrder(rows[0]) : null;
}
const id = memIdempotencyIndex.get(key);
return id ? mem.get(id) ?? null : null;
}
// 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 interface CreateOrderResult {
order: Order;
/** True when this call returned a PRE-EXISTING order (a safe retry under the same Idempotency-Key), not a newly created one. */
replayed: boolean;
}
/**
* P014: `idempotencyKey` is optional -- a caller that omits it (or the
* pre-P014 call shape) behaves exactly as before, creating a fresh row every
* call. When supplied (the `Idempotency-Key` header convention, see
* sql/006_orders_idempotency.sql), a retry with the SAME key returns the
* original order unchanged (`replayed: true`) instead of creating a
* duplicate; a retry with the same key but a DIFFERENT flow/config throws
* {@link IdempotencyKeyConflictError} rather than silently picking one.
*
* The Postgres path checks-then-inserts, which has a real (if narrow) race
* window between two concurrent requests carrying the same fresh key -- the
* UNIQUE INDEX from sql/006 is the actual source of truth, and a
* unique-violation on INSERT is caught and re-resolved the same way a
* pre-existing row would be, rather than surfacing a raw DB error.
*/
export async function createOrder(flow: string, config: OrderConfig, idempotencyKey?: string): Promise<CreateOrderResult> {
const key = idempotencyKey?.trim() || undefined;
if (key) {
const existing = await findOrderByIdempotencyKey(key);
if (existing) {
if (sameSubmission(existing, flow, config)) return { order: existing, replayed: true };
throw new IdempotencyKeyConflictError(key);
}
}
const est = estimateFor(config);
const sql = getSql();
if (sql) {
try {
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, idempotency_key)
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}, ${key ?? null})
RETURNING order_id, created_at`;
return {
order: {
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,
},
replayed: false,
};
} catch (err) {
if (key && isUniqueViolation(err)) {
const raced = await findOrderByIdempotencyKey(key);
if (raced) {
if (sameSubmission(raced, flow, config)) return { order: raced, replayed: true };
throw new IdempotencyKeyConflictError(key);
}
}
throw err;
}
}
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);
if (key) memIdempotencyIndex.set(key, id);
return { order, replayed: false };
}
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;
}