dev #71
@@ -936,7 +936,7 @@ export class CategoriesService {
|
||||
// verification can measure source-DB coverage.
|
||||
let partsResult = await this.pcatSourceDb.fetchParts(catalogId, carId, groupId);
|
||||
if (partsResult) {
|
||||
this.logger.debug(`[source-db hit pcat] car=${carId} group=${groupId}`);
|
||||
this.logger.log(`[source-db hit pcat] car=${carId} group=${groupId}`);
|
||||
} else {
|
||||
partsResult = await this.partsCatalogsService.fetchParts(
|
||||
catalogId,
|
||||
@@ -1092,16 +1092,19 @@ export class CategoriesService {
|
||||
} else if (vehicle && category.source === "emex") {
|
||||
// EMEX: fetch parts + schema image via Puppeteer from QuickDetails URL
|
||||
try {
|
||||
// Local dump first (uses vehicles.rawData.ssd persisted by the
|
||||
// emex mapper). Falls back to live upstream on miss.
|
||||
const emexSsd = (vehicle.rawData as { ssd?: string } | null)?.ssd;
|
||||
// Local dump first (catalog-wide via vehicles.rawData.catalogCode +
|
||||
// category.externalId). The dump's per-vehicle bridge is the EMEX
|
||||
// SSD, which is session-regenerated and never matches what sase
|
||||
// stored — so we accept over-return at the catalog level (~71% hit).
|
||||
// Falls back to live upstream on miss.
|
||||
const catalogCode = (vehicle.rawData as { catalogCode?: string } | null)?.catalogCode;
|
||||
let emexResult = await this.emexSourceDb.fetchCategoryParts(
|
||||
emexSsd,
|
||||
category.linkPath ?? "",
|
||||
catalogCode,
|
||||
category.externalId,
|
||||
);
|
||||
if (emexResult) {
|
||||
this.logger.debug(
|
||||
`[source-db hit emex] ssd=${emexSsd?.slice(0, 12)}... gid=${category.externalId}`,
|
||||
this.logger.log(
|
||||
`[source-db hit emex] catalog=${catalogCode} gid=${category.externalId}`,
|
||||
);
|
||||
} else {
|
||||
emexResult = await this.emexService.fetchCategoryParts(category.linkPath);
|
||||
|
||||
@@ -73,6 +73,17 @@ export default () => ({
|
||||
enabled: process.env.CATALOG_SOURCE_DB_ENABLED === "true",
|
||||
pcatUrl: process.env.PCAT_SOURCE_DB_URL,
|
||||
emexUrl: process.env.EMEX_SOURCE_DB_URL,
|
||||
// Per-source kill switches. PCAT defaults off — the current dump's deep-scrape
|
||||
// doesn't cover sase's TR-market vehicles; see service comment for details.
|
||||
emexEnabled: (process.env.EMEX_SOURCE_DB_ENABLED ?? "true") === "true",
|
||||
pcatEnabled: process.env.PCAT_SOURCE_DB_ENABLED === "true",
|
||||
// Per-catalog allowlist for emex parts lookup. Empty → service returns
|
||||
// null for every catalog (safe default after the 2026-06-01 noise audit).
|
||||
// Populate ONLY after a catalog's per-vehicle bridge is wired & verified.
|
||||
emexAllowedCatalogs: (process.env.EMEX_SOURCE_DB_ALLOWED_CATALOGS ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
},
|
||||
otel: {
|
||||
enabled: process.env.OTEL_ENABLED === "true",
|
||||
|
||||
@@ -4,31 +4,50 @@ import mysql, { type Pool, type RowDataPacket } from "mysql2/promise";
|
||||
import type { EmexHotspot, EmexHotspotArea, EmexPart, EmexPartsResult } from "../emex/emex.types";
|
||||
|
||||
/**
|
||||
* Look up a vehicle's parts + schema for a category (PNC group) in the local
|
||||
* EMEX dump (sase-catalog-src-emex MariaDB). Returns null on any miss so the
|
||||
* caller falls through to the live emex scrape.
|
||||
* Look up parts + schema for an EMEX category in the local dump
|
||||
* (sase-catalog-src-emex MariaDB). Returns null on any miss so the caller
|
||||
* falls through to the live emex scrape.
|
||||
*
|
||||
* Inputs:
|
||||
* - vehicleSsd: emex's per-vehicle session-state-descriptor — captured into
|
||||
* `vehicles.rawData.emexSsd` during live VIN decode. Required (no VIN
|
||||
* column in the dump; SSD is the only stable vehicle identifier).
|
||||
* - categoryUrl: the QuickDetails.aspx URL stored in `categories.linkPath`.
|
||||
* We parse `gid` (the group id) out of it.
|
||||
* Bridge: catalogs.code ↔ sase's vehicles.rawData.catalogCode,
|
||||
* part_groups.group_id (varchar) ↔ sase's categories.externalId (gid).
|
||||
*
|
||||
* SAFETY (2026-06-01 audit): the catalog-wide bridge below (no per-vehicle
|
||||
* filter) measured a 7-114x noiseRatio across every sampled catalog and
|
||||
* 49-98 wrong-OEM per 100 parts served, because vehicle_parts is shared
|
||||
* densely across all variants in a (catalog, group) pair (e.g. Renault
|
||||
* Mégane vs Duster vs Clio share the same gids). That directly violates the
|
||||
* "always correct OEM, no exceptions" rule. To prevent leakage, fetchCategoryParts
|
||||
* now returns null unless the catalogCode is in the EMEX_SOURCE_DB_ALLOWED_CATALOGS
|
||||
* allowlist — empty by default. A catalog should ONLY be added once a
|
||||
* per-vehicle bridge (vehicles.unique_key reconstruction from raw_data.parsedOptions)
|
||||
* lands and has been verified OEM-by-OEM vs live. All alternative bridges (SSD,
|
||||
* api_cache, wizard_parameters, scrape_queue_v2.vehicle_ssd) were verified
|
||||
* dead — see memory `sase-emex-source-db-safety.md` for the full inventory.
|
||||
*
|
||||
* The connection pool stays alive (master + emex switches default-on) so
|
||||
* follow-up code can use it for per-vehicle queries / schema-only diagrams
|
||||
* without having to flip env again.
|
||||
*/
|
||||
@Injectable()
|
||||
export class EmexSourceDbService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(EmexSourceDbService.name);
|
||||
private pool: Pool | null = null;
|
||||
private enabled = false;
|
||||
private allowedCatalogs: Set<string> = new Set();
|
||||
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
onModuleInit() {
|
||||
const enabled = this.config.get<boolean>("catalogSource.enabled");
|
||||
const masterEnabled = this.config.get<boolean>("catalogSource.enabled");
|
||||
const emexEnabled = this.config.get<boolean>("catalogSource.emexEnabled");
|
||||
const url = this.config.get<string>("catalogSource.emexUrl");
|
||||
if (!enabled || !url) {
|
||||
const allowed = this.config.get<string[]>("catalogSource.emexAllowedCatalogs") ?? [];
|
||||
this.allowedCatalogs = new Set(allowed);
|
||||
if (!masterEnabled || !emexEnabled || !url) {
|
||||
this.logger.log(
|
||||
`[emex-src] disabled (enabled=${enabled}, urlSet=${Boolean(url)}); upstream-only`,
|
||||
`[emex-src] disabled (master=${masterEnabled}, emex=${emexEnabled}, urlSet=${Boolean(
|
||||
url,
|
||||
)}); upstream-only`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -39,7 +58,15 @@ export class EmexSourceDbService implements OnModuleInit, OnModuleDestroy {
|
||||
waitForConnections: true,
|
||||
});
|
||||
this.enabled = true;
|
||||
this.logger.log("[emex-src] connected, lookup-first enabled");
|
||||
if (this.allowedCatalogs.size === 0) {
|
||||
this.logger.warn(
|
||||
"[emex-src] connected but ALLOWLIST EMPTY — all fetchCategoryParts calls return null until EMEX_SOURCE_DB_ALLOWED_CATALOGS is populated (safety: catalog-wide bridge has 7-114x noise; see sase-emex-source-db-safety memory)",
|
||||
);
|
||||
} else {
|
||||
this.logger.log(
|
||||
`[emex-src] connected, allowlist: [${[...this.allowedCatalogs].sort().join(",")}]`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
@@ -50,45 +77,45 @@ export class EmexSourceDbService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror the live `EmexCatalogService.fetchCategoryParts` shape.
|
||||
* Mirror the live `EmexService.fetchCategoryParts` shape.
|
||||
* `catalogCode` = vehicles.rawData.catalogCode (e.g. "RENAULT201910"),
|
||||
* `gid` = categories.externalId (e.g. "11754").
|
||||
* Returns null on miss; never throws.
|
||||
*/
|
||||
async fetchCategoryParts(
|
||||
vehicleSsd: string | null | undefined,
|
||||
categoryUrl: string,
|
||||
catalogCode: string | null | undefined,
|
||||
gid: string | null | undefined,
|
||||
): Promise<EmexPartsResult | null> {
|
||||
if (!this.enabled || !this.pool) return null;
|
||||
if (!vehicleSsd) return null; // no SSD captured → can't locate vehicle in dump
|
||||
const gid = extractGid(categoryUrl);
|
||||
if (!gid) return null;
|
||||
if (!catalogCode || !gid) return null;
|
||||
// Safety gate: the only path currently implemented (catalog-wide DISTINCT
|
||||
// on vehicle_parts.group_id, ignoring vehicle_id) is unsafe — see class
|
||||
// doc. Until a per-vehicle bridge lands, allow no catalogs.
|
||||
if (!this.allowedCatalogs.has(catalogCode)) return null;
|
||||
|
||||
try {
|
||||
// 1) Resolve vehicle.id from SSD. We try `ssd = ?` first; if the dump
|
||||
// canonicalized into `unique_key` (hash), the second arm catches it.
|
||||
const [vehicleRows] = await this.pool.execute<RowDataPacket[]>(
|
||||
"SELECT id, catalog_id FROM vehicles WHERE ssd = ? OR unique_key = ? LIMIT 1",
|
||||
[vehicleSsd, vehicleSsd],
|
||||
);
|
||||
if (vehicleRows.length === 0) return null;
|
||||
const vehicleId = vehicleRows[0].id as number;
|
||||
const catalogId = vehicleRows[0].catalog_id as number;
|
||||
|
||||
// 2) Resolve part_group.id from external gid scoped to this catalog.
|
||||
// Resolve (catalog.id, part_group.id) in one round trip.
|
||||
const [groupRows] = await this.pool.execute<RowDataPacket[]>(
|
||||
"SELECT id FROM part_groups WHERE catalog_id = ? AND group_id = ? LIMIT 1",
|
||||
[catalogId, gid],
|
||||
`SELECT pg.id AS pg_id, pg.catalog_id
|
||||
FROM catalogs c
|
||||
JOIN part_groups pg ON pg.catalog_id = c.id
|
||||
WHERE c.code = ? AND pg.group_id = ?
|
||||
LIMIT 1`,
|
||||
[catalogCode, gid],
|
||||
);
|
||||
if (groupRows.length === 0) return null;
|
||||
const groupPk = groupRows[0].id as number;
|
||||
const groupPk = groupRows[0].pg_id as number;
|
||||
|
||||
// 3) Parts for this vehicle in this group.
|
||||
// Parts: catalog-wide via vehicle_parts (no vehicle filter — see class
|
||||
// comment). DISTINCT collapses the same part being linked from many
|
||||
// vehicles in the catalog.
|
||||
const [partRows] = await this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT p.id AS part_id, p.part_number, p.name, p.position_number, p.pnc
|
||||
`SELECT DISTINCT p.id AS part_id, p.part_number, p.name, p.position_number, p.pnc
|
||||
FROM vehicle_parts vp
|
||||
JOIN parts p ON p.id = vp.part_id
|
||||
WHERE vp.vehicle_id = ? AND vp.group_id = ?
|
||||
WHERE vp.group_id = ?
|
||||
ORDER BY p.position_number, p.id`,
|
||||
[vehicleId, groupPk],
|
||||
[groupPk],
|
||||
);
|
||||
|
||||
const parts: EmexPart[] = partRows.map((r) => ({
|
||||
@@ -97,11 +124,11 @@ export class EmexSourceDbService implements OnModuleInit, OnModuleDestroy {
|
||||
positionCode: r.position_number ?? r.pnc ?? undefined,
|
||||
}));
|
||||
|
||||
// 4) Schema image + hotspots for this group.
|
||||
// Schema image + hotspots for this group.
|
||||
const [imgRows] = await this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT original_url, width, height, hotspots
|
||||
FROM part_images
|
||||
WHERE group_id = ? AND image_type IN ('DIAGRAM','SCHEMATIC')
|
||||
WHERE group_id = ? AND image_type IN ('DIAGRAM', 'SCHEMATIC')
|
||||
ORDER BY is_primary DESC, sort_order
|
||||
LIMIT 1`,
|
||||
[groupPk],
|
||||
@@ -135,24 +162,14 @@ export class EmexSourceDbService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
return { parts, schemaImageUrl, hotspots, schemaWidth, schemaHeight };
|
||||
} catch (err) {
|
||||
this.logger.warn(`[emex-src] lookup failed (gid=${gid}): ${(err as Error).message}`);
|
||||
this.logger.warn(
|
||||
`[emex-src] lookup failed (${catalogCode}/${gid}): ${(err as Error).message}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract `gid=...` from a QuickDetails.aspx / similar URL. */
|
||||
function extractGid(url: string): string | null {
|
||||
if (!url) return null;
|
||||
const m = url.match(/[?&]gid=([^&#]+)/i);
|
||||
if (!m) return null;
|
||||
try {
|
||||
return decodeURIComponent(m[1]);
|
||||
} catch {
|
||||
return m[1];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the dump's hotspots JSON ([{x,y,w,h,part_id}]) into the EmexHotspot
|
||||
* shape used by the live scraper (grouped by position code).
|
||||
|
||||
@@ -24,11 +24,19 @@ export class PcatSourceDbService implements OnModuleInit, OnModuleDestroy {
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
onModuleInit() {
|
||||
const enabled = this.config.get<boolean>("catalogSource.enabled");
|
||||
const masterEnabled = this.config.get<boolean>("catalogSource.enabled");
|
||||
const pcatEnabled = this.config.get<boolean>("catalogSource.pcatEnabled");
|
||||
const url = this.config.get<string>("catalogSource.pcatUrl");
|
||||
if (!enabled || !url) {
|
||||
// Default off: verified 2026-06-01 that the current pcat dump's deep-scrape
|
||||
// (7.978 cars with real parts) targets US/JDM-market models (Toyota 2112,
|
||||
// Nissan 1508, Audi 1311, Chevy 1050, Hyundai 745) and covers 0 of sase's
|
||||
// 103 dev TR-market pcat carIds via either bridge (schema_parts or
|
||||
// part_groups+part_group_items). Container stays up for future use cases.
|
||||
if (!masterEnabled || !pcatEnabled || !url) {
|
||||
this.logger.log(
|
||||
`[pcat-src] disabled (enabled=${enabled}, urlSet=${Boolean(url)}); upstream-only`,
|
||||
`[pcat-src] disabled (master=${masterEnabled}, pcat=${pcatEnabled}, urlSet=${Boolean(
|
||||
url,
|
||||
)}); upstream-only`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -51,6 +51,9 @@ async function bootstrap() {
|
||||
"https://challenges.cloudflare.com",
|
||||
"https://destek.sase.tr",
|
||||
"wss://destek.sase.tr",
|
||||
// Sentry browser SDK envelope POSTs (otolog org, de region).
|
||||
// Without this CSP silently blocks every error/replay upload.
|
||||
"https://*.ingest.de.sentry.io",
|
||||
],
|
||||
objectSrc: ["'none'"],
|
||||
frameSrc: ["https://challenges.cloudflare.com", "https://destek.sase.tr"],
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"@remotion/player": "^4.0.422",
|
||||
"@sase/shared": "workspace:*",
|
||||
"@sase/ui": "workspace:*",
|
||||
"@sentry/react": "^9.0.0",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"@tanstack/react-router": "^1.120.0",
|
||||
"better-auth": "^1.2.0",
|
||||
|
||||
57
apps/web/src/lib/sentry.ts
Normal file
57
apps/web/src/lib/sentry.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Sentry browser SDK init — gated on VITE_SENTRY_DSN.
|
||||
*
|
||||
* Co-exists with Faro (Grafana) and PostHog: Sentry handles uncaught JS errors
|
||||
* and on-error session replay; Faro handles RUM/traces; PostHog handles product
|
||||
* analytics. No conflict between the three at the global error-handler layer.
|
||||
*
|
||||
* KVKK note: `sendDefaultPii: false` and `maskAllText: true` on replay.
|
||||
*/
|
||||
import type { init as SentryInit } from "@sentry/react";
|
||||
|
||||
let initialized = false;
|
||||
|
||||
export async function initSentry() {
|
||||
if (initialized) return;
|
||||
const dsn = import.meta.env.VITE_SENTRY_DSN;
|
||||
if (!dsn) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.info("[sentry] VITE_SENTRY_DSN unset — skipping init");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const environment =
|
||||
import.meta.env.VITE_SENTRY_ENVIRONMENT ?? import.meta.env.MODE ?? "production";
|
||||
const release = import.meta.env.VITE_SENTRY_RELEASE;
|
||||
|
||||
try {
|
||||
const Sentry = await import("@sentry/react");
|
||||
(Sentry.init as typeof SentryInit)({
|
||||
dsn,
|
||||
environment,
|
||||
release,
|
||||
integrations: [
|
||||
Sentry.browserTracingIntegration(),
|
||||
Sentry.replayIntegration({ maskAllText: true, blockAllMedia: true }),
|
||||
],
|
||||
// Performance: 10% trace sample rate (low volume site, can raise later).
|
||||
tracesSampleRate: 0.1,
|
||||
// Replay: only when an error fires; no session-replay otherwise (cost + privacy).
|
||||
replaysSessionSampleRate: 0.0,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
// KVKK: do not auto-attach IP / cookies / user agent fingerprints.
|
||||
sendDefaultPii: false,
|
||||
// Drop framework noise that isn't actionable.
|
||||
ignoreErrors: [
|
||||
"ResizeObserver loop limit exceeded",
|
||||
"ResizeObserver loop completed with undelivered notifications.",
|
||||
"Non-Error promise rejection captured",
|
||||
],
|
||||
});
|
||||
initialized = true;
|
||||
console.log("[sentry] browser SDK initialized");
|
||||
} catch (err) {
|
||||
console.warn("[sentry] init failed:", (err as Error).message);
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,13 @@ import { initFaro } from "./lib/faro";
|
||||
import { initLocale } from "./lib/i18n";
|
||||
import { initMetaPixel } from "./lib/meta-pixel";
|
||||
import { initPostHog } from "./lib/posthog";
|
||||
import { initSentry } from "./lib/sentry";
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
import "./globals.css";
|
||||
|
||||
// Sentry first so it can capture errors thrown by other init code.
|
||||
initSentry();
|
||||
|
||||
// Read persisted locale from localStorage before first render — without this
|
||||
// a reload always falls back to the TR default even if the user picked EN.
|
||||
initLocale();
|
||||
|
||||
@@ -30,6 +30,9 @@ export default defineConfig({
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
sourcemap: false,
|
||||
// 'hidden' emits source maps but strips the //# sourceMappingURL comment
|
||||
// from JS bundles. Sentry can still consume them (via release upload), but
|
||||
// browsers won't fetch them, so end-user stack traces stay minified.
|
||||
sourcemap: "hidden",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -10,6 +10,11 @@ services:
|
||||
- VITE_META_PIXEL_ID=${VITE_META_PIXEL_ID:-}
|
||||
- VITE_CHATWOOT_BASE_URL=${VITE_CHATWOOT_BASE_URL:-}
|
||||
- VITE_CHATWOOT_WEBSITE_TOKEN=${VITE_CHATWOOT_WEBSITE_TOKEN:-}
|
||||
# Sentry browser SDK — gated on DSN presence (unset = skip init).
|
||||
# VITE_* args must be build-time; runtime env won't reach the bundle.
|
||||
- VITE_SENTRY_DSN=${VITE_SENTRY_DSN:-}
|
||||
- VITE_SENTRY_ENVIRONMENT=${VITE_SENTRY_ENVIRONMENT:-production}
|
||||
- VITE_SENTRY_RELEASE=${VITE_SENTRY_RELEASE:-}
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=4000
|
||||
@@ -68,6 +73,20 @@ services:
|
||||
- ML_PREDICTION_ENABLED=${ML_PREDICTION_ENABLED:-false}
|
||||
# Chatwoot live-chat widget — HMAC secret for verified user identity
|
||||
- CHATWOOT_HMAC_TOKEN=${CHATWOOT_HMAC_TOKEN:-}
|
||||
# Local catalog-dump lookup (CategoriesService → PcatSourceDb / EmexSourceDb).
|
||||
# Defaults off; when CATALOG_SOURCE_DB_ENABLED=true + URLs set, parts fetches
|
||||
# hit the dump first and only fall through to live upstream on miss.
|
||||
- CATALOG_SOURCE_DB_ENABLED=${CATALOG_SOURCE_DB_ENABLED:-false}
|
||||
- PCAT_SOURCE_DB_URL=${PCAT_SOURCE_DB_URL:-}
|
||||
- EMEX_SOURCE_DB_URL=${EMEX_SOURCE_DB_URL:-}
|
||||
# Per-source kill switches. pcat default off (dump doesn't cover TR vehicles).
|
||||
- EMEX_SOURCE_DB_ENABLED=${EMEX_SOURCE_DB_ENABLED:-true}
|
||||
- PCAT_SOURCE_DB_ENABLED=${PCAT_SOURCE_DB_ENABLED:-false}
|
||||
# Per-catalog parts-lookup allowlist (comma-separated). Empty → all
|
||||
# fetchCategoryParts calls return null. See sase-emex-source-db-safety
|
||||
# memory: catalog-wide bridge had 7-114x noise; only add a catalog once
|
||||
# its per-vehicle bridge is wired & OEM-verified.
|
||||
- EMEX_SOURCE_DB_ALLOWED_CATALOGS=${EMEX_SOURCE_DB_ALLOWED_CATALOGS:-}
|
||||
depends_on:
|
||||
sase-redis:
|
||||
condition: service_healthy
|
||||
@@ -130,6 +149,18 @@ services:
|
||||
- OTEL_EXPORTER_OTLP_HEADERS=${OTEL_EXPORTER_OTLP_HEADERS:-}
|
||||
- OTEL_SERVICE_NAME=${OTEL_SERVICE_NAME:-sase-worker}
|
||||
- OTEL_TRACE_SAMPLE_RATE=${OTEL_TRACE_SAMPLE_RATE:-1.0}
|
||||
# Local catalog-dump lookup (worker drives prefetch which uses CategoriesService).
|
||||
- CATALOG_SOURCE_DB_ENABLED=${CATALOG_SOURCE_DB_ENABLED:-false}
|
||||
- PCAT_SOURCE_DB_URL=${PCAT_SOURCE_DB_URL:-}
|
||||
- EMEX_SOURCE_DB_URL=${EMEX_SOURCE_DB_URL:-}
|
||||
# Per-source kill switches. pcat default off (dump doesn't cover TR vehicles).
|
||||
- EMEX_SOURCE_DB_ENABLED=${EMEX_SOURCE_DB_ENABLED:-true}
|
||||
- PCAT_SOURCE_DB_ENABLED=${PCAT_SOURCE_DB_ENABLED:-false}
|
||||
# Per-catalog parts-lookup allowlist (comma-separated). Empty → all
|
||||
# fetchCategoryParts calls return null. See sase-emex-source-db-safety
|
||||
# memory: catalog-wide bridge had 7-114x noise; only add a catalog once
|
||||
# its per-vehicle bridge is wired & OEM-verified.
|
||||
- EMEX_SOURCE_DB_ALLOWED_CATALOGS=${EMEX_SOURCE_DB_ALLOWED_CATALOGS:-}
|
||||
depends_on:
|
||||
sase-redis:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -120,6 +120,35 @@ export const envSchema = z.object({
|
||||
.default("false"),
|
||||
PCAT_SOURCE_DB_URL: z.string().url().optional(),
|
||||
EMEX_SOURCE_DB_URL: z.string().optional(), // mysql://... — not a strict URL per WHATWG
|
||||
// Per-source kill switches under the master CATALOG_SOURCE_DB_ENABLED.
|
||||
// EMEX_SOURCE_DB_ENABLED keeps the connection pool alive but, per the
|
||||
// 2026-06-01 safety audit, fetchCategoryParts ALWAYS returns null unless the
|
||||
// requested catalogCode is also in EMEX_SOURCE_DB_ALLOWED_CATALOGS. The
|
||||
// catalog-wide bridge measured 7-114x noiseRatio across every catalog and
|
||||
// 49-98 wrong-OEM per 100 served — direct violation of the "always correct
|
||||
// OEM" rule. Default allowlist is EMPTY → behaviour is safe by default; the
|
||||
// master/emex switches stay default-on so the service is ready for the
|
||||
// per-vehicle unique_key bridge (follow-up work).
|
||||
// PCAT_SOURCE_DB_ENABLED defaults FALSE — verified 2026-06-01 that the dump's
|
||||
// deep-scrape covers a US/JDM market subset (Toyota/Nissan/Audi/Chevy/Hyundai)
|
||||
// that doesn't intersect sase's TR-market vehicle pool (0 / 103 dev carIds
|
||||
// had real parts data through either bridge). Container stays running for
|
||||
// future use cases (OEM cross-ref, alt-part search).
|
||||
EMEX_SOURCE_DB_ENABLED: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default("true"),
|
||||
PCAT_SOURCE_DB_ENABLED: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.default("false"),
|
||||
// Per-catalog parts-lookup allowlist. Comma-separated catalog codes
|
||||
// (e.g. "RENAULT201910,FFIAT84"). Empty (default) → fetchCategoryParts
|
||||
// always returns null → live emex handles every request. A catalog SHOULD
|
||||
// only be added here AFTER its per-vehicle bridge (unique_key) is wired and
|
||||
// verified against live OEM-by-OEM on at least 5 sampled vehicles. See
|
||||
// memory `sase-emex-source-db-safety.md` for the bridge inventory & audit.
|
||||
EMEX_SOURCE_DB_ALLOWED_CATALOGS: z.string().default(""),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
|
||||
80
pnpm-lock.yaml
generated
80
pnpm-lock.yaml
generated
@@ -214,6 +214,9 @@ importers:
|
||||
'@sase/ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/ui
|
||||
'@sentry/react':
|
||||
specifier: ^9.0.0
|
||||
version: 9.47.1(react@19.2.4)
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.62.0
|
||||
version: 5.90.21(react@19.2.4)
|
||||
@@ -2864,14 +2867,38 @@ packages:
|
||||
'@scarf/scarf@1.4.0':
|
||||
resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==}
|
||||
|
||||
'@sentry-internal/browser-utils@9.47.1':
|
||||
resolution: {integrity: sha512-twv6YhrUlPkvKz4/iQDH4KHgcv9t4cMjmZPf4/dCSCXn4/GOjzjx2d74c1w+1KOdS7lcsQzI+MtbK6SeYLiGfQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry-internal/feedback@9.47.1':
|
||||
resolution: {integrity: sha512-xJ4vKvIpAT8e+Sz80YrsNinPU0XV7jPxPjdZ4ex8R2mMvx7pM0gq8JiR/sIVmNiOE0WiUDr6VwLDE8j2APSRMA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry-internal/node-cpu-profiler@2.4.0':
|
||||
resolution: {integrity: sha512-zMrbqkd05LS1Ibt+js4R1aMmjdAO0yi9xiywWeulYs/bxN8P5qq20QHYleI76MorsocvYJAFo9GkYfzyzMd6Og==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry-internal/replay-canvas@9.47.1':
|
||||
resolution: {integrity: sha512-r9nve+l5+elGB9NXSN1+PUgJy790tXN1e8lZNH2ziveoU91jW4yYYt34mHZ30fU9tOz58OpaRMj3H3GJ/jYZVA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry-internal/replay@9.47.1':
|
||||
resolution: {integrity: sha512-O9ZEfySpstGtX1f73m3NbdbS2utwPikaFt6sgp74RG4ZX4LlXe99VAjKR464xKECpYsLmj2bYpiK4opURF0pBA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry/browser@9.47.1':
|
||||
resolution: {integrity: sha512-at5JOLziw5QpVYytxTDU6xijdV6lDQ/Rxp/qXJaHXud3gIK4suv2cXW+tupJfwoUoHFCnDNfccjCmPmP0yRqiA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry/core@10.52.0':
|
||||
resolution: {integrity: sha512-VA/kAqLhkMnRWY2RXdBLyTemR9D4m7MVRy/gyapoq9yvllVPx9WXbvKgnMP2LQp7mFgT/oLFvw58aQKaYTGn3A==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry/core@9.47.1':
|
||||
resolution: {integrity: sha512-KX62+qIt4xgy8eHKHiikfhz2p5fOciXd0Cl+dNzhgPFq8klq4MGMNaf148GB3M/vBqP4nw/eFvRMAayFCgdRQw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry/nestjs@10.52.0':
|
||||
resolution: {integrity: sha512-biGgCtQ6kBg5dthoaZMdg4frcLk1To1rfP4TB7njBmRhR0nRfvNBsyqc33afkhxCu2UOuJ+ucOKgh3+LUSOv8Q==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2921,6 +2948,12 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
'@sentry/react@9.47.1':
|
||||
resolution: {integrity: sha512-Anqt0hG1R+nktlwEiDc2FmD+6DUGMJOLuArgr7q1cSCdPbK2Gb1eZ2rF57Ui+CDo9XLvlX9QP2is/M08rrVe3w==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: ^16.14.0 || 17.x || 18.x || 19.x
|
||||
|
||||
'@smithy/abort-controller@4.2.8':
|
||||
resolution: {integrity: sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -4589,6 +4622,9 @@ packages:
|
||||
resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
hoist-non-react-statics@3.3.2:
|
||||
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
|
||||
|
||||
html-encoding-sniffer@6.0.0:
|
||||
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
@@ -5501,6 +5537,9 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^19.2.4
|
||||
|
||||
react-is@16.13.1:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
|
||||
react-is@17.0.2:
|
||||
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
||||
|
||||
@@ -8992,13 +9031,41 @@ snapshots:
|
||||
|
||||
'@scarf/scarf@1.4.0': {}
|
||||
|
||||
'@sentry-internal/browser-utils@9.47.1':
|
||||
dependencies:
|
||||
'@sentry/core': 9.47.1
|
||||
|
||||
'@sentry-internal/feedback@9.47.1':
|
||||
dependencies:
|
||||
'@sentry/core': 9.47.1
|
||||
|
||||
'@sentry-internal/node-cpu-profiler@2.4.0':
|
||||
dependencies:
|
||||
detect-libc: 2.1.2
|
||||
node-abi: 3.92.0
|
||||
|
||||
'@sentry-internal/replay-canvas@9.47.1':
|
||||
dependencies:
|
||||
'@sentry-internal/replay': 9.47.1
|
||||
'@sentry/core': 9.47.1
|
||||
|
||||
'@sentry-internal/replay@9.47.1':
|
||||
dependencies:
|
||||
'@sentry-internal/browser-utils': 9.47.1
|
||||
'@sentry/core': 9.47.1
|
||||
|
||||
'@sentry/browser@9.47.1':
|
||||
dependencies:
|
||||
'@sentry-internal/browser-utils': 9.47.1
|
||||
'@sentry-internal/feedback': 9.47.1
|
||||
'@sentry-internal/replay': 9.47.1
|
||||
'@sentry-internal/replay-canvas': 9.47.1
|
||||
'@sentry/core': 9.47.1
|
||||
|
||||
'@sentry/core@10.52.0': {}
|
||||
|
||||
'@sentry/core@9.47.1': {}
|
||||
|
||||
'@sentry/nestjs@10.52.0(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@opentelemetry/exporter-trace-otlp-http@0.212.0(@opentelemetry/api@1.9.0))':
|
||||
dependencies:
|
||||
'@nestjs/common': 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
@@ -9079,6 +9146,13 @@ snapshots:
|
||||
- '@opentelemetry/exporter-trace-otlp-http'
|
||||
- supports-color
|
||||
|
||||
'@sentry/react@9.47.1(react@19.2.4)':
|
||||
dependencies:
|
||||
'@sentry/browser': 9.47.1
|
||||
'@sentry/core': 9.47.1
|
||||
hoist-non-react-statics: 3.3.2
|
||||
react: 19.2.4
|
||||
|
||||
'@smithy/abort-controller@4.2.8':
|
||||
dependencies:
|
||||
'@smithy/types': 4.12.0
|
||||
@@ -10981,6 +11055,10 @@ snapshots:
|
||||
|
||||
helmet@8.1.0: {}
|
||||
|
||||
hoist-non-react-statics@3.3.2:
|
||||
dependencies:
|
||||
react-is: 16.13.1
|
||||
|
||||
html-encoding-sniffer@6.0.0(@noble/hashes@2.0.1):
|
||||
dependencies:
|
||||
'@exodus/bytes': 1.13.0(@noble/hashes@2.0.1)
|
||||
@@ -12124,6 +12202,8 @@ snapshots:
|
||||
react: 19.2.4
|
||||
scheduler: 0.27.0
|
||||
|
||||
react-is@16.13.1: {}
|
||||
|
||||
react-is@17.0.2: {}
|
||||
|
||||
react-markdown@9.1.0(@types/react@19.2.14)(react@19.2.4):
|
||||
|
||||
Reference in New Issue
Block a user