import crypto from "node:crypto"; export function loadConfig(env = process.env) { return { atomentaOrigin: (env.ATOMENTA_ORIGIN || "https://atomenta.com.br").replace(/\/+$/, ""), moduleToken: env.FEATURE_CONTROL_MODULE_TOKEN || env.ATOMENTA_FEATURE_CONTROL_MODULE_ID || "", defaultEnvironment: env.FEATURE_CONTROL_ENVIRONMENT || "production", cacheTtlMs: Number(env.FEATURE_CONTROL_CACHE_TTL_MS || 60_000), requestTimeoutMs: Number(env.FEATURE_CONTROL_TIMEOUT_MS || 2_000), staticDefaults: parseDefaults(env.FEATURE_CONTROL_DEFAULTS_JSON) }; } export function createRuntime(config) { return { cache: new Map(), inFlight: new Map(), config }; } export function nowIso() { return new Date().toISOString(); } export function writeJson(res, status, payload) { const body = JSON.stringify(payload); res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Content-Length": Buffer.byteLength(body), "Cache-Control": "no-store" }); res.end(body); } const MAX_BODY_BYTES = 128 * 1024; const MAX_KEYS = 100; const MAX_ATTRIBUTES_BYTES = 16 * 1024; const MAX_EXPOSURE_EVENTS = 100; function parseDefaults(raw) { if (!raw) return {}; const parsed = safeParseJson(raw); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; return parsed; } function safeParseJson(input) { try { return JSON.parse(input); } catch { return null; } } function normalizeString(value) { return typeof value === "string" ? value.trim() : ""; } function getSubjectFromAuthHeader(authHeader) { if (!authHeader || !authHeader.toLowerCase().startsWith("bearer ")) return null; const token = authHeader.slice(7).trim(); const parts = token.split("."); if (parts.length < 2) return null; try { const payloadBase64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); const padded = payloadBase64 + "=".repeat((4 - (payloadBase64.length % 4)) % 4); const payload = JSON.parse(Buffer.from(padded, "base64").toString("utf-8")); const subjectId = normalizeString(payload.sub) || normalizeString(payload.customerId) || normalizeString(payload.id); if (!subjectId) return null; return { subjectType: "customer", subjectId }; } catch { return null; } } function buildCacheKey(input) { const stable = JSON.stringify({ env: input.environment, keys: input.keys, context: { subjectType: input.context.subjectType, subjectId: input.context.subjectId, storeId: input.context.storeId || "", platform: input.context.platform || "", appVersion: input.context.appVersion || "", attributes: input.context.attributes || {} } }); return crypto.createHash("sha256").update(stable).digest("hex"); } async function readBody(req) { const chunks = []; let size = 0; for await (const chunk of req) { size += chunk.length; if (size > MAX_BODY_BYTES) { throw Object.assign(new Error("Payload muito grande"), { status: 413, code: "PAYLOAD_TOO_LARGE" }); } chunks.push(chunk); } return Buffer.concat(chunks).toString("utf-8"); } function validateBootstrapRequest(body, authHeader, config) { if (!body || typeof body !== "object" || Array.isArray(body)) { throw Object.assign(new Error("Body inválido"), { status: 400, code: "INVALID_BODY" }); } const keys = Array.isArray(body.keys) ? body.keys.map(normalizeString).filter(Boolean) : []; if (!keys.length) throw Object.assign(new Error("`keys` é obrigatório"), { status: 400, code: "FEATURE_CONTROL_KEYS_REQUIRED" }); if (keys.length > MAX_KEYS) { throw Object.assign(new Error(`Máximo de ${MAX_KEYS} chaves por request`), { status: 400, code: "FEATURE_CONTROL_TOO_MANY_KEYS" }); } const environment = normalizeString(body.environment) || config.defaultEnvironment; const contextInput = body.context && typeof body.context === "object" ? body.context : {}; const fromJwt = getSubjectFromAuthHeader(authHeader); const subjectType = normalizeString(contextInput.subjectType) || fromJwt?.subjectType || "customer"; const subjectId = normalizeString(contextInput.subjectId) || fromJwt?.subjectId || ""; if (!subjectId) { throw Object.assign(new Error("`context.subjectId` é obrigatório"), { status: 400, code: "FEATURE_CONTROL_SUBJECT_ID_REQUIRED" }); } const allowed = new Set(["user", "customer", "store", "anonymous"]); if (!allowed.has(subjectType)) { throw Object.assign(new Error("`context.subjectType` inválido"), { status: 400, code: "FEATURE_CONTROL_SUBJECT_TYPE_INVALID" }); } const attributes = contextInput.attributes && typeof contextInput.attributes === "object" && !Array.isArray(contextInput.attributes) ? contextInput.attributes : {}; if (Buffer.byteLength(JSON.stringify(attributes), "utf-8") > MAX_ATTRIBUTES_BYTES) { throw Object.assign(new Error("`context.attributes` excede limite"), { status: 400, code: "FEATURE_CONTROL_ATTRIBUTES_TOO_LARGE" }); } return { environment, keys: [...new Set(keys)].sort(), context: { subjectType, subjectId, storeId: normalizeString(contextInput.storeId) || undefined, platform: normalizeString(contextInput.platform) || undefined, appVersion: normalizeString(contextInput.appVersion) || undefined, attributes } }; } function validateExposureRequest(body) { if (!body || typeof body !== "object" || Array.isArray(body)) { throw Object.assign(new Error("Body inválido"), { status: 400, code: "INVALID_BODY" }); } if (!Array.isArray(body.events) || body.events.length === 0) { throw Object.assign(new Error("`events` é obrigatório"), { status: 400, code: "FEATURE_CONTROL_EVENTS_REQUIRED" }); } if (body.events.length > MAX_EXPOSURE_EVENTS) { throw Object.assign(new Error(`Máximo de ${MAX_EXPOSURE_EVENTS} eventos por request`), { status: 400, code: "FEATURE_CONTROL_EXPOSURE_TOO_MANY_EVENTS" }); } const events = body.events.map((event) => ({ featureKey: normalizeString(event?.featureKey), variant: normalizeString(event?.variant) || "off", subjectType: normalizeString(event?.subjectType) || "customer", storeId: normalizeString(event?.storeId) || undefined })); for (const event of events) { if (!event.featureKey) { throw Object.assign(new Error("`featureKey` é obrigatório"), { status: 400, code: "FEATURE_CONTROL_EXPOSURE_FEATURE_KEY_REQUIRED" }); } } return { events }; } function buildFallbackFlags(keys, config) { const raw = {}; const simplified = {}; for (const key of keys) { const fromEnv = config.staticDefaults[key]; const enabled = Boolean(fromEnv?.enabled); const variant = typeof fromEnv?.variant === "string" ? fromEnv.variant : enabled ? "on" : "off"; raw[key] = { enabled, variant, payload: fromEnv?.payload ?? null, reason: "fallback_default" }; } for (const [key, value] of Object.entries(raw)) { const simplifiedKey = key.replace(/^fc\./, "").replace(/[_-]([a-z])/g, (_, c) => c.toUpperCase()).replace(/\./g, ""); if (!simplified[simplifiedKey]) { simplified[simplifiedKey] = value.variant === "on" || value.enabled === true ? true : value.variant; } } return { raw, simplified }; } function mapUpstreamError(error) { return { status: Number(error?.status || 500), code: normalizeString(error?.code) || "FEATURE_CONTROL_BFF_ERROR", message: normalizeString(error?.message) || "Erro ao avaliar flags" }; } async function callAtomenta(path, requestPayload, config) { if (!config.moduleToken) { throw Object.assign(new Error("Token de módulo não configurado"), { status: 500, code: "FEATURE_CONTROL_MODULE_TOKEN_MISSING" }); } const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), config.requestTimeoutMs); try { const response = await fetch(`${config.atomentaOrigin}${path}`, { method: "POST", signal: controller.signal, headers: { "Content-Type": "application/json", "Accept": "application/json", "Atomenta-Token": config.moduleToken }, body: JSON.stringify(requestPayload) }); const text = await response.text(); const parsed = safeParseJson(text) || {}; if (!response.ok) { const upstreamCode = parsed.code || "FEATURE_CONTROL_UPSTREAM_ERROR"; const upstreamMessage = parsed.message || `Erro upstream (${response.status})`; throw Object.assign(new Error(upstreamMessage), { status: response.status, code: upstreamCode }); } return parsed; } catch (error) { if (error.name === "AbortError") { throw Object.assign(new Error("Timeout no upstream"), { status: 504, code: "FEATURE_CONTROL_UPSTREAM_TIMEOUT" }); } throw error; } finally { clearTimeout(timeout); } } function formatBffResponse(evaluated, requestPayload, source = "live") { const result = evaluated?.result || {}; const flags = result.flags && typeof result.flags === "object" ? result.flags : {}; const simplified = {}; for (const key of requestPayload.keys) { const value = flags[key]; if (!value || typeof value !== "object") continue; const simplifiedKey = key.replace(/^fc\./, "").replace(/[_-]([a-z])/g, (_, c) => c.toUpperCase()).replace(/\./g, ""); if (!simplified[simplifiedKey]) simplified[simplifiedKey] = value.variant === "on" || value.enabled === true ? true : value.variant || "off"; } return { ok: true, source, configVersion: Number(result.configVersion || 0), evaluatedAt: result.evaluatedAt || nowIso(), flags: simplified, raw: flags }; } async function evaluateWithCache(requestPayload, runtime) { const cacheKey = buildCacheKey(requestPayload); const current = runtime.cache.get(cacheKey); if (current && current.expiresAt > Date.now()) return current.payload; const inflight = runtime.inFlight.get(cacheKey); if (inflight) return inflight; const work = (async () => { const evaluated = await callAtomenta("/api/feature-control/evaluate", requestPayload, runtime.config); const payload = formatBffResponse(evaluated, requestPayload, "live"); runtime.cache.set(cacheKey, { payload, expiresAt: Date.now() + runtime.config.cacheTtlMs }); return payload; })(); runtime.inFlight.set(cacheKey, work); try { return await work; } finally { runtime.inFlight.delete(cacheKey); } } export async function handleBootstrap(req, res, runtime) { try { const body = safeParseJson(await readBody(req)); if (!body) return writeJson(res, 400, { ok: false, code: "INVALID_JSON", message: "JSON inválido" }); const requestPayload = validateBootstrapRequest(body, req.headers.authorization, runtime.config); try { const payload = await evaluateWithCache(requestPayload, runtime); return writeJson(res, 200, payload); } catch (error) { const mapped = mapUpstreamError(error); const fallback = buildFallbackFlags(requestPayload.keys, runtime.config); return writeJson(res, mapped.status === 400 ? 400 : 200, { ok: mapped.status === 400 ? false : true, source: "fallback", configVersion: 0, evaluatedAt: nowIso(), flags: fallback.simplified, raw: fallback.raw, upstreamError: mapped }); } } catch (error) { return writeJson(res, Number(error?.status || 500), { ok: false, code: normalizeString(error?.code) || "FEATURE_CONTROL_BFF_ERROR", message: normalizeString(error?.message) || "Erro interno" }); } } export async function handleExposure(req, res, runtime) { try { const body = safeParseJson(await readBody(req)); if (!body) return writeJson(res, 400, { ok: false, code: "INVALID_JSON", message: "JSON inválido" }); const requestPayload = validateExposureRequest(body); const response = await callAtomenta("/api/feature-control/telemetry/exposure", requestPayload, runtime.config); return writeJson(res, 200, { ok: true, code: "FEATURE_CONTROL_EXPOSURE_ACCEPTED", result: response?.result ?? null }); } catch (error) { return writeJson(res, Number(error?.status || 500), { ok: false, code: normalizeString(error?.code) || "FEATURE_CONTROL_EXPOSURE_ERROR", message: normalizeString(error?.message) || "Falha ao enviar exposição" }); } }