Skip to content

Admin & Ops Console ​

pocket agent ships with an internal operator console at /admin — a self-contained dashboard for support and incident response. It is where an operator reads the state of the system (users, agents, cards, transactions, money ops, the funnel) and takes a small, carefully-chosen set of safe, audited actions: freeze a card, decide KYC, suspend an account, deny a pending approval, sweep the treasury to an allowlisted destination, and — the big red button — pause all withdrawals.

The design principle throughout: every write action reuses an existing protected path and appends an immutable audit row. An operator can stop money and reverse-safe controls, but can never invent a new, unaudited way to move funds.

The settings surface users see; operators get their own console at /admin

Enabling the console ​

The console is fail-closed. It is mounted only when ADMIN_PASSWORD is set; with it unset, the entire /admin surface returns 404, and a second in-app guard refuses every request even if the route is reached (defense-in-depth).

bash
ADMIN_PASSWORD=<a strong secret>   # mounts /admin (adminConfigured)
# A real signing secret is then MANDATORY — it HMACs the admin session cookie:
REAP_AUTH_SECRET=…                 # or REAP_WEBHOOK_SECRET

Enabling the admin panel makes a real signing secret mandatory at boot: the server refuses to start with a default secret whenever the admin panel is on, because that same AUTH_SECRET signs operator session cookies — a default would let anyone mint a valid session. See Infrastructure & Feature Flags for the boot guard.

Authentication model ​

Operator auth is completely decoupled from the consumer Privy auth. An operator signs in with the shared ADMIN_PASSWORD and receives a short-lived, signed, httpOnly session cookie.

The security properties, exactly as implemented (src/admin/auth.ts, src/admin/router.ts):

PropertyHow
No stored passwordThe password is never persisted; the compare is constant-time (both sides hashed to a fixed 32 bytes so length never leaks via early return).
Signed sessionA padm_session cookie carries {name, exp} HMAC-signed with the server AUTH_SECRET; tampering or expiry → 401.
Cookie hardeninghttpOnly, SameSite=Strict, Secure in production, scoped to /admin, 12h TTL.
Brute-force protectionLogin is rate-limited to 8 attempts / 10 minutes per IP, and the IP is the rightmost X-Forwarded-For entry (appended by the trusted edge) so a client cannot spoof a fresh bucket per request.
Failed-login alertingEvery failed login fires alert("admin.login.fail", …).

What the console shows (read endpoints) ​

