Files
sase.tr/packages/config/src/index.ts
Semih Yesilyurt 337192685c
Some checks are pending
QA Gate (P0/P1) / Test affected app (pull_request) Waiting to run
feat(carcatonline): saatlik istek kotası + kaldığı yerden devam eden tarama
Dev'de ölçüldü: upstream 429 gövdesi "Customer Hourly Request Limit Exceeded",
~110 çağrı sonra; 47 dk sonraki denemede hâlâ 429 (kayan saatlik kota, sabit
kilit değil). Kilide çarpıp her araç için baştan başlamak yerine:
- Throttle: `CARCATONLINE_HOURLY_CALL_CAP` (varsayılan 90) — Istanbul saat
  dilimi başına Redis sayaç; dolunca iş kendini bir sonraki saat başına erteler.
- Tarama cache'i: groups2 cevapları Redis'te 24 s (`carcatonline:groups:*`);
  tekrar koşan iş cache'ten okur, çağrı harcamaz.
- Varyant çözümü metadata'ya `status:"matched"` olarak yazılır; tarama saat
  ortasında kesilirse sonraki koşu kademeli seçimi atlar. Scan 'matched'
  satırları 7 günlük bekleme olmadan yeniden kuyruklar.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-26 16:25:00 +03:00

293 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { z } from "zod";
export const envSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
PORT: z.coerce.number().default(4000),
DATABASE_URL: z.string().url(),
REDIS_HOST: z.string().default("127.0.0.1"),
REDIS_PORT: z.coerce.number().default(6379),
REDIS_PASSWORD: z.string(),
BETTER_AUTH_SECRET: z.string().min(32),
BETTER_AUTH_URL: z.string().url(),
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
// Cloudflare Turnstile (captcha) — tanımlı değilse captcha atlanır
TURNSTILE_SECRET_KEY: z.string().optional(),
MINIO_ENDPOINT: z.string(),
MINIO_ACCESS_KEY: z.string(),
MINIO_SECRET_KEY: z.string(),
MINIO_BUCKET_NAME: z.string().default("sase-schemas"),
MINIO_PUBLIC_URL: z.string(),
MINIO_USE_SSL: z
.string()
.transform((v) => v === "true")
.default("false"),
CORS_ORIGIN: z.string().default("http://localhost:3000"),
STRIPE_SECRET_KEY: z.string().optional(),
STRIPE_PUBLISHABLE_KEY: z.string().optional(),
STRIPE_WEBHOOK_SECRET: z.string().optional(),
STRIPE_SUCCESS_URL: z
.string()
.url()
.default("http://localhost:3000/dashboard/subscription?stripe=success"),
STRIPE_CANCEL_URL: z
.string()
.url()
.default("http://localhost:3000/dashboard/subscription?stripe=cancelled"),
PL24_BASE_URL: z.string().optional(),
PL24_COMPANY_CODE: z.string().optional(),
PL24_USERNAME: z.string().optional(),
PL24_PASSWORD: z.string().optional(),
PL24_COMPANY_CODE_2: z.string().optional(), // de-708171
PL24_USERNAME_2: z.string().optional(),
PL24_PASSWORD_2: z.string().optional(),
PL24_PROXY_DE: z.string().optional(), // http://user:pass@gw.dataimpulse.com:10000
EMEX_USERNAME: z.string().optional(),
EMEX_PASSWORD: z.string().optional(),
// Vinpin ePER decode-oracle (browser-automated yetkili-servis catalog).
// Master feature flag — OFF by default. When false the whole driver/queue/
// integration is a strict no-op (decodeVin behaviour is byte-identical to
// before). Auth/URL come from env so the paid seat can be swapped in later
// with ZERO code change. See integrations/vinpin.
VINPIN_ENABLED: z
.string()
.transform((v) => v === "true")
.default("false"),
VINPIN_URL: z.string().url().default("https://login.tr.vinpin.online"),
VINPIN_USER: z.string().optional(),
VINPIN_PASS: z.string().optional(),
// Per-VIN decode retry budget inside the driver before giving up (-> not_found).
VINPIN_DECODE_MAX_ATTEMPTS: z.coerce.number().int().min(1).default(3),
// Run the Vinpin browser headful (xvfb path). Default headless, matching the
// emex browser which already runs headless in the deployed image.
VINPIN_HEADFUL: z
.string()
.transform((v) => v === "true")
.default("false"),
// RPartStore (rpartstore.renault.com) Renault/Dacia VIN-decode fallback — runs
// AFTER the pcat/PL24/emex race, before Vinpin. Off by default; the worker
// needs the dealer credentials. RPARTSTORE_DAILY_CAP is a hard ceiling on VIN
// searches sent per Istanbul day (the portal itself also rate-limits 2/10 s).
RPARTSTORE_ENABLED: z
.string()
.transform((v) => v === "true")
.default("false"),
RPARTSTORE_USER: z.string().optional(),
RPARTSTORE_PASS: z.string().optional(),
RPARTSTORE_DAILY_CAP: z.coerce.number().int().min(0).default(10),
RPARTSTORE_BROKER_URL: z.preprocess(
(v) => (typeof v === "string" && v.trim() === "" ? undefined : v),
z.string().url().default("wss://1po-bff.renault-edh.com/ws"),
),
RPARTSTORE_APP_VERSION: z.preprocess(
(v) => (typeof v === "string" && v.trim() === "" ? undefined : v),
z.string().default("1.34.0.6"),
),
// carcatonline (pro.carcatonline.com, PartsLink24 mirror) — night-window
// category backfill for empty PL24 catalog vehicles + on-demand parts for the
// seeded leaves. Shared Redis pacing/lockout/daily budget across api+worker.
CARCATONLINE_ENABLED: z
.string()
.transform((v) => v === "true")
.default("false"),
CARCATONLINE_EMAIL: z.string().optional(),
CARCATONLINE_PASSWORD: z.string().optional(),
CARCATONLINE_DAILY_CALL_CAP: z.coerce.number().int().min(0).default(15000),
CARCATONLINE_HOURLY_CALL_CAP: z.coerce.number().int().min(0).default(90),
CARCATONLINE_WINDOW_START: z.coerce.number().int().min(0).max(23).default(20),
CARCATONLINE_WINDOW_END: z.coerce.number().int().min(0).max(23).default(7),
CARCATONLINE_MIN_INTERVAL_MS: z.coerce.number().int().min(500).default(7000),
CARCATONLINE_LOCKOUT_SECONDS: z.coerce.number().int().min(60).default(2400),
CARCATONLINE_SCAN_BATCH: z.coerce.number().int().min(1).default(20),
// Parts-Catalogs (Playwright JWT capture + DataImpulse proxy)
PCAT_USE_PROXY: z.string().default("true"),
PCAT_PROXY_HOST: z.string().default("gw.dataimpulse.com"),
PCAT_PROXY_USER: z.string().optional(),
PCAT_PROXY_PASS: z.string().optional(),
ML_PREDICTION_ENABLED: z
.string()
.transform((v) => v === "true")
.default("false"),
// OpenRouter (used by scripts/emex-translate-bootstrap.ts → DeepSeek V3,
// worker translation processor and the VIN photo-OCR endpoint)
OPENROUTER_API_KEY: z.string().optional(),
// VIN photo-OCR (POST /vehicles/decode/ocr) — vision models called through
// OpenRouter with the key above. Bake-off 2026-07-09: gemini-2.5-flash reads
// type plates/stickers reliably (~$0.0005/photo); flash-lite mangles digits
// on hard shots, so don't downgrade. Fallback runs only when the primary
// returns no valid 17-char VIN — cross-provider for failure independence.
VIN_OCR_MODEL: z.preprocess(
(v) => (typeof v === "string" && v.trim() === "" ? undefined : v),
z.string().default("google/gemini-2.5-flash"),
),
VIN_OCR_FALLBACK_MODEL: z.preprocess(
(v) => (typeof v === "string" && v.trim() === "" ? undefined : v),
z.string().default("openai/gpt-5.4-nano"),
),
// Postal Email
POSTAL_API_URL: z.string().url().optional(),
POSTAL_API_KEY: z.string().optional(),
POSTAL_FROM_ADDRESS: z.string().email().default("noreply@sase.tr"),
POSTAL_FROM_NAME: z.string().default("Sase.tr"),
// Novu — lifecycle/transactional email automation (Tailscale-only, sends via Postal).
// When NOVU_API_KEY is unset, triggers are logged and skipped (dev fallback).
NOVU_API_URL: z.string().url().default("https://api.bildirim.semih.ai"),
NOVU_API_KEY: z.string().optional(),
// Public marketing-site origin used to build CTA targets (e.g. https://sase.tr/dashboard).
APP_PUBLIC_URL: z.string().url().default("https://sase.tr"),
// HMAC secret for signed track.sase.tr click links. When unset, CTAs are passed
// un-wrapped (no click tracking) — links still work.
MAILTRACK_SECRET: z.string().optional(),
// Unsubscribe (List-Unsubscribe header + body footer links). When the secret
// is unset, mails carry the mailto: variant only and one-click POSTs are
// rejected — fine for dev, must be set in prod.
UNSUBSCRIBE_SECRET: z.string().optional(),
// "" → undefined preprocess: compose ships `${VAR:-}` so unset values arrive
// as empty strings, and a bare .url()/.email() would crash boot on "".
UNSUBSCRIBE_URL_BASE: z.preprocess(
(v) => (typeof v === "string" && v.trim() === "" ? undefined : v),
z.string().url().optional(),
),
UNSUBSCRIBE_EMAIL: z.preprocess(
(v) => (typeof v === "string" && v.trim() === "" ? undefined : v),
z.string().email().default("unsubscribe@sase.tr"),
),
// OpenTelemetry
OTEL_ENABLED: z
.string()
.transform((v) => v === "true")
.default("false"),
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().optional(),
OTEL_EXPORTER_OTLP_HEADERS: z.string().optional(),
OTEL_SERVICE_NAME: z.string().default("sase-api"),
OTEL_TRACE_SAMPLE_RATE: z.coerce.number().min(0).max(1).default(1.0),
// PostHog — product analytics (server-side)
POSTHOG_API_KEY: z.string().optional(),
POSTHOG_HOST: z.string().url().default("https://t.sase.tr"),
// Personal API key (or feature-flags secure key) → enables LOCAL server-side
// feature-flag evaluation: source kill switches, guarded decode rollout, and
// remote-config ops tuning. Without it those flags fail open (no effect, no
// added latency). Keep secret — never expose to the browser.
POSTHOG_PERSONAL_API_KEY: z.string().optional(),
// Gate server-side analytics CAPTURE independently of flag evaluation, so a
// non-prod env can evaluate flags (kill switches, rollout) while the project
// key is set WITHOUT shipping events to the shared prod project. Default on;
// set "false" on dev. (Plain string — read as `!== "false"`.)
POSTHOG_CAPTURE_ENABLED: z.string().optional(),
// Meta Conversions API — server-side signup tracking (CompleteRegistration) so
// Meta can optimize toward / attribute signups despite the unreliable browser
// pixel. Empty → CAPI no-ops. Pixel id is public; the access token is a secret
// from Events Manager. Test event code routes to Events Manager > Test Events.
META_CAPI_PIXEL_ID: z.string().optional(),
META_CAPI_ACCESS_TOKEN: z.string().optional(),
META_CAPI_TEST_EVENT_CODE: z.string().optional(),
// Sentry — error tracking
SENTRY_DSN: z.string().url().optional(),
// Changelog automation (token-authed internal endpoint for Fusion webhook)
CHANGELOG_AUTOMATION_TOKEN: z.string().min(32).optional(),
// Chatwoot — HMAC secret for the verified live-chat widget identity
// (/api/chatwoot/identity → setUser identifier_hash). When unset, that
// endpoint returns 503 and the widget falls back to anonymous visitors.
CHATWOOT_HMAC_TOKEN: z.string().optional(),
// Catalog-source dumps — local DB-first lookup before live scrape.
// When CATALOG_SOURCE_DB_ENABLED is "true" AND the URL for a source is set,
// prefetch/category fetches will try the local dump DB first and only fall
// back to the live upstream on a miss. Both URLs optional independently.
CATALOG_SOURCE_DB_ENABLED: z
.string()
.transform((v) => v === "true")
.default("false"),
// docker-compose's `${VAR:-}` substitution always sets the env, even if to
// an empty string. zod's `.optional()` only accepts undefined, so a chained
// `.url()` would reject "" and crash boot — preprocess "" → undefined first.
PCAT_SOURCE_DB_URL: z.preprocess(
(v) => (typeof v === "string" && v.trim() === "" ? undefined : v),
z.string().url().optional(),
),
EMEX_SOURCE_DB_URL: z.preprocess(
(v) => (typeof v === "string" && v.trim() === "" ? undefined : v),
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(""),
// Tedarikçi fiyat verisi (takip MySQL, Tailscale üzerinden). Kapalı/boş URL →
// part-prices uçları { matched: false } döner, UI fiyat bölümünü hiç çizmez.
SUPPLIER_PRICE_DB_ENABLED: z
.string()
.transform((v) => v === "true")
.default("false"),
SUPPLIER_PRICE_DB_URL: z.preprocess(
(v) => (typeof v === "string" && v.trim() === "" ? undefined : v),
z.string().optional(),
), // mysql://... — not a strict URL per WHATWG
});
export type Env = z.infer<typeof envSchema>;
export function validateEnv(
env: Record<string, unknown> = process.env as Record<string, unknown>,
): Env {
const result = envSchema.safeParse(env);
if (!result.success) {
const formatted = result.error.format();
console.error("Environment validation failed:");
console.error(JSON.stringify(formatted, null, 2));
throw new Error("Invalid environment variables");
}
return result.data;
}