CORE vs BASE — Separação de Responsabilidades
O Princípio
O
front-web-baseé burro por design.
O template React apenas consome dados já normalizados e renderiza UI. Toda inteligência de domínio — regras de negócio, normalização de campos da API, transforms, constantes, validadores — vive nos pacotes @cactus-agents/* do front-cactus-core.
Isso garante que:
- Qualquer novo front-end (novo fork, nova plataforma) não precisa "redescobrir" as regras
- Agentes de IA que trabalham no projeto têm uma fonte de verdade clara
- Bugs de normalização são corrigidos uma vez no SDK, refletem em todos os forks
Onde o código deve viver
| Se é... | Onde vive | Exemplo concreto |
|---|---|---|
| Shape real da API (snake_case) | @cactus-agents/<pkg>/src/raw-types.ts | RawLoginHistoryItem.date |
| Tipo público normalizado (camelCase) | @cactus-agents/<pkg>/src/types.ts | LoginHistoryItem.createdAt |
| Transform raw → público | @cactus-agents/<pkg>/src/transform.ts | transformLoginHistoryResponse() |
| Constante de domínio | @cactus-agents/<pkg>/src/ | TIMEOUT_DAYS_OPTIONS, SELF_EXCLUSION_MONTHS_OPTIONS |
| Helper de cálculo | @cactus-agents/<pkg>/src/ | isoDurationToHours(), parseLimitPeriod() |
| Validador de formato | @cactus-agents/country-config/src/validators/ | validateClabe(), validateCpf() |
| Lógica condicional por país | @cactus-agents/country-config/src/countries/ | bankAccountSection: "pix" |
| Feature flag da API | @cactus-agents/brand/src/transform/features.ts | accountSetLimits, accountTimeoutLimits |
| Proxy HTTP server-side | front-web-base/app/routes/api.*.ts | api.user.login-history.ts |
| Renderização de UI | front-web-base/app/components/**/*.tsx | DepositLimitSection.tsx |
| Lógica de rota/navegação | front-web-base/app/routes/user/ | irpf.tsx com guard de país |
Padrão Raw → Transform → Público
Todos os pacotes SDK seguem este padrão (inspirado no @cactus-agents/payments):
API (snake_case)
↓
RawXxx { campo_api: string } ← nunca exposto ao front-end
↓ transformXxx()
Xxx { campoNormalizado: string } ← tipo público, camelCase, sempre válido
Exemplo: Login History
CORE — @cactus-agents/user/src/login-history.ts:
// Raw: o que a API realmente retorna
interface RawLoginHistoryItem {
user_id: number;
city: string | null;
state: string | null;
ip: string | null;
date: string; // "2026-03-17 18:14:42" — sem T, sem timezone
}
// Público: o que o front-end consome
interface LoginHistoryItem {
userId: number;
ip: string | null;
location: string | null; // "São Paulo, SP" — composto automaticamente
createdAt: string; // "2026-03-17T18:14:42" — ISO 8601 normalizado
}
function transformLoginHistoryItem(raw: RawLoginHistoryItem): LoginHistoryItem {
const locationParts = [raw.city, raw.state].filter(Boolean);
return {
userId: raw.user_id,
ip: raw.ip,
location: locationParts.join(", ") || null,
createdAt: raw.date.replace(" ", "T"), // corrige formato sem T
};
}
BASE — app/routes/user/login-history.tsx:
// Importa tipos do SDK — zero lógica de normalização
import type { LoginHistoryItem, LoginHistoryResponse } from "@cactus-agents/user";
// formatDate é trivial: createdAt já é ISO 8601
function formatDate(isoStr: string): string {
return new Date(isoStr).toLocaleString(intlLocale, { ... });
}
Padrão de proxy server-side
As rotas api/*.ts no template são proxies simples:
// app/routes/api.user.login-history.ts
import { transformLoginHistoryResponse, type RawLoginHistoryResponse } from "@cactus-agents/user";
export async function loader({ request, context }: Route.LoaderArgs) {
const token = getTokenFromRequest(request);
const client = createClient(cf, { request, getAccessToken: () => token });
// Chama a API diretamente (sem usar o UserService, pois é server-side proxy)
const { data: raw } = await client.get<RawLoginHistoryResponse>(
`/bff/users/login-history?page=${page}`,
);
// Transform acontece no proxy — o front-end recebe dados normalizados
return Response.json({ ok: true, data: transformLoginHistoryResponse(raw) });
}
Regra: Proxies só autenticam e delegam. Se precisar de uma função auxiliar, ela deve estar no CORE.
Constantes e helpers de domínio no CORE
@cactus-agents/user/src/responsible-gaming.ts
Fonte de verdade para todas as regras de responsible gaming:
export const TIMEOUT_DAYS_OPTIONS = [1, 3, 7, 14, 30, 45] as const;
export const SELF_EXCLUSION_MONTHS_OPTIONS = [3, 6, 12, 24, 36, 60, -1] as const;
export function parseLimitPeriod(value): LimitPeriodCode
export function isoDurationToHours(raw): number | null
export function hoursToIsoDuration(hours): string
export function isPermanentExclusion(months): boolean
No BASE — apenas importa:
import { TIMEOUT_DAYS_OPTIONS, isoDurationToHours } from "@cactus-agents/user";
// Zero lógica local
const currentHours = isoDurationToHours(userInfo.user_limit_time);
Validadores no @cactus-agents/country-config
import { validateClabe, stripClabe, CLABE_LENGTH } from "@cactus-agents/country-config";
// No componente ClabeSection.tsx — sem regex local
if (!validateClabe(stripClabe(clabeValue))) {
setError(t("user:account.clabe_invalid"));
}
Todos os validadores de documento e campo bancário ficam em packages/country-config/src/validators/:
| Arquivo | Exports |
|---|---|
cpf.ts | validateCpf, formatCpf, stripCpf |
rut.ts | validateRut, formatRut, stripRut |
curp.ts | validateCurp, formatCurp, stripCurp |
clabe.ts | validateClabe, stripClabe, CLABE_LENGTH |
dni.ts | validateDni, formatDni, stripDni |
Configuração por país no @cactus-agents/country-config
Decisões condicionais por país ficam no SDK, não em if (country === "CHL") no BASE:
// braConfig: bankAccountSection: "pix", legal.hasIncomeTaxReport: true
// chlConfig: bankAccountSection: "chl-banks", legal.hasIncomeTaxReport: false
// mexConfig: bankAccountSection: "clabe", legal.hasIncomeTaxReport: false
No BASE:
const { payments, legal } = useCountry();
// Nunca: if (country === "BRA") <PixSection />
// Sempre:
{payments.bankAccountSection === "pix" && <PixSection />}
{payments.bankAccountSection === "clabe" && <ClabeSection />}
{payments.bankAccountSection === "chl-banks" && <ChlBanksSection />}
// IRPF: guard automático pelo SDK
if (!legal.hasIncomeTaxReport) return <Navigate to="/user/account" />;
Feature flags da API no @cactus-agents/brand
Campos do endpoint /bff/features são transformados em BrandFeatures pelo SDK:
// Disponível via useBrand()
interface BrandFeatures {
accountSetLimits: boolean; // account_set_limits_active
accountTimeoutLimits: boolean; // account_timeout_limits_active
socialAuth: { facebook, google, steam, twitch: boolean };
userMigration: boolean;
// ...
}
No BASE:
const { features } = useBrand();
{features.accountSetLimits && <DepositLimitSection />}
{features.accountTimeoutLimits && <TimeoutSection />}
{features.socialAuth.google && <GoogleLoginButton />}
Fluxo de desenvolvimento local
Quando altera um pacote do CORE e quer testar no BASE antes de publicar, não precisa buildar nem linkar nada. Se o front-cactus-core está clonado ao lado do front-web-base, o Vite / Vitest / tsc do base leem @cactus-agents/* direto do src/*.ts do core — via aliases regex-exatos gerados em runtime pelo helper app/utils/core-alias.ts.
# 1. Editar o fonte no CORE
# (Ex: front-cactus-core/packages/accounts/src/types.ts)
# 2. Rodar o dev/test/typecheck do BASE — lê o source do core automaticamente
cd front-web-base
pnpm dev # HMR sub-segundo, mudanças no core refletem no browser
pnpm typecheck # tsc vê a nova assinatura (via tsconfig.json)
pnpm test # vitest resolve pelo mesmo alias
# 3. Antes de abrir PR, valide contra o pacote publicado:
pnpm dev --registry # força registry, ignora core local
# ou: CACTUS_FORCE_REGISTRY=1 pnpm dev
Como funciona
O helper buildCoreAliases(rootDir, mode) roda em tempo de config e produz:
- Entradas de alias (regex ancorada
^@cactus-agents/<pkg>$→<path>/src/index.ts) proresolve.aliasde Vite/Vitest - Entradas de sub-entrypoint (ex:
^@cactus-agents/accounts/react$→src/react/index.ts) - Lista de
moduleNamespraoptimizeDeps.exclude
Em tsconfig.json, os paths apontam source-first com fallback pro node_modules (gerados estaticamente pelo script scripts/regen-tsconfig-paths.mjs — rode ao adicionar um package novo no core e comite o resultado). Quando o core não está clonado, TypeScript cai no fallback e resolve pelo registry — mesma config serve pra editor (Cursor/VSCode), dev local e CI.
Produção (pnpm build) ignora os aliases — deploy sempre resolve pelo registry, determinístico.
:::info Quando forçar registry
pnpm dev --registry— validar que o pacote publicado ainda funciona (sanity pré-release, repro de bug de CI)pnpm dev --local— oposto: exige source local, falha rápido se o core não estiver clonado (evita fallback silencioso pro registry) :::
:::warning Não altere package.json do base
Não aponte @cactus-agents/* para file:../... ou link:../... no package.json. O package.json no git sempre mantém as versões do registry (^0.10.0, etc.) — os aliases source-direct são puramente de tempo de resolução (Vite/tsc/vitest), não tocam node_modules.
:::
Checklist para implementar uma nova feature
Antes de escrever qualquer código, responda:
- O campo da API tem nome/formato diferente do que o front espera? → Criar
RawXxx+transformno pacote - Existe lógica de "converter X para Y" reutilizável? → Colocar em
transform.tsdo pacote - Há constante de domínio (opções, limites)? → Exportar do pacote, importar no BASE
- Há validação de formato (CLABE, CPF, etc.)? → Criar em
country-config/src/validators/ - Decisão depende do país? → Configurar em
country-config/src/countries/<code>.ts - Decisão depende de feature flag da API? → Já está em
BrandFeatures— usaruseBrand().features - O route handler
api.*.tstem lógica além de autenticar e delegar? → Extrair para o pacote
Exemplos: antes e depois
Login History
❌ Antes (lógica no BASE):
// login-history.tsx
interface LoginHistoryItem { // tipo redefinido localmente
date: string; // nome diferente da API
city: string | null;
state: string | null;
}
function formatDate(str: string) {
return new Date(str.replace(" ", "T")).toLocaleString(...); // normalização no componente
}
const location = [item.city, item.state].filter(Boolean).join(", "); // composição no JSX
✅ Depois (CORE faz o trabalho):
import type { LoginHistoryItem } from "@cactus-agents/user"; // tipo correto do SDK
function formatDate(isoStr: string) {
return new Date(isoStr).toLocaleString(intlLocale, ...); // createdAt já é ISO 8601
}
item.location // já composto pelo SDK: "São Paulo, SP"
item.createdAt // já normalizado: "2026-03-17T18:14:42"
Limit Period
❌ Antes:
// Duplicado em 3 arquivos do BASE
const PERIOD_MAP: Record<string, string> = {
daily: "1", weekly: "2", monthly: "3", yearly: "4",
};
const periodValue = PERIOD_MAP[raw] ?? "1";
✅ Depois:
import { parseLimitPeriod } from "@cactus-agents/user";
const periodValue = String(parseLimitPeriod(raw)); // handle tudo: "daily"→1, "1"→1, 2→2
Validação CLABE
❌ Antes:
// ClabeSection.tsx
if (clabe.length !== 18 || !/^\d+$/.test(clabe)) {
setError("CLABE inválida"); // regex inline no componente
}
✅ Depois:
import { validateClabe, stripClabe, CLABE_LENGTH } from "@cactus-agents/country-config";
const clabe = stripClabe(rawValue);
if (!validateClabe(clabe)) {
setError(t("user:account.clabe_invalid")); // SDK valida, componente só renderiza
}