@cactus-agents/accounts
@cactus-agents/accounts@2.9.0 — autenticação, conta do usuário e carteira em um único pacote. É o pacote mais consumido do SDK pelo front-web-base.
Este pacote substituiu @cactus-agents/auth, @cactus-agents/user e @cactus-agents/wallet, que foram removidos do monorepo (core 452450e, 2026-04-17; unificação em eac3812). Os três não existem mais no registry — pnpm add @cactus-agents/auth falha.
Dois entry points
| Import | Conteúdo | Depende de React? |
|---|---|---|
@cactus-agents/accounts | Services HTTP (auth, user, wallet, recovery), transforms e regras puras | Não |
@cactus-agents/accounts/react | Store Zustand useAccountsStore, hooks e initAccounts() | Sim |
Esta página cobre o root. A camada React tem página própria: accounts/react.
Peer deps (ambas opcionais no package.json, exigidas só pelo subpath /react): react >= 18, zustand >= 4.
Instalação
pnpm add @cactus-agents/accounts
No front-web-base já é dependência declarada — não precisa instalar.
Migração dos pacotes antigos
Todo símbolo que vivia em auth/user/wallet continua exportado com o mesmo nome; só o pacote de origem mudou. Basta reescrever o import.
| Antes | Agora |
|---|---|
import { createAuthFromClient } from "@cactus-agents/auth" | import { createAuthFromClient } from "@cactus-agents/accounts" |
import { createUserFromClient } from "@cactus-agents/user" | import { createUserFromClient } from "@cactus-agents/accounts" |
import { createWalletFromClient, fetchWalletData } from "@cactus-agents/wallet" | import { createWalletFromClient, fetchWalletData } from "@cactus-agents/accounts" |
Símbolos que a doc antiga atribuía a auth/user/wallet e hoje vivem aqui:
| Símbolo | Antigo pacote | Arquivo atual |
|---|---|---|
AuthService, AuthUser, AuthUserInfo, LoginPayload, LoginResponse, ForgotPasswordPayload, ValidateDocumentPayload/Response, RefreshTokenResponse, ForceRequestKycReason | auth | src/types/auth.ts |
shouldRefreshToken, markTokenSaved, markRefreshStarted, markRefreshCompleted, initialRefreshState, TokenRefreshState | auth | src/refresh.ts |
UserService, UserFetcher, UserLimits, UpdateLimitsPayload, SelfExclusionPayload, TimeoutLimitsPayload, IncomeReport* | user | src/types/user.ts |
LoginHistoryItem, LoginHistoryResponse, transformLoginHistoryItem, transformLoginHistoryResponse | user | src/login-history.ts |
TIMEOUT_DAYS_OPTIONS, SELF_EXCLUSION_MONTHS_OPTIONS, LIMIT_PERIOD, parseLimitPeriod, isoDurationToHours, hoursToIsoDuration, isPermanentExclusion, isLimitChangeBlocked, getLimitChangeUnblocksAt, filterSelfExclusionOptions, filterTimeoutOptions, getMinSelfExclusionMonths, getMinTimeoutDays | user | src/responsible-gaming.ts |
getAccountRestriction, getRestrictionType, isAccountRestricted, SPA_EXCLUSION_CODES, AccountRestriction | user | src/account-restriction.ts |
WalletService, WalletFetcher, Wallet, RealWallet, BonusWallet, CashbackWallet, Rollover, Transaction*, WalletData | wallet | src/types/wallet.ts |
fetchWalletData, aggregateWalletData, transformWalletResponse, transformTransaction, transformTransactionResponse, transformRollover | wallet | src/wallet-service.ts, src/wallet-transform.ts |
Uso básico
Cada domínio tem um par createXFromClient(client) (recomendado, aceita ApiClient) e createXService(fetcher) (fetcher manual).
import { createApiClient } from "@cactus-agents/api-client";
import {
createAuthFromClient,
createUserFromClient,
createWalletFromClient,
createRecoveryFromClient,
fetchWalletData,
} from "@cactus-agents/accounts";
const client = createApiClient({ baseUrl, tenant, language });
const auth = createAuthFromClient(client);
const user = createUserFromClient(client);
const wallet = createWalletFromClient(client);
const recovery = createRecoveryFromClient(client);
const session = await auth.login({ login: "user@email.com", password: "senha123" });
const walletData = await fetchWalletData(wallet);
Fetcher manual — note que UserFetcher exige patch e delete além de get/post (os outros três exigem só get/post):
import { createUserService } from "@cactus-agents/accounts";
const user = createUserService({
get: (path) => myHttpClient.get(path),
post: (path, body) => myHttpClient.post(path, body),
patch: (path, body) => myHttpClient.patch(path, body),
delete: (path) => myHttpClient.delete(path),
});
AuthService
| Método | Verbo | Endpoint |
|---|---|---|
login(payload) | POST | /auth/login |
logout() | POST | /auth/logout |
logoutAuto() | POST | /auth/logout-auto |
getUserProfile(options?) | GET | /auth/user-profile |
refreshToken() | POST | /users/refresh-token |
registerSimplified(payload) | POST | /bff/register-simplified |
forgotPassword(payload) | POST | /auth/passwords/reset/options |
validateDocument(payload) | POST | /documents/validate |
:::warning logoutAuto() existe, mas não está ligado em ponta nenhuma
A intenção de logoutAuto() é o logout forçado (401/440 vindos do BFF): bater em /auth/logout-auto para o BFF distinguir, no audit log, um sign-out iniciado pelo usuário de uma sessão terminada pelo servidor. O comportamento no front seria idêntico ao de logout().
Na prática, hoje, nada chama esse método — em três camadas:
- O método é real e funciona (
src/auth-service.ts, com teste emsrc/__tests__/auth-service.test.ts). Só é alcançável via oAuthServicedo root. - A camada React tem um adapter
defaultLogoutAuto()(src/react/auth-http.ts) apontando para/api/auth/logout-auto, mas ele não é exportado do barrel/reacte não tem nenhum call site — é código morto. AccountsConfigsó tem o seamlogout?— não existelogoutAuto?. Ou seja, nem dá para ligar o fluxo automático viainitAccounts().
E no front-web-base não existe rota /api/auth/logout-auto: o caminho de 401/440 termina em POST /api/auth/logout, que chama o logout() comum. Consequência prática: o audit log do BFF não distingue logout forçado de voluntário hoje.
Usar isso de verdade exige criar a rota de proxy no base e chamar logoutAuto() de lá (ou abrir um seam novo no core). Não escreva código assumindo que o caminho já existe.
:::
:::caution forgotPassword() é @deprecated
Use RecoveryService.getOptions(). O endpoint é o mesmo, mas a API espera document (não login) — e o RecoveryService cobre o fluxo multi-step completo.
:::
login(payload)
interface LoginPayload {
login: string; // email ou documento
password: string;
captcha_token?: string;
app_source?: string;
two_factor_code?: number;
/** Token de device-fingerprint da Incognia, gerado client-side antes do submit.
* Só brands que integram Incognia enviam. */
icg_token?: string;
}
interface LoginResponse {
access_token: string;
expires_in: number;
token_type: string;
user: AuthUser;
userInfo: AuthUserInfo;
type?: "cancelled_account" | "two_factor_code";
}
getUserProfile(options?)
interface GetUserProfileOptions {
/** Anexa `?check_spa_again=1` — dispara consulta SIGAP síncrona no BFF.
* Só em ação explícita do usuário, NUNCA em fetch de rotina. Default: false. */
checkSpaAgain?: boolean;
/** Anexa `?icg_token=...` para correlacionar o fetch com o fingerprint do login. */
icgToken?: string;
}
Retorna { user: AuthUser, userInfo: AuthUserInfo }.
registerSimplified(payload)
interface RegisterSimplifiedPayload {
email: string;
password: string;
country: string;
nationalities: string; // JSON string
optIn: boolean;
ddi?: string;
phone?: string;
name?: string;
birth_date?: string;
number?: string; // documento
captcha_token?: string;
app_source?: string;
affiliation_code?: string;
}
Retorna LoginResponse.
:::note Campos de tracking de marketing
UTMs, src e ga_client_id não fazem parte de RegisterSimplifiedPayload no tipo do core — o front-web-base os anexa ao body no próprio fluxo de registro, a partir do cookie de tracking. Política de captura e atribuição em marketing-tracking.
:::
AuthUser / AuthUserInfo
Shapes completos em packages/accounts/src/types/auth.ts. Pontos que costumam pegar quem lê só o nome do campo:
AuthUser.cactus_id?: string— identificador de plataforma (string), distinto doidnumérico. Usado por integrações 3rd-party que precisam de id estável. Opcional — contas legadas podem não ter.AuthUser._cached?: AuthUserCached— blocos agregados pelo BFF no/auth/user-profile, cada um atrás de flag/try-catch, todos opcionais. Sóget-last-deposité tipado; os demais ficam comounknown.AuthUserInfo.preferencies?: UserPreferences— sim,preferencies. É o contrato do BFF; não "corrija" o typo.AuthUserInfocarrega os limites de responsible gaming (user_limit_*,user_pause*,user_exclusion*), as exclusões read-only de operador/SPA (operator_exclusion*,spa_exclusion*) e os limites prudenciaisspa_limit_deposit/spa_limit_bet/spa_limit_loss.
RecoveryService
Fluxo multi-step de recuperação de senha.
| Método | Verbo | Endpoint |
|---|---|---|
getOptions(payload) | POST | /auth/passwords/reset/options |
sendEmail(payload) | POST | /auth/passwords/reset/by-email |
sendSms(payload) | POST | /auth/passwords/reset/by-sms |
validateCode(payload) | POST | /auth/passwords/reset/validate-code |
confirmReset(payload) | POST | /auth/passwords/reset/confirm |
interface RecoveryOptionsPayload {
/** Exatamente UM de document/email/phone/recovery_token. O front garante a
* exclusividade mútua — o BFF rejeita payload ambíguo. */
document?: string;
email?: string;
phone?: string; // só dígitos, sem DDI
recovery_token?: string;
captcha_token?: string;
}
interface RecoveryOptionsResponse {
token: string; // token temporário usado em todo o fluxo
email: string; // mascarado: "p*****@email.com"
final_phone: string; // últimos dígitos: "7766"
kyc?: boolean;
active_options?: RecoveryActiveOptions;
}
:::caution active_options não tem default permissivo
RecoveryActiveOptions (feature_email_password_recovery, feature_phone_password_recovery, feature_kyc_password_recovery) vem da config de backend da brand. Se o BFF omitir o objeto, o fluxo não renderiza nenhuma opção — o front não deve assumir "tudo liberado".
:::
UserService
Perfil e endereço
| Método | Verbo | Endpoint |
|---|---|---|
updateProfile(id, data) | POST | /users/update/{id} |
getAddress() | GET | /bff/users/address-by-user |
updateEmail(data) | PATCH | /bff/users/self-email |
addPhone(data) | PATCH | /bff/users/add-phone |
updateAddress(data) | PATCH | /bff/users/update-address |
lookupByPostalCode(postalCode, userId?) | POST | /apicep |
storeDocument(data) | POST | /documents/{endpoint} |
updateContracts(type) | PATCH | /bff/users/self-contracts |
updateMarketing(data) | PATCH | /bff/users/self-mkt |
updateUserPreferences(data) | PATCH | /bff/users/update-user-info-metadata |
updateAddress espera address (rua + número + complemento), não street:
interface UpdateAddressPayload {
address: string;
city: string;
state: string;
zipcode: string;
country?: string;
ddi?: string;
captcha_token?: string;
password_check?: string;
kyc_id?: number;
}
storeDocument monta o path a partir do próprio payload — o campo endpoint é retirado do body antes do POST:
interface StoreDocumentPayload {
number: string; // raw, sem formatação
captcha_token: string;
endpoint: "store-user-document" | "store-mex-document";
}
"store-user-document"→ CPF (BRA)"store-mex-document"→ os demais documentos (RUT, CURP, DNI, genérico)
O valor certo vem de DocumentConfig.storeEndpoint no country-config.
updateContracts(type) envia { type } com ContractType = "tc" | "privacy" | "lgpd" | "law" | "migrate" | "mkt".
updateMarketing(data) recebe { mkt_accepted_at: string | null } — datetime "YYYY-MM-DD HH:MM:SS" ao aceitar, null ao revogar.
Preferências do usuário — ALLOWED_PREFERENCES
PATCH /bff/users/update-user-info-metadata é um endpoint campo livre: o BFF persiste qualquer JSON sob preferencies. O whitelist do que pode ser gravado vive no core, em src/types/user.ts:
export const LANDING_PAGE_OPTIONS = ["home", "casino", "casino_live", "sports", "sports_live"] as const;
/** Allow-list estrito: chave de preferência → conjunto de valores aceitos. */
export const ALLOWED_PREFERENCES = {
landingPage: LANDING_PAGE_OPTIONS,
} as const;
interface UserPreferences {
landingPage?: LandingPagePreference;
}
interface UpdateUserPreferencesPayload {
preferencies: UserPreferences; // nome do campo é contrato do BFF (sic)
}
:::caution O base nunca conhece o path nem a lista
Essa é a regra fixada no CLAUDE.md do workspace: para endpoints "campo livre" do BFF, o whitelist é do core. Adicionar uma preferência nova exige entrada em ALLOWED_PREFERENCES + campo em UserPreferences + bump do core. O front-web-base não deve replicar a lista nem chamar o path do BFF direto.
:::
Segurança
| Método | Verbo | Endpoint |
|---|---|---|
changePassword(id, data) | POST | /users/change-password/{id} |
checkPassword(data) | POST | /bff/users/check-password |
toggleTwoFactor(data) | PATCH | /bff/users/self-two-factor |
getSocialAccounts() | GET | /bff/users/account-social |
connectSocial(type) | POST | /bff/users/account-social/connect/{type} |
disconnectSocial(id) | DELETE | /bff/users/account-social/{id} |
getLoginHistory(page?) | GET | /bff/users/login-history?page={page} |
interface ChangePasswordPayload {
current_password: string;
new_password: string;
new_password_confirmation: string;
captcha_token?: string;
kyc_id?: number;
/** Token emitido por /bff/users/check-password quando o módulo
* `password` do fluxo `updateData` está ativo. */
password_check?: string;
}
checkPassword retorna { success: boolean }.
getLoginHistory já devolve dados normalizados (via transformLoginHistoryResponse) — o template nunca vê o shape raw:
interface LoginHistoryItem {
userId: number;
ip: string | null;
location: string | null; // "São Paulo, SP" — city + state compostos
createdAt: string; // ISO 8601: "2026-03-17T18:14:42"
latitude: string | null;
longitude: string | null;
}
interface LoginHistoryResponse {
data: LoginHistoryItem[];
currentPage: number;
lastPage: number;
perPage: number;
total: number;
}
Histórico e carteira (via UserService)
| Método | Verbo | Endpoint |
|---|---|---|
getLastCasinoGames() | GET | /bff/games/user-last-casino-games-dl |
getWallet() | GET | /users/wallet |
getRollover() | GET | /bonus/rollover |
checkRolloverAccomplished() | GET | /bonus/rollover-accomplished |
getLastCasinoGames() mora sob /bff/games/ por razões históricas de roteamento do BFF, mas é estado por usuário (exige auth). Retorna LastCasinoGame[] já transformado ({ gameId, slug, name, image }, slug no formato "provider/game").
Responsible gaming
| Método | Verbo | Endpoint |
|---|---|---|
updateLimits(data) | PATCH | /bff/users/update-limits |
setTimeoutLimits(data) | PATCH | /bff/users/timeout-limits |
setSelfExclusion(data) | PATCH | /bff/users/self-exclusion |
interface UpdateLimitsPayload {
user_limit_deposit?: number;
user_limit_deposit_active?: number;
user_limit_deposit_period?: string;
user_limit_bet?: number;
user_limit_bet_active?: number;
user_limit_bet_period?: string;
user_limit_loss?: number;
user_limit_loss_active?: number;
user_limit_loss_period?: string;
user_limit_time?: number;
user_limit_time_active?: number;
user_limit_time_period?: string;
kyc_id?: number;
password_check?: string;
}
interface TimeoutLimitsPayload { user_pause: number; reason: number; kyc_id?: number; password_check?: string }
interface SelfExclusionPayload { user_exclusion: number; reason: number; kyc_id?: number; password_check?: string }
Income report (IRPF)
| Método | Verbo | Endpoint |
|---|---|---|
generateIncomeReport(data) | POST | /income-report/generate |
getIncomeReportStatus(id) | GET | /income-report/{id} |
getAvailableYears() | GET | /income-report/available-years |
interface IncomeReportData {
id: number;
reference_year: number;
status: 1 | 2 | 3 | 4;
delivery_type: "email" | "download";
requested_at: string;
generated_at: string | null;
path: string | null;
download_url: string | null;
}
getIncomeReportStatus é usado em polling até o status mudar.
WalletService
| Método | Verbo | Endpoint |
|---|---|---|
getWallet() | GET | /users/wallet |
getTransactions(filter?) | POST | /bff/transactions |
getCashbackTransactions(page?, datePeriod?) | GET | /transactions/cashback |
getRollover() | GET | /bonus/rollover |
checkRolloverAccomplished() | GET | /bonus/rollover-accomplished |
transferBonus() | POST | /bonus/transfer |
transferCashback() | POST | /cashback/transfer |
getWithdrawReceipt(id) | GET | /withdraw/{id}/generate |
fetchWalletData(service) — otimização de rollover
Use sempre fetchWalletData em vez de orquestrar as chamadas à mão:
- busca
getWallet()primeiro; - checa se a carteira de bônus tem saldo (
bonus_wallet_result.credit > 0); - checa expiração (
expiry_datetimeno futuro); - só chama
getRollover()+checkRolloverAccomplished()quando o bônus está ativo — e cada uma emtry/catchindividual, com default em caso de erro; - se o bônus está vazio/expirado, devolve defaults via
aggregateWalletData.
Isso evita os 400 que o backend devolve para rollover indefinido e corta requests inúteis.
import { createWalletFromClient, fetchWalletData } from "@cactus-agents/accounts";
const data = await fetchWalletData(createWalletFromClient(client));
// data.wallet.real.credit
// data.rollover.accomplished
WalletData é { wallet: Wallet; rollover: Rollover }, com Wallet = { real, bonus, cashback }.
Notas de transform
transformWalletResponseconverte os valores da carteira de centavos para unidade monetária (/ 100).transformTransactionResponseconverte a paginação para camelCase e preserva o payload original em_raw(client-side, para dev tooling).transformRollovernormalizafirst_transaction→firstTransactione aplicaaccomplished.normalizeTransactionStatuscobre também as variantes de falha v3 (failed,integration_failure,provider_rejected), distintas dedenied(rejeição manual).TransactionFilterTypeaceita"full"como "sem filtro" explícito. Valores fora da união fazem o BFF devolver a lista não filtrada silenciosamente.
Display de transação
buildDisplay(raw, hint?) devolve uma projeção pronta para renderizar, mantendo o componente burro:
interface TransactionDisplay {
titleKey: string; // "wallet.title.casino_bet"
titleParams?: Record<string, string>; // { game: "Fortune Tiger" }
details: TransactionDetail[]; // [{ labelKey, value }]
}
Helpers relacionados: buildTitle, buildDetails, deriveCategory (deriva a TransactionCategory semântica a partir de raw.src + direção) e parseProviderAndGame (extrai [PROVIDER] [GAME] de player_desc).
Regras puras (sem HTTP)
Responsible gaming
const TIMEOUT_DAYS_OPTIONS = [1, 3, 7, 14, 30, 45] as const;
const SELF_EXCLUSION_MONTHS_OPTIONS = [3, 6, 12, 24, 36, 60, -1] as const; // -1 = permanente
const LIMIT_PERIOD = { DAILY: 1, WEEKLY: 2, MONTHLY: 3, YEARLY: 4 } as const;
| Helper | Comportamento |
|---|---|
parseLimitPeriod(value) | "daily"/1 → 1, "weekly" → 2, … Fallback DAILY para valor desconhecido |
isoDurationToHours(raw) | "PT4H" → 4, 4 → 4, inválido → null |
hoursToIsoDuration(hours) | 4 → "PT4H" |
isPermanentExclusion(months) | true para -1 ou >= 99 |
isLimitChangeBlocked(changeAt) | true quando o limite mudou nas últimas 24h (cooldown) |
getLimitChangeUnblocksAt(changeAt) | ISO de quando o cooldown expira, ou null |
getMinSelfExclusionMonths(input) / getMinTimeoutDays(input) | Mínimo selecionável a partir do estado atual (*_min da API, fallback no valor vigente) |
filterSelfExclusionOptions(input) / filterTimeoutOptions(input) | Filtram as constantes para períodos >= mínimo — o usuário pode estender, nunca reduzir |
RestrictionMinInput é um Pick de AuthUserInfo (user_exclusion, user_exclusion_min, user_pause, user_pause_min), então dá para passar o userInfo inteiro.
import {
filterSelfExclusionOptions,
filterTimeoutOptions,
isoDurationToHours,
parseLimitPeriod,
isPermanentExclusion,
} from "@cactus-agents/accounts";
const currentHours = isoDurationToHours(userInfo.user_limit_time);
const periodCode = parseLimitPeriod(userInfo.user_limit_deposit_period);
const isPermanent = isPermanentExclusion(userInfo.user_exclusion ?? 0);
const exclusionOptions = filterSelfExclusionOptions(userInfo);
const timeoutOptions = filterTimeoutOptions(userInfo);
Account restriction
Detecção centralizada de conta restrita (modo "só saques").
isAccountRestricted(userInfo); // true quando qualquer restrição está ativa
getRestrictionType(userInfo); // "spa_exclusion" | "operator_exclusion" | "self_exclusion" | "timeout" | null
const r = getAccountRestriction(userInfo);
// r.type, r.isPermanent, r.endsAt, r.activatedAt, r.note, r.spaCode, r.months, r.days
Prioridade (paridade com o legado): SPA > Operator > Self-exclusion > Timeout.
const SPA_EXCLUSION_CODES = {
PERMANENT: 99, // exclusão permanente pelo regulador
SOCIAL_BENEFIT: 102, // beneficiário social (ex: IN 22/24 no Brasil)
CENTRALIZED: 103, // autoexclusão centralizada via plataforma governamental
} as const;
Token refresh
Funções puras para o ciclo de refresh (intervalo de 6h):
import {
initialRefreshState,
markRefreshCompleted,
markRefreshStarted,
markTokenSaved,
shouldRefreshToken,
} from "@cactus-agents/accounts";
let state = markTokenSaved();
if (shouldRefreshToken(state)) {
state = markRefreshStarted(state);
try {
await authService.refreshToken();
state = markRefreshCompleted(state, true);
} catch {
state = markRefreshCompleted(state, false);
}
}
:::note Não confundir com o refresh-on-401 do api-client
Este módulo é o refresh proativo por tempo. O retry automático em resposta a 401 vive no api-client (enableRefreshRetry / refreshTokenFn).
:::
Último depósito
getLastDepositAmount(user); // number | null
Lê o bloco _cached["get-last-deposit"] do user-profile. O BFF envia amount em centavos; a conversão para unidades da moeda acontece dentro do helper — callers sempre recebem unidades. Retorna null quando o bloco não existe (flag do tenant desligada), quando success é false, quando não há depósito ou quando o amount não é numérico positivo.
Tipos
O barrel src/index.ts exporta a superfície completa; os shapes vivem em:
| Arquivo | Conteúdo |
|---|---|
src/types/auth.ts | AuthUser, AuthUserInfo, payloads de login/registro/recovery, AuthService, RecoveryService |
src/types/user.ts | Payloads de perfil/segurança/limites, UserService, UserLimits, income report, ALLOWED_PREFERENCES |
src/types/wallet.ts | Wallet e derivados, Rollover, Transaction*, WalletService |
src/types/coins.ts | CoinId, CoinDef, CoinsConfig (camada dual-currency — ver accounts/react) |