All read endpoints live under /admin/api/* behind the operator session guard. The dashboard is a single-page shell (ui.ts) that branches on session state.

EndpointShows
GET /overviewThe home dashboard — total/new/active users (real cohorts, not updated_at), KYC breakdown, agent and card counts, total card balance, frozen cards, task statuses, pending approvals, a 30-day transaction grid by kind × status, pending fund/redeem ops, suspended-user count, and the withdrawals-paused flag.
GET /growthThe funnel + CAC + gross-margin screen — reads the events spine and the cost_events COGS ledger (defaults to 30 days, caps at 90).
GET /treasuryTreasury address(es), live on-chain balances, the total card-balance liability it backs, "free to withdraw" liquidity above that liability, and the configured payout allowlist.
GET /users, GET /users/:idUser search/list and a per-user detail (profile, KYC, agents, suspend state).
GET /agentsAgent search/list.
GET /transactionsThe unified ledger, filterable by kind, status, and user.
GET /cardsCards, optionally only frozen.
GET /approvalsAll approvals, filterable by status.
GET /tasksAutonomous agent tasks, filterable by status.
GET /opsUnresolved money ops — fund_ops not yet credited/failed/expired, and redeem_ops not yet done/failed/released. This is the operator's "what is stuck?" view.
GET /auditThe recent immutable admin audit log.
GET /sessionThe current operator's display name.

"Free to withdraw" is a solvency guardrail

The treasury view computes freeUsd = max(0, totalUsd − liabilityUsd), where the liability is the sum of all card balances the treasury backs. Sweeping more than freeUsd would under-collateralize user cards — the number is there so an operator can see, before a payout, exactly how much is actually free.

Actions (every one audited) ​

The console deliberately exposes a minimal set of write actions. Each reuses a path that already exists for users or the money engine, and each appends an admin_audit row (operator, action, target, detail, ip) before returning.

ActionEndpointWhat it doesReuses
Freeze / unfreeze cardPOST /cards/:agentId/freezeFlips the ledger frozen flag and best-effort tells Reap. Reversible.The user-facing /cards/:cardId/freeze path
Decide KYCPOST /users/:id/kycSets kyc_status to one of NONE / IN_REVIEW / APPROVED / REJECTED.store.upsertUser (same write as the KYC endpoint)
Suspend / unsuspendPOST /users/:id/suspendSets kv admin:suspend:<id> (what the money path checks) and mirrors users.data.suspended for visibility. A suspended user cannot move money or fund cards.The kv flag operatorBlock reads
Deny approvalPOST /approvals/:id/denyFlips a pending approval to denied. Operators can deny but never approve on a user's behalf.The approvals table
Treasury payoutPOST /treasury/payoutSweeps treasury funds to an env-allowlisted destination only. Most dangerous — serialized, kill-switch-aware, alerted.treasuryPayout → the audited sendWithdrawal(owner:'treasury') path
Kill-switchPOST /flags/kill-withdrawalsSets/clears kv admin:kill_withdrawals to pause/resume all withdrawals without a redeploy.The kv flag operatorBlock reads
Funnel backfillPOST /growth/backfillSeeds the funnel from historical activity. Analytics-only — writes to events with ON CONFLICT DO NOTHING, never touches funds.—

Operators can stop a spend, never authorize one

The console intentionally has no "approve on behalf of a user" action. Approving a payment is the user's consent, and that consent cannot be delegated to an operator. An operator can only deny a proposed spend.

The kill-switch, in depth ​

The withdrawals kill-switch is the primary incident-response control. It sets a single kv key, admin:kill_withdrawals, which the money engine reads on every money operation. Flipping it pauses all withdrawals instantly, with no redeploy.

Coverage. operatorBlock(userId) runs inside withMoneyGuard, the wrapper around every money operation — card funding, p2p transfer, swap, and card redeem — and is also called directly in executeWithdraw (the single protected wallet-send path). So one kv flip pauses card funding, transfers, swaps, redeems, and wallet withdrawals alike. The same wrapper then enforces the per-tx and daily velocity caps (WITHDRAW_MAX_USD and friends) before the send runs.

This closes a gap called out in earlier audits

Earlier the kill-switch and suspend flag only covered executeWithdraw (wallet withdrawals). Today they are checked in withMoneyGuard, so they cover card redeem, transfer, and funding too — alongside the velocity caps, which are enforced on every money-out path and are on by default even while ENABLE_WITHDRAWALS is off. The kill-switch sits on top of that env flag: it is a live, no-redeploy emergency stop.

Fail-open on read error. operatorBlock deliberately fails open if the kv read itself errors — a database hiccup must never block all money movement by accident. The hard money gate (ENABLE_WITHDRAWALS) and the velocity caps remain in force regardless, so failing open on the kv read does not open a hole.

Treasury payout, in depth ​

The treasury payout is the single most dangerous action in the whole product — it signs a real on-chain send of pooled funds. It is defended in depth so that even a fully compromised admin session cannot drain funds to an attacker.

The layered defenses:

  • Destination is not caller-chosen. The operator supplies only {chain, asset, amount}. The destination is resolved from the env allowlist (TREASURY_PAYOUT_ADDRESS / TREASURY_PAYOUT_SOL) only. A stolen session cannot sweep to an arbitrary wallet. If no allowlist entry exists, the action is disabled.
  • Serialized + kill-switch-aware — it runs under a Redis lock and respects the withdrawals kill-switch.
  • Live-balance check — it verifies on-chain liquidity before sending.
  • Same audited send path — it reuses sendWithdrawal(owner:'treasury'), the exact path every other money send uses, so all the no-phantom-credit and confirm-before-success guarantees apply.
  • Audited + alerted — a treasury.payout audit row is written and an alert fires, every time.

There is also a boot-time guard: if TREASURY_EVM/TREASURY_SOL is an address-only override (which cannot sign) and ENABLE_WITHDRAWALS is on, the server refuses to start — because every redeem/payout would otherwise fail.

KYC queue, freeze & suspend ​

These are the day-to-day support controls.

  • KYC decision writes through store.upsertUser exactly like the user-facing KYC endpoint, so the operator decision and the automated flow converge on one code path. Valid states: NONE, IN_REVIEW, APPROVED, REJECTED.
  • Freeze mirrors the user's own freeze: it flips the ledger frozen flag and best-effort calls Reap freezeCard/unfreezeCard. A frozen card declines authorizations and blocks redeem.
  • Suspend sets admin:suspend:<id>=1 — the exact kv flag operatorBlock checks — so a suspended user is blocked from every money path, and mirrors users.data.suspended so the state is visible in the user detail view.

Audit log ​

Every operator action — including login — appends an immutable row to admin_audit:

ColumnContent
operatorThe display name from the signed session
actione.g. treasury.payout, flags.withdrawals.pause, kyc.decide, user.suspend, card.freeze, approval.deny, login
targetThe affected user/agent/approval id (or the payout destination)
detailA JSON blob (amounts, tx hash, status, etc.)
ipThe trusted rightmost X-Forwarded-For entry

The audit table is append-only; the console offers no delete. The most sensitive actions (treasury payout, kill-switch) also fire a real-time alert() to ALERT_WEBHOOK_URL, so a payout or an emergency pause pages a human in addition to being recorded.

Security posture, summarized ​

In short: the console is off unless deliberately enabled, protected by a hardened login, limited to a minimal set of reversible-or-allowlisted actions, and fully audited. For the flags that govern the money engine underneath it, see Infrastructure & Feature Flags.

be everywhere — live here.