feat(feature-control): add standalone BFF with bootstrap, exposure telemetry and tests

This commit is contained in:
Daniel Arantes Loverde
2026-04-16 14:46:38 -03:00
parent 2bc70c7d2a
commit f8d2fb8eb7
5 changed files with 741 additions and 0 deletions

View File

@@ -0,0 +1,333 @@
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"
});
}
}