feat(feature-control): add standalone BFF with bootstrap, exposure telemetry and tests
This commit is contained in:
333
feature-control-bff/core.mjs
Normal file
333
feature-control-bff/core.mjs
Normal 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"
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user