diff --git a/.env.example b/.env.example index 1f516a86f..eee9b8486 100644 --- a/.env.example +++ b/.env.example @@ -527,6 +527,10 @@ VERCEL_PROJECT_ID=prj_your_project_id_here # Neon API Key (required for user database provisioning) NEON_API_KEY=your_neon_api_key_here +# Optional: provision isolated Neon branches on this shared parent project +# instead of creating one project per app/agent. +# NEON_PARENT_PROJECT_ID=proj_your_parent_project_id_here + # ============================================================================ # FIELD ENCRYPTION [REQUIRED for sensitive data protection] # ============================================================================ @@ -739,3 +743,11 @@ ELIZA_APP_WHATSAPP_PHONE_NUMBER= # Create a dedicated org + user in the platform for this purpose. # WAIFU_SERVICE_ORG_ID=uuid-of-waifu-service-org # WAIFU_SERVICE_USER_ID=uuid-of-waifu-service-user + +# ── Steward Wallet Provider (Phase 1) ──────────────────────────────────── +# Set USE_STEWARD_FOR_NEW_WALLETS=true to route new agent wallets through Steward +# instead of Privy. Existing wallets are unaffected. +# USE_STEWARD_FOR_NEW_WALLETS=false +# STEWARD_API_URL=http://localhost:3200 +# STEWARD_TENANT_API_KEY=stw_your_key_here +# STEWARD_TENANT_ID=milady-cloud diff --git a/.github/workflows/deploy-backend.yml b/.github/workflows/deploy-backend.yml index b8f0f389c..3856121a3 100644 --- a/.github/workflows/deploy-backend.yml +++ b/.github/workflows/deploy-backend.yml @@ -177,6 +177,9 @@ jobs: # Restart the Next.js service sudo systemctl restart eliza-cloud + # Restart the provisioning worker (VPS owns container lifecycle) + sudo systemctl restart milady-provisioning-worker + echo "=== Deploy complete ===" - name: Health Check diff --git a/.gitignore b/.gitignore index df2ef8b16..12f650779 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,4 @@ storybook-static DEV_TO_PROD_AUDIT.md TRIAGE_NOTES.md .env.preview +.env.local.bak-* diff --git a/CLAUDE.md b/CLAUDE.md index f14296242..eb62d11f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ npx drizzle-kit generate --custom --name=descriptive_name **`bun run check-types` has many pre-existing errors across the codebase (db/, lib/services/, app/).** Don't try to fix them all — only verify your changed files have no new errors. Filter output: ```bash -bun run check-types 2>&1 | grep -E "(your-file\.ts|your-other-file\.ts)" +bun run check-types 2>&1 | grep -E "(your-file\\.ts|your-other-file\\.ts)" ``` If the grep returns empty, your changes are clean. `bun run build` also fails on unrelated env vars (`ELIZA_APP_DISCORD_BOT_TOKEN`). Use `check-types` filtered to your files instead. @@ -63,4 +63,3 @@ db/ components/ # React components scripts/ # CLI utilities ``` - diff --git a/app/api/compat/agents/[id]/route.ts b/app/api/compat/agents/[id]/route.ts index cf9df9d8e..788fae298 100644 --- a/app/api/compat/agents/[id]/route.ts +++ b/app/api/compat/agents/[id]/route.ts @@ -12,6 +12,7 @@ import { } from "@/lib/api/compat-envelope"; import { reusesExistingMiladyCharacter } from "@/lib/services/milady-agent-config"; import { miladySandboxService } from "@/lib/services/milady-sandbox"; +import { getStewardAgent } from "@/lib/services/steward-client"; import { logger } from "@/lib/utils/logger"; import { requireCompatAuth } from "../../_lib/auth"; import { handleCompatCorsOptions, withCompatCors } from "../../_lib/cors"; @@ -41,7 +42,23 @@ export async function GET(request: NextRequest, { params }: RouteParams) { ); } - return withCompatCors(NextResponse.json(envelope(toCompatAgent(agent))), CORS_METHODS); + // Resolve wallet info for Docker-backed agents + let walletInfo: { address: string | null; provider: "steward" | "privy" | null } | undefined; + if (agent.node_id) { + try { + const stewardAgent = await getStewardAgent(agentId); + if (stewardAgent?.walletAddress) { + walletInfo = { address: stewardAgent.walletAddress, provider: "steward" }; + } + } catch { + // Steward unreachable — wallet fields will be null + } + } + + return withCompatCors( + NextResponse.json(envelope(toCompatAgent(agent, walletInfo))), + CORS_METHODS, + ); } catch (err) { return handleCompatError(err, CORS_METHODS); } diff --git a/app/api/compat/agents/route.ts b/app/api/compat/agents/route.ts index 782b394a0..016ccd524 100644 --- a/app/api/compat/agents/route.ts +++ b/app/api/compat/agents/route.ts @@ -29,7 +29,10 @@ export async function GET(request: NextRequest) { try { const { user } = await requireCompatAuth(request); const agents = await miladySandboxService.listAgents(user.organization_id); - return withCompatCors(NextResponse.json(envelope(agents.map(toCompatAgent))), CORS_METHODS); + return withCompatCors( + NextResponse.json(envelope(agents.map((a) => toCompatAgent(a)))), + CORS_METHODS, + ); } catch (err) { return handleCompatError(err, CORS_METHODS); } diff --git a/app/api/cron/cleanup-stuck-provisioning/route.ts b/app/api/cron/cleanup-stuck-provisioning/route.ts new file mode 100644 index 000000000..2077602e7 --- /dev/null +++ b/app/api/cron/cleanup-stuck-provisioning/route.ts @@ -0,0 +1,166 @@ +/** + * Cleanup Stuck Provisioning Cron + * + * Detects and recovers agents that are stuck in "provisioning" status with no + * active job to drive them forward. This happens when: + * + * 1. A container crashes while the agent is running, and something (e.g. + * the Next.js sync provision path) sets status = 'provisioning' but + * never creates a jobs-table record. + * 2. A provision job is enqueued but the worker invocation dies before it + * can claim the record — in this case the job-recovery logic in + * process-provisioning-jobs will already handle it, but we add a belt- + * and-suspenders check here for the no-job case. + * + * Criteria for "stuck": + * - status = 'provisioning' + * - updated_at < NOW() - 10 minutes (well beyond any normal provision time) + * - no jobs row in ('pending', 'in_progress') whose data->>'agentId' matches + * + * Action: set status = 'error', write a descriptive error_message so the user + * can see what happened and re-provision. + * + * Schedule: every 5 minutes; see vercel.json for the exact cron entry. + * Protected by CRON_SECRET. + */ + +import { and, eq, lt, sql } from "drizzle-orm"; +import { NextRequest, NextResponse } from "next/server"; +import { dbWrite } from "@/db/client"; +import { jobs } from "@/db/schemas/jobs"; +import { miladySandboxes } from "@/db/schemas/milady-sandboxes"; +import { verifyCronSecret } from "@/lib/api/cron-auth"; +import { logger } from "@/lib/utils/logger"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const maxDuration = 60; + +/** How long an agent must be stuck before we reset it (ms). */ +const STUCK_THRESHOLD_MINUTES = 10; + +interface CleanupResult { + agentId: string; + agentName: string | null; + organizationId: string; + stuckThresholdMinutes: number; +} + +async function handleCleanupStuckProvisioning(request: NextRequest) { + try { + const authError = verifyCronSecret(request, "[Cleanup Stuck Provisioning]"); + if (authError) return authError; + + logger.info("[Cleanup Stuck Provisioning] Starting scan"); + + const cutoff = new Date(Date.now() - STUCK_THRESHOLD_MINUTES * 60 * 1000); + + /** + * Single UPDATE … RETURNING query: + * + * UPDATE milady_sandboxes + * SET status = 'error', + * error_message = '...', + * updated_at = NOW() + * WHERE status = 'provisioning' + * AND updated_at < :cutoff + * AND NOT EXISTS ( + * SELECT 1 FROM jobs + * WHERE jobs.data->>'agentId' = milady_sandboxes.id::text + * AND jobs.status IN ('pending', 'in_progress') + * ) + * RETURNING id, agent_name, organization_id, updated_at + * + * We run this inside dbWrite so it lands on the primary replica and is + * subject to the write path's connection pool. + */ + const stuckAgents = await dbWrite + .update(miladySandboxes) + .set({ + status: "error", + error_message: + "Agent was stuck in provisioning state with no active provisioning job. " + + "This usually means a container crashed before the provisioning job could be created, " + + "or the job was lost. Please try starting the agent again.", + updated_at: new Date(), + }) + .where( + and( + eq(miladySandboxes.status, "provisioning"), + lt(miladySandboxes.updated_at, cutoff), + sql`NOT EXISTS ( + SELECT 1 FROM ${jobs} + WHERE ${jobs.data}->>'agentId' = ${miladySandboxes.id}::text + AND ${jobs.status} IN ('pending', 'in_progress') + )`, + ), + ) + .returning({ + agentId: miladySandboxes.id, + agentName: miladySandboxes.agent_name, + organizationId: miladySandboxes.organization_id, + updatedAt: miladySandboxes.updated_at, + }); + + const results: CleanupResult[] = stuckAgents.map((row) => ({ + agentId: row.agentId, + agentName: row.agentName, + organizationId: row.organizationId, + // updatedAt is now the new timestamp; we can't recover the old one here, + // but the log message below captures the count. + stuckThresholdMinutes: STUCK_THRESHOLD_MINUTES, + })); + + if (results.length > 0) { + logger.warn("[Cleanup Stuck Provisioning] Reset stuck agents", { + count: results.length, + agents: results.map((r) => ({ + agentId: r.agentId, + agentName: r.agentName, + organizationId: r.organizationId, + })), + }); + } else { + logger.info("[Cleanup Stuck Provisioning] No stuck agents found"); + } + + return NextResponse.json({ + success: true, + data: { + cleaned: results.length, + thresholdMinutes: STUCK_THRESHOLD_MINUTES, + timestamp: new Date().toISOString(), + agents: results, + }, + }); + } catch (error) { + logger.error( + "[Cleanup Stuck Provisioning] Failed:", + error instanceof Error ? error.message : String(error), + ); + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "Cleanup failed", + }, + { status: 500 }, + ); + } +} + +/** + * GET /api/cron/cleanup-stuck-provisioning + * Cron endpoint — protected by CRON_SECRET (Vercel passes it automatically). + */ +export async function GET(request: NextRequest) { + return handleCleanupStuckProvisioning(request); +} + +/** + * POST /api/cron/cleanup-stuck-provisioning + * Manual trigger for testing — same auth requirement. + */ +export async function POST(request: NextRequest) { + return handleCleanupStuckProvisioning(request); +} diff --git a/app/api/stripe/create-checkout-session/route.ts b/app/api/stripe/create-checkout-session/route.ts index 0d6588c31..e0922c232 100644 --- a/app/api/stripe/create-checkout-session/route.ts +++ b/app/api/stripe/create-checkout-session/route.ts @@ -19,6 +19,8 @@ const ALLOWED_ORIGINS = [ process.env.NEXT_PUBLIC_APP_URL, "http://localhost:3000", "http://localhost:3001", + "https://milady.ai", + "https://www.milady.ai", ].filter(Boolean) as string[]; // Configurable currency diff --git a/app/api/v1/admin/docker-containers/route.ts b/app/api/v1/admin/docker-containers/route.ts index 8682056df..89505948e 100644 --- a/app/api/v1/admin/docker-containers/route.ts +++ b/app/api/v1/admin/docker-containers/route.ts @@ -14,10 +14,33 @@ import { NextRequest, NextResponse } from "next/server"; import { dbRead } from "@/db/helpers"; import { type MiladySandboxStatus, miladySandboxes } from "@/db/schemas/milady-sandboxes"; import { requireAdmin } from "@/lib/auth"; +import { getStewardAgent } from "@/lib/services/steward-client"; import { logger } from "@/lib/utils/logger"; export const dynamic = "force-dynamic"; +const STEWARD_ENRICHMENT_CONCURRENCY = 5; + +async function mapWithConcurrency( + items: T[], + limit: number, + mapper: (item: T, index: number) => Promise, +): Promise { + const results = new Array(items.length); + let nextIndex = 0; + + async function worker() { + while (nextIndex < items.length) { + const currentIndex = nextIndex++; + results[currentIndex] = await mapper(items[currentIndex], currentIndex); + } + } + + const workerCount = Math.min(limit, items.length); + await Promise.all(Array.from({ length: workerCount }, () => worker())); + return results; +} + // --------------------------------------------------------------------------- // GET — List all Docker containers across all nodes // --------------------------------------------------------------------------- @@ -97,10 +120,41 @@ export async function GET(request: NextRequest) { .orderBy(desc(miladySandboxes.created_at)) .limit(limit); + // Enrich containers with wallet info from Steward (best-effort, parallel) + const enrichedContainers = await mapWithConcurrency( + containers, + STEWARD_ENRICHMENT_CONCURRENCY, + async (c) => { + let walletAddress: string | null = null; + let walletProvider: "steward" | "privy" | null = null; + + // All Docker-node containers use Steward wallets + if (c.nodeId) { + try { + const stewardAgent = await getStewardAgent(c.id); + if (stewardAgent?.walletAddress) { + walletAddress = stewardAgent.walletAddress; + walletProvider = "steward"; + } else { + walletProvider = "steward"; // registered but wallet pending + } + } catch { + // Steward unreachable — leave as null + } + } + + return { + ...c, + walletAddress, + walletProvider, + }; + }, + ); + return NextResponse.json({ success: true, data: { - containers, + containers: enrichedContainers, total: totalCount, // actual total matching filters returned: containers.length, // number returned in this page filters: { diff --git a/app/api/v1/admin/service-pricing/route.ts b/app/api/v1/admin/service-pricing/route.ts index ce6771ce7..e8b1b7555 100644 --- a/app/api/v1/admin/service-pricing/route.ts +++ b/app/api/v1/admin/service-pricing/route.ts @@ -21,7 +21,7 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; -import { servicePricingRepository } from "@/db/repositories"; +import { servicePricingRepository } from "@/db/repositories/service-pricing"; import { requireAdminWithResponse } from "@/lib/api/admin-auth"; import { invalidateServicePricingCache } from "@/lib/services/proxy/pricing"; import { logger } from "@/lib/utils/logger"; diff --git a/app/api/v1/milady/agents/[agentId]/api/wallet/[...path]/route.ts b/app/api/v1/milady/agents/[agentId]/api/wallet/[...path]/route.ts new file mode 100644 index 000000000..42d646bea --- /dev/null +++ b/app/api/v1/milady/agents/[agentId]/api/wallet/[...path]/route.ts @@ -0,0 +1,132 @@ +import { NextRequest, NextResponse } from "next/server"; +import { errorToResponse } from "@/lib/api/errors"; +import { requireAuthOrApiKeyWithOrg } from "@/lib/auth"; +import { miladySandboxService } from "@/lib/services/milady-sandbox"; +import { applyCorsHeaders, handleCorsOptions } from "@/lib/services/proxy/cors"; +import { logger } from "@/lib/utils/logger"; + +export const dynamic = "force-dynamic"; + +const CORS_METHODS = "GET, POST, OPTIONS"; + +export function OPTIONS() { + return handleCorsOptions(CORS_METHODS); +} + +/** + * Proxy handler for both GET and POST wallet requests. + * + * Incoming URL pattern: + * /api/v1/milady/agents/[agentId]/api/wallet/[...path] + * + * Proxied to the agent at: + * {bridge_url}/api/wallet/{path} + * + * This allows the homepage dashboard (via CloudApiClient) to reach wallet + * endpoints on agents running in Docker containers, authenticated by the + * cloud API key and authorization-checked against the user's organization. + */ +async function proxyToAgent( + request: NextRequest, + params: Promise<{ agentId: string; path: string[] }>, + method: "GET" | "POST", +): Promise { + try { + const { user } = await requireAuthOrApiKeyWithOrg(request); + const { agentId, path } = await params; + + // Use only the first path segment (e.g. ["steward-policies"] → "steward-policies") + // Reject multi-segment paths to prevent path traversal + if (path.length !== 1 || path[0].includes("..")) { + return applyCorsHeaders( + NextResponse.json({ success: false, error: "Invalid wallet path" }, { status: 400 }), + CORS_METHODS, + ); + } + const walletPath = path[0]; + + // Forward validated query string (e.g. ?limit=20 for steward-tx-records) + const query = request.nextUrl.search ? request.nextUrl.search.slice(1) : undefined; + + // Read POST body if present (with size limit and content-type check) + let body: string | null = null; + if (method === "POST") { + const contentType = request.headers.get("content-type"); + if (!contentType?.includes("application/json")) { + return applyCorsHeaders( + NextResponse.json( + { success: false, error: "Content-Type must be application/json" }, + { status: 400 }, + ), + CORS_METHODS, + ); + } + body = await request.text(); + if (body.length > 1_048_576) { + return applyCorsHeaders( + NextResponse.json({ success: false, error: "Request body too large" }, { status: 413 }), + CORS_METHODS, + ); + } + } + + logger.info("[wallet-proxy] Request", { + agentId, + orgId: user.organization_id, + walletPath, + method, + }); + + const agentResponse = await miladySandboxService.proxyWalletRequest( + agentId, + user.organization_id, + walletPath, + method, + body, + query, + ); + + if (!agentResponse) { + logger.warn("[wallet-proxy] Proxy returned null", { + agentId, + orgId: user.organization_id, + walletPath, + }); + return applyCorsHeaders( + NextResponse.json( + { success: false, error: "Agent is not running or unreachable" }, + { status: 503 }, + ), + CORS_METHODS, + ); + } + + // Forward status + body from agent response directly + const responseBody = await agentResponse.text(); + const contentType = agentResponse.headers.get("content-type") ?? "application/json"; + + return applyCorsHeaders( + new Response(responseBody, { + status: agentResponse.status, + headers: { "Content-Type": contentType }, + }), + CORS_METHODS, + ); + } catch (error) { + return applyCorsHeaders(errorToResponse(error), CORS_METHODS); + } +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ agentId: string; path: string[] }> }, +) { + return proxyToAgent(request, params, "GET"); +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ agentId: string; path: string[] }> }, +) { + return proxyToAgent(request, params, "POST"); +} diff --git a/app/api/v1/milady/agents/[agentId]/pairing-token/route.ts b/app/api/v1/milady/agents/[agentId]/pairing-token/route.ts index 5d5781bfa..8410f93f2 100644 --- a/app/api/v1/milady/agents/[agentId]/pairing-token/route.ts +++ b/app/api/v1/milady/agents/[agentId]/pairing-token/route.ts @@ -59,12 +59,11 @@ export async function POST( ); } + const tokenService = getPairingTokenService(); const envVars = (sandbox.environment_vars ?? {}) as Record; - const hasUiApiToken = Boolean( + const supportsUiTokenPairing = Boolean( envVars.MILADY_API_TOKEN?.trim() || envVars.ELIZA_API_TOKEN?.trim(), ); - - const tokenService = getPairingTokenService(); const pairingToken = await tokenService.generateToken( user.id, user.organization_id, @@ -77,7 +76,7 @@ export async function POST( success: true, data: { token: pairingToken, - redirectUrl: hasUiApiToken ? `${webUiUrl}/pair?token=${pairingToken}` : webUiUrl, + redirectUrl: supportsUiTokenPairing ? `${webUiUrl}/pair?token=${pairingToken}` : webUiUrl, expiresIn: 60, }, }), diff --git a/app/api/v1/milady/agents/[agentId]/provision/route.ts b/app/api/v1/milady/agents/[agentId]/provision/route.ts index fd2cc6bf1..a0698b5ea 100644 --- a/app/api/v1/milady/agents/[agentId]/provision/route.ts +++ b/app/api/v1/milady/agents/[agentId]/provision/route.ts @@ -66,7 +66,11 @@ export async function POST( try { const { user } = await requireAuthOrApiKeyWithOrg(request); const { agentId } = await params; - const sync = request.nextUrl.searchParams.get("sync") === "true"; + const syncRequested = request.nextUrl.searchParams.get("sync") === "true"; + const sync = + syncRequested && + (process.env.NODE_ENV !== "production" || + process.env.ALLOW_MILADY_SYNC_PROVISIONING === "true"); logger.info("[milady-api] Provision requested", { agentId, @@ -122,7 +126,7 @@ export async function POST( ); } - // ── Sync fallback (legacy) ──────────────────────────────────────── + // ── Sync fallback (legacy / local-only) ─────────────────────────── if (sync) { const result = await miladySandboxService.provision(agentId, user.organization_id!); diff --git a/app/api/v1/milady/agents/[agentId]/route.ts b/app/api/v1/milady/agents/[agentId]/route.ts index 8b2913d01..642fe1c15 100644 --- a/app/api/v1/milady/agents/[agentId]/route.ts +++ b/app/api/v1/milady/agents/[agentId]/route.ts @@ -1,11 +1,15 @@ +import { eq } from "drizzle-orm"; import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; +import { db } from "@/db/client"; import { userCharactersRepository } from "@/db/repositories/characters"; +import { agentServerWallets } from "@/db/schemas/agent-server-wallets"; import { errorToResponse } from "@/lib/api/errors"; import { requireAuthOrApiKeyWithOrg } from "@/lib/auth"; import { reusesExistingMiladyCharacter } from "@/lib/services/milady-agent-config"; import { miladySandboxService } from "@/lib/services/milady-sandbox"; import { applyCorsHeaders, handleCorsOptions } from "@/lib/services/proxy/cors"; +import { getStewardAgent } from "@/lib/services/steward-client"; import { logger } from "@/lib/utils/logger"; export const dynamic = "force-dynamic"; @@ -70,6 +74,42 @@ export async function GET( tokenTicker = typeof cfg?.tokenTicker === "string" ? cfg.tokenTicker : null; } + // Resolve wallet info — Docker agents use Steward, others use Privy + let walletAddress: string | null = null; + let walletProvider: "steward" | "privy" | null = null; + let walletStatus: "active" | "pending" | "none" | "error" = "none"; + + const isDockerAgent = !!agent.node_id; + + if (isDockerAgent) { + // Steward-backed agent — query Steward for wallet address + try { + const stewardAgent = await getStewardAgent(agentId); + if (stewardAgent?.walletAddress) { + walletAddress = stewardAgent.walletAddress; + walletProvider = "steward"; + walletStatus = "active"; + } else if (stewardAgent) { + walletProvider = "steward"; + walletStatus = "pending"; + } + } catch (err) { + logger.warn(`[milady-api] Steward wallet lookup failed for ${agentId}`, { err }); + } + } + + // Fallback: check for Privy server wallet via character_id + if (!walletAddress && agent.character_id) { + const walletRecord = await db.query.agentServerWallets.findFirst({ + where: eq(agentServerWallets.character_id, agent.character_id), + }); + if (walletRecord) { + walletAddress = walletRecord.address; + walletProvider = "privy"; + walletStatus = "active"; + } + } + return applyCorsHeaders( NextResponse.json({ success: true, @@ -90,6 +130,10 @@ export async function GET( token_chain: tokenChain, token_name: tokenName, token_ticker: tokenTicker, + // Wallet info + walletAddress, + walletProvider, + walletStatus, }, }), CORS_METHODS, diff --git a/app/api/v1/milady/agents/[agentId]/wallet/route.ts b/app/api/v1/milady/agents/[agentId]/wallet/route.ts new file mode 100644 index 000000000..ba16bb4d1 --- /dev/null +++ b/app/api/v1/milady/agents/[agentId]/wallet/route.ts @@ -0,0 +1,133 @@ +/** + * GET /api/v1/milady/agents/[agentId]/wallet + * + * Returns detailed wallet information for an agent, including address, + * provider type, balance, and chain info. + * + * For steward-backed agents: queries Steward API for live wallet data. + * For privy-backed agents: returns DB-stored wallet info. + */ + +import { eq } from "drizzle-orm"; +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/db/client"; +import { agentServerWallets } from "@/db/schemas/agent-server-wallets"; +import { errorToResponse } from "@/lib/api/errors"; +import { requireAuthOrApiKeyWithOrg } from "@/lib/auth"; +import { miladySandboxService } from "@/lib/services/milady-sandbox"; +import { applyCorsHeaders, handleCorsOptions } from "@/lib/services/proxy/cors"; +import { getStewardWalletInfo } from "@/lib/services/steward-client"; +import { logger } from "@/lib/utils/logger"; + +export const dynamic = "force-dynamic"; + +const CORS_METHODS = "GET, OPTIONS"; + +export function OPTIONS() { + return handleCorsOptions(CORS_METHODS); +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ agentId: string }> }, +) { + try { + const { user } = await requireAuthOrApiKeyWithOrg(request); + const { agentId } = await params; + + // Verify the agent belongs to this user's org + const agent = await miladySandboxService.getAgent(agentId, user.organization_id); + if (!agent) { + return applyCorsHeaders( + NextResponse.json({ success: false, error: "Agent not found" }, { status: 404 }), + CORS_METHODS, + ); + } + + // Check if there's a privy server wallet linked by character_id + let privyWallet: { address: string; chain_type: string } | null = null; + if (agent.character_id) { + const walletRecord = await db.query.agentServerWallets.findFirst({ + where: eq(agentServerWallets.character_id, agent.character_id), + }); + if (walletRecord) { + privyWallet = { address: walletRecord.address, chain_type: walletRecord.chain_type }; + } + } + + // All Docker-node agents use Steward for wallet management. + // Try Steward first for any agent with a node_id (Docker-backed). + const isDockerAgent = !!agent.node_id; + + if (isDockerAgent) { + const stewardInfo = await getStewardWalletInfo(agentId); + + if (stewardInfo) { + return applyCorsHeaders( + NextResponse.json({ + success: true, + data: { + agentId, + walletAddress: stewardInfo.walletAddress, + walletProvider: "steward", + walletStatus: stewardInfo.walletStatus, + balance: stewardInfo.balance, + chain: stewardInfo.chain ?? "base", + // Include privy wallet info if it exists (legacy/dual period) + ...(privyWallet + ? { + legacyWallet: { + address: privyWallet.address, + provider: "privy", + chainType: privyWallet.chain_type, + }, + } + : {}), + }, + }), + CORS_METHODS, + ); + } + + // Steward unreachable — fall through to privy wallet if available + logger.warn(`[wallet-api] Steward unreachable for agent ${agentId}, falling back to DB`); + } + + // Privy / DB fallback + if (privyWallet) { + return applyCorsHeaders( + NextResponse.json({ + success: true, + data: { + agentId, + walletAddress: privyWallet.address, + walletProvider: "privy", + walletStatus: "active", + balance: null, // Privy doesn't expose balance via our API + chain: privyWallet.chain_type === "evm" ? "base" : privyWallet.chain_type, + }, + }), + CORS_METHODS, + ); + } + + // No wallet found + return applyCorsHeaders( + NextResponse.json({ + success: true, + data: { + agentId, + walletAddress: null, + walletProvider: null, + walletStatus: "none", + balance: null, + chain: null, + }, + }), + CORS_METHODS, + ); + } catch (error) { + logger.error("[wallet-api] GET /agents/[agentId]/wallet error", { error }); + return applyCorsHeaders(errorToResponse(error), CORS_METHODS); + } +} diff --git a/app/dashboard/billing/page.tsx b/app/dashboard/billing/page.tsx index 03e296847..2237e15c3 100644 --- a/app/dashboard/billing/page.tsx +++ b/app/dashboard/billing/page.tsx @@ -1,7 +1,5 @@ import type { Metadata } from "next"; import { requireAuth } from "@/lib/auth"; -import { creditsService } from "@/lib/services/credits"; -import { miladySandboxService } from "@/lib/services/milady-sandbox"; import { BillingPageWrapper } from "@/packages/ui/src/components/billing/billing-page-wrapper"; export const metadata: Metadata = { @@ -9,47 +7,15 @@ export const metadata: Metadata = { description: "Add funds and manage your billing", }; -// Force dynamic rendering since we use server-side auth (cookies) export const dynamic = "force-dynamic"; -/** - * Billing page for managing credits and billing information. - * Displays available credit packs and current credit balance. - * - * @param searchParams - Search parameters, including optional `canceled` flag for canceled checkout sessions. - * @returns The rendered billing page wrapper component. - */ export default async function BillingPage({ searchParams, }: { searchParams: Promise<{ canceled?: string }>; }) { const user = await requireAuth(); - const creditPacks = await creditsService.listActiveCreditPacks(); const params = await searchParams; - // Fetch agent counts for runway estimation (best-effort) - let runningAgents = 0; - let idleAgents = 0; - try { - if (user.organization_id) { - const agents = await miladySandboxService.listAgents(user.organization_id); - runningAgents = agents.filter((a) => a.status === "running").length; - idleAgents = agents.filter( - (a) => a.status === "stopped" || a.status === "disconnected", - ).length; - } - } catch { - // Table may not exist — degrade gracefully - } - - return ( - - ); + return ; } diff --git a/app/dashboard/containers/agents/[id]/page.tsx b/app/dashboard/containers/agents/[id]/page.tsx index edc58604d..8475688f7 100644 --- a/app/dashboard/containers/agents/[id]/page.tsx +++ b/app/dashboard/containers/agents/[id]/page.tsx @@ -14,7 +14,6 @@ import Link from "next/link"; import { redirect } from "next/navigation"; import { requireAuthWithOrg } from "@/lib/auth"; import { statusBadgeColor, statusDotColor } from "@/lib/constants/sandbox-status"; -import { getPreferredMiladyAgentWebUiUrl } from "@/lib/milady-web-ui"; import { adminService } from "@/lib/services/admin"; import { miladySandboxService } from "@/lib/services/milady-sandbox"; import { MiladyAgentActions } from "@/packages/ui/src/components/containers/agent-actions"; @@ -79,7 +78,6 @@ export default async function MiladyAgentDetailPage({ params }: PageProps) { const isAdmin = await adminService.isUserAdmin(user.id).catch(() => false); const isDockerBacked = !!agent.node_id; - const webUiUrl = getPreferredMiladyAgentWebUiUrl(agent); const sshCommand = agent.headscale_ip ? `ssh root@${agent.headscale_ip}` : null; const badgeColor = statusBadgeColor(agent.status); @@ -99,7 +97,7 @@ export default async function MiladyAgentDetailPage({ params }: PageProps) { Containers - {webUiUrl && agent.status === "running" && } + {agent.status === "running" && } {/* ── Agent header ── */} @@ -225,15 +223,6 @@ export default async function MiladyAgentDetailPage({ params }: PageProps) { )} - - {webUiUrl && ( -
- - Web UI - - {webUiUrl} -
- )} )} @@ -300,7 +289,7 @@ export default async function MiladyAgentDetailPage({ params }: PageProps) { )} {/* ── Actions card ── */} - + {/* ── Backups / history ── */} - {/* ── Error message ── */} - {agent.error_message && ( -
- -
-

- Error ({agent.error_count} occurrence{agent.error_count !== 1 ? "s" : ""}) -

-

{agent.error_message}

-
-
- )} - - {/* ── Docker infrastructure (admin) ── */} - {isAdmin && isDockerBacked && ( -
-
- -

- Infrastructure -

-
+ {/* ── Tabs: Overview | Wallet | Transactions | Policies ── */} + + {/* ── Overview tab content ── */} -
- - - - {agent.headscale_ip && ( - - )} - {agent.bridge_port && ( - - )} - {agent.web_ui_port && ( - - )} + {/* ── Error message ── */} + {agent.error_message && ( +
+ +
+

+ Error ({agent.error_count} occurrence{agent.error_count !== 1 ? "s" : ""}) +

+

{agent.error_message}

+
+ )} - {webUiUrl && ( -
- - Web UI - - {webUiUrl} + {/* ── Docker infrastructure (admin) ── */} + {isAdmin && isDockerBacked && ( +
+
+ +

+ Infrastructure +

- )} -
- )} - - {/* ── SSH access (admin) ── */} - {isAdmin && sshCommand && ( -
-
- -

- SSH Access -

-
-
-
- - - {sshCommand} - +
+ + + + {agent.headscale_ip && ( + + )} + {agent.bridge_port && ( + + )} + {agent.web_ui_port && ( + + )}
- {agent.bridge_port && ( + + {webUiUrl && ( +
+ + Web UI + + {webUiUrl} +
+ )} +
+ )} + + {/* ── SSH access (admin) ── */} + {isAdmin && sshCommand && ( +
+
+ +

+ SSH Access +

+
+ +
- + - {`curl http://${agent.headscale_ip}:${agent.bridge_port}/health`} + {sshCommand}
- )} -
-
- )} - - {/* ── Vercel sandbox info (admin) ── */} - {isAdmin && !isDockerBacked && agent.bridge_url && ( -
-
- -

- Sandbox Connection -

-
+ {agent.bridge_port && ( +
+ + + {`curl http://${agent.headscale_ip}:${agent.bridge_port}/health`} + +
+ )} +
+
+ )} -
- - Bridge URL - - - {agent.bridge_url} - - -
- - )} - - {/* ── Actions card ── */} - - - {/* ── Backups / history ── */} - - - {/* ── User-facing app logs ── */} - - - {/* ── Admin: Docker Logs ── */} - {isAdmin && isDockerBacked && agent.container_name && agent.node_id && ( - +
+ +

+ Sandbox Connection +

+
+ +
+ + Bridge URL + + + {agent.bridge_url} + + +
+ + )} + + {/* ── Actions card ── */} + + + {/* ── Backups / history ── */} + + + {/* ── User-facing app logs ── */} + - )} + + {/* ── Admin: Docker Logs ── */} + {isAdmin && isDockerBacked && agent.container_name && agent.node_id && ( + + )} + ); } diff --git a/app/dashboard/milady/page.tsx b/app/dashboard/milady/page.tsx index 5801abcc6..6fbe20b86 100644 --- a/app/dashboard/milady/page.tsx +++ b/app/dashboard/milady/page.tsx @@ -51,9 +51,6 @@ export default async function MiladyDashboardPage() {

Milady Instances

-

- Launch an existing agent into the web app or create a new managed instance. -

{ - const [r] = await dbRead + // Use dbWrite (primary) instead of dbRead (replica) to ensure fresh data. + // The VPS worker writes bridge_url/status to primary, and read replicas + // may lag behind, causing the wallet proxy to return "not running". + const [r] = await dbWrite .select() .from(miladySandboxes) .where( diff --git a/packages/db/schemas/agent-server-wallets.ts b/packages/db/schemas/agent-server-wallets.ts index 152616454..875ae7966 100644 --- a/packages/db/schemas/agent-server-wallets.ts +++ b/packages/db/schemas/agent-server-wallets.ts @@ -7,9 +7,12 @@ import { users } from "./users"; /** * Agent Server Wallets table schema. * - * Tracks secure server-side wallets provisioned via Privy for agents. - * The private keys reside entirely within Privy KMS. - * The client pubkey is used to verify RPC requests from the remote agent. + * Tracks secure server-side wallets provisioned for agents. + * Supports dual providers: Privy (legacy) and Steward (new). + * + * The `wallet_provider` column routes RPC calls to the correct backend. + * New wallets default to 'steward' when the feature flag is enabled; + * existing wallets remain on 'privy' until explicitly migrated. */ export const agentServerWallets = pgTable( "agent_server_wallets", @@ -25,8 +28,15 @@ export const agentServerWallets = pgTable( onDelete: "set null", }), - // The ID of the wallet in Privy - privy_wallet_id: text("privy_wallet_id").notNull(), + // Provider routing: 'privy' (legacy) or 'steward' (new) + wallet_provider: text("wallet_provider").notNull().default("privy"), + + // Privy wallet ID (nullable — only set for privy-managed wallets) + privy_wallet_id: text("privy_wallet_id"), + + // Steward references (only set for steward-managed wallets) + steward_agent_id: text("steward_agent_id"), + steward_tenant_id: text("steward_tenant_id"), // The public address of the provisioned wallet address: text("address").notNull(), @@ -47,6 +57,10 @@ export const agentServerWallets = pgTable( privy_wallet_idx: index("agent_server_wallets_privy_wallet_idx").on(table.privy_wallet_id), address_idx: index("agent_server_wallets_address_idx").on(table.address), client_address_idx: index("agent_server_wallets_client_address_idx").on(table.client_address), + steward_agent_idx: index("agent_server_wallets_steward_agent_idx").on(table.steward_agent_id), + wallet_provider_idx: index("agent_server_wallets_wallet_provider_idx").on( + table.wallet_provider, + ), }), ); diff --git a/packages/lib/api/compat-envelope.ts b/packages/lib/api/compat-envelope.ts index 259e1a61a..1dacbdceb 100644 --- a/packages/lib/api/compat-envelope.ts +++ b/packages/lib/api/compat-envelope.ts @@ -53,12 +53,24 @@ export interface CompatAgentShape { database_status: string; error_message: string | null; last_heartbeat_at: string | null; + // wallet info (Phase 1 — steward migration) + wallet_address: string | null; + wallet_provider: "steward" | "privy" | null; } /** * Translate a MiladySandbox row to the canonical Agent shape. */ -export function toCompatAgent(sandbox: MiladySandbox): CompatAgentShape { +/** + * Translate a MiladySandbox row to the canonical Agent shape. + * + * Optionally accepts pre-resolved wallet info to avoid redundant Steward calls. + * If not provided, wallet fields default to null. + */ +export function toCompatAgent( + sandbox: MiladySandbox, + walletInfo?: { address: string | null; provider: "steward" | "privy" | null }, +): CompatAgentShape { const webUiUrl = getAgentWebUiUrl(sandbox); return { @@ -78,6 +90,9 @@ export function toCompatAgent(sandbox: MiladySandbox): CompatAgentShape { database_status: sandbox.database_status, error_message: sandbox.error_message, last_heartbeat_at: sandbox.last_heartbeat_at ? toISO(sandbox.last_heartbeat_at) : null, + // Wallet info — only expose a provider when wallet info was actually resolved + wallet_address: walletInfo?.address ?? null, + wallet_provider: walletInfo?.provider ?? null, }; } diff --git a/packages/lib/config/feature-flags.ts b/packages/lib/config/feature-flags.ts index df50aea07..0bf3df158 100644 --- a/packages/lib/config/feature-flags.ts +++ b/packages/lib/config/feature-flags.ts @@ -122,3 +122,5 @@ export function getFeatureForRoute(pathname: string): FeatureFlag | null { } return null; } + +// Steward wallet migration flags live in wallet-provider-flags.ts diff --git a/packages/lib/config/wallet-provider-flags.ts b/packages/lib/config/wallet-provider-flags.ts new file mode 100644 index 000000000..25cf0dd88 --- /dev/null +++ b/packages/lib/config/wallet-provider-flags.ts @@ -0,0 +1,19 @@ +/** + * Feature flags for the Privy → Steward wallet migration. + * + * Controlled via environment variables. Defaults are conservative + * (Privy remains the default) so the rollout is opt-in. + * + * Separate from the main feature-flags.ts to avoid coupling the + * migration with the existing UI feature flag system. + */ +export const WALLET_PROVIDER_FLAGS = { + /** When true, new agent wallets are created via Steward instead of Privy. */ + USE_STEWARD_FOR_NEW_WALLETS: process.env.USE_STEWARD_FOR_NEW_WALLETS === "true", + + /** When true, the migration script is allowed to convert Privy wallets to Steward. */ + ALLOW_PRIVY_MIGRATION: process.env.ALLOW_PRIVY_MIGRATION === "true", + + /** When true, Privy wallet creation is fully disabled (Phase 3). */ + DISABLE_PRIVY_WALLETS: process.env.DISABLE_PRIVY_WALLETS === "true", +} as const; diff --git a/packages/lib/eliza/config.ts b/packages/lib/eliza/config.ts index 192295194..11ce161c6 100644 --- a/packages/lib/eliza/config.ts +++ b/packages/lib/eliza/config.ts @@ -45,8 +45,8 @@ export function getElizaCloudApiUrl(): string { */ export function getDefaultModels() { return { - small: process.env.ELIZAOS_CLOUD_SMALL_MODEL || "moonshotai/kimi-k2-0905", - large: process.env.ELIZAOS_CLOUD_LARGE_MODEL || "moonshotai/kimi-k2-0905", + small: process.env.ELIZAOS_CLOUD_SMALL_MODEL || "minimax/minimax-m2.7", + large: process.env.ELIZAOS_CLOUD_LARGE_MODEL || "anthropic/claude-sonnet-4.6", embedding: process.env.ELIZAOS_CLOUD_EMBEDDING_MODEL || "text-embedding-3-small", }; } @@ -57,8 +57,8 @@ export function getDefaultModels() { */ export const ALLOWED_CHAT_MODELS: readonly string[] = [ ...FALLBACK_TEXT_SELECTOR_MODELS.map((model) => model.modelId), - "moonshotai/kimi-k2-0905", - "moonshotai/kimi-k2-turbo", + "minimax/minimax-m2.7", + "anthropic/claude-sonnet-4.6", "deepseek/deepseek-v3.2-exp", "deepseek/deepseek-r1", ]; diff --git a/packages/lib/middleware/rate-limit.ts b/packages/lib/middleware/rate-limit.ts index 32bd48939..09a809196 100644 --- a/packages/lib/middleware/rate-limit.ts +++ b/packages/lib/middleware/rate-limit.ts @@ -43,13 +43,21 @@ function validateRateLimitConfig() { if (process.env.NODE_ENV === "production") { if (process.env.REDIS_RATE_LIMITING !== "true") { - throw new Error( - "🚨 SECURITY: Redis rate limiting is required in production. " + - "In-memory rate limiting allows bypass across serverless instances. " + - "Set REDIS_RATE_LIMITING=true and configure Redis connection.", + // ⚠️ IMPORTANT: On a single-server VPS, in-memory rate limiting is safe + // because there is only one process. Multi-instance/serverless deployments + // SHOULD configure Redis so limits are shared across instances. + // We log a warning here instead of throwing so the first API call does not + // fail with HTTP 500 (the original bug: hasValidatedConfig was set to true + // BEFORE the throw, causing the first request to 500 and subsequent ones + // to succeed — masking the misconfiguration while breaking UX). + logger.warn( + "[Rate Limit] ⚠️ In-memory rate limiting in production. " + + "For multi-instance deployments set REDIS_RATE_LIMITING=true and configure Redis. " + + "Single-server deployments are unaffected.", ); + } else { + logger.info("[Rate Limit] ✓ Using Redis-backed rate limiting (production mode)"); } - logger.info("[Rate Limit] ✓ Using Redis-backed rate limiting (production mode)"); } else { logger.info("[Rate Limit] 🔓 Development mode: Rate limits relaxed (10000 req/window)"); } diff --git a/packages/lib/models/catalog.ts b/packages/lib/models/catalog.ts index 774e95c3a..30673013f 100644 --- a/packages/lib/models/catalog.ts +++ b/packages/lib/models/catalog.ts @@ -67,7 +67,11 @@ const MISTRAL_TEXT_MODEL_IDS = [ "mistral/mistral-large-3", "mistral/ministral-8b", ] as const; -const MINIMAX_TEXT_MODEL_IDS = ["minimax/minimax-m2.5", "minimax/minimax-m2.1-lightning"] as const; +const MINIMAX_TEXT_MODEL_IDS = [ + "minimax/minimax-m2.7", + "minimax/minimax-m2.5", + "minimax/minimax-m2.1-lightning", +] as const; const QWEN_TEXT_MODEL_IDS = [ "alibaba/qwen3-max", "alibaba/qwen3.5-plus", diff --git a/packages/lib/models/model-tiers.ts b/packages/lib/models/model-tiers.ts index 5079f8eff..65d388242 100644 --- a/packages/lib/models/model-tiers.ts +++ b/packages/lib/models/model-tiers.ts @@ -54,9 +54,9 @@ function extractProvider(modelId: string): string { return "openai"; } -const FAST_MODEL_ID = getEnvModelId("fast", "openai/gpt-oss-120b"); -const PRO_MODEL_ID = getEnvModelId("pro", "anthropic/claude-opus-4.5"); -const ULTRA_MODEL_ID = getEnvModelId("ultra", "anthropic/claude-sonnet-4.5"); +const FAST_MODEL_ID = getEnvModelId("fast", "minimax/minimax-m2.7"); +const PRO_MODEL_ID = getEnvModelId("pro", "anthropic/claude-sonnet-4.6"); +const ULTRA_MODEL_ID = getEnvModelId("ultra", "anthropic/claude-opus-4.6"); export const MODEL_TIERS: Record = { fast: { @@ -67,8 +67,8 @@ export const MODEL_TIERS: Record = { provider: extractProvider(FAST_MODEL_ID), icon: "zap", pricing: { - inputPer1k: 0.0001, - outputPer1k: 0.0004, + inputPer1k: 0.0002, + outputPer1k: 0.001, currency: "USD", }, capabilities: ["text", "code", "function_calling"], @@ -82,8 +82,8 @@ export const MODEL_TIERS: Record = { provider: extractProvider(PRO_MODEL_ID), icon: "sparkles", pricing: { - inputPer1k: 0.001, - outputPer1k: 0.005, + inputPer1k: 0.003, + outputPer1k: 0.015, currency: "USD", }, capabilities: ["text", "code", "reasoning", "vision", "function_calling", "long_context"], @@ -218,13 +218,6 @@ export const DEFAULT_IMAGE_MODEL = IMAGE_MODELS[0]; export const ADDITIONAL_MODELS: AdditionalModel[] = [ // Moonshot AI - { - id: "kimi-k2", - name: "Kimi K2", - description: "Fast & capable", - modelId: "moonshotai/kimi-k2-0905", - provider: "moonshot", - }, { id: "kimi-k2-turbo", name: "Kimi K2 Turbo", @@ -250,9 +243,23 @@ export const ADDITIONAL_MODELS: AdditionalModel[] = [ // Anthropic { id: "claude-opus", - name: "Claude Opus 4.1", + name: "Claude Opus 4.6", description: "Most powerful", - modelId: "anthropic/claude-opus-4.1", + modelId: "anthropic/claude-opus-4.6", + provider: "anthropic", + }, + { + id: "claude-sonnet", + name: "Claude Sonnet 4.6", + description: "Balanced and capable", + modelId: "anthropic/claude-sonnet-4.6", + provider: "anthropic", + }, + { + id: "claude-haiku", + name: "Claude Haiku 4.5", + description: "Fast Anthropic option", + modelId: "anthropic/claude-haiku-4.5", provider: "anthropic", }, // Google @@ -317,10 +324,17 @@ export const ADDITIONAL_MODELS: AdditionalModel[] = [ provider: "alibaba", }, // Minimax + { + id: "minimax-m2.7", + name: "Minimax M2.7", + description: "Fast & affordable default", + modelId: "minimax/minimax-m2.7", + provider: "minimax", + }, { id: "minimax-m2.5", name: "Minimax M2.5", - description: "Chinese frontier model", + description: "Previous-generation Minimax", modelId: "minimax/minimax-m2.5", provider: "minimax", }, @@ -365,8 +379,8 @@ export const ADDITIONAL_MODELS: AdditionalModel[] = [ export const BUILD_MODE_TIERS: Record = { fast: { ...MODEL_TIERS.fast, - modelId: "moonshotai/kimi-k2-0905", - provider: "moonshotai", + modelId: "minimax/minimax-m2.7", + provider: "minimax", description: "Fast responses for build mode", }, pro: { @@ -396,7 +410,7 @@ export const DEFAULT_MODEL_TIER: ModelTier = "pro"; * logger.info(config.modelId); // "google/gemini-2.5-flash-lite" * * // Using raw model ID (returns matching tier or creates custom config) - * const config = resolveModel("anthropic/claude-sonnet-4.5"); + * const config = resolveModel("anthropic/claude-sonnet-4.6"); */ export function resolveModel(tierOrModelId?: string | null): ModelTierConfig { if (!tierOrModelId) { diff --git a/packages/lib/pricing.ts b/packages/lib/pricing.ts index 11543c121..846ca0f06 100644 --- a/packages/lib/pricing.ts +++ b/packages/lib/pricing.ts @@ -104,11 +104,23 @@ function getFallbackPricing( "gpt-4o-mini": { input: 0.00015, output: 0.0006 }, "gpt-4-turbo": { input: 0.01, output: 0.03 }, "gpt-3.5-turbo": { input: 0.0005, output: 0.0015 }, + "gpt-5": { input: 0.005, output: 0.02 }, + "gpt-5-mini": { input: 0.0003, output: 0.0012 }, + "claude-sonnet-4.6": { input: 0.003, output: 0.015 }, + "claude-sonnet-4.5": { input: 0.003, output: 0.015 }, + "claude-opus-4.6": { input: 0.015, output: 0.075 }, + "claude-opus-4.5": { input: 0.015, output: 0.075 }, + "claude-haiku-4.5": { input: 0.0008, output: 0.004 }, "claude-3-5-sonnet-20241022": { input: 0.003, output: 0.015 }, "claude-3-5-haiku-20241022": { input: 0.001, output: 0.005 }, + "gemini-2.5-flash": { input: 0.00015, output: 0.001 }, + "gemini-3-pro": { input: 0.00125, output: 0.01 }, + "deepseek-v3.2": { input: 0.00027, output: 0.0011 }, + "minimax-m2.7": { input: 0.0002, output: 0.001 }, + "minimax-m2.5": { input: 0.0002, output: 0.001 }, }; - const pricing = pricingMap[model] || { input: 0.0025, output: 0.01 }; + const pricing = pricingMap[model] || { input: 0.001, output: 0.005 }; // Calculate base costs in cents const baseInputCostCents = Math.ceil((inputTokens / 1000) * pricing.input * 100); diff --git a/packages/lib/privy-sync.ts b/packages/lib/privy-sync.ts index 744b45091..83e334919 100644 --- a/packages/lib/privy-sync.ts +++ b/packages/lib/privy-sync.ts @@ -7,7 +7,8 @@ * 2. Just-in-time sync (fallback for race conditions) */ -import { organizationInvitesRepository, usersRepository } from "@/db/repositories"; +import { organizationInvitesRepository } from "@/db/repositories/organization-invites"; +import { usersRepository } from "@/db/repositories/users"; import { abuseDetectionService, type SignupContext } from "@/lib/services/abuse-detection"; import { apiKeysService } from "@/lib/services/api-keys"; import { charactersService } from "@/lib/services/characters/characters"; diff --git a/packages/lib/security/redirect-validation.ts b/packages/lib/security/redirect-validation.ts index 1f34f93d3..42ba529ce 100644 --- a/packages/lib/security/redirect-validation.ts +++ b/packages/lib/security/redirect-validation.ts @@ -5,6 +5,8 @@ const DEFAULT_PLATFORM_REDIRECT_ORIGINS = [ "http://localhost:3001", "http://127.0.0.1:3000", "http://127.0.0.1:3001", + "https://milady.ai", + "https://www.milady.ai", ]; function isHttpUrl(url: URL): boolean { diff --git a/packages/lib/services/docker-sandbox-provider.ts b/packages/lib/services/docker-sandbox-provider.ts index 6de4eb931..80568a5c4 100644 --- a/packages/lib/services/docker-sandbox-provider.ts +++ b/packages/lib/services/docker-sandbox-provider.ts @@ -80,6 +80,7 @@ const DOCKER_NETWORK = process.env.MILADY_DOCKER_NETWORK || "milady-isolated"; const STEWARD_HOST_URL = process.env.STEWARD_API_URL || "http://localhost:3200"; const STEWARD_TENANT_API_KEY = process.env.STEWARD_TENANT_API_KEY || ""; const STEWARD_TENANT_ID = process.env.STEWARD_TENANT_ID || "milady-cloud"; +let hasWarnedMissingStewardTenantApiKey = false; // URL injected into container env vars. Containers on the bridge network (milady-isolated) // cannot reach the host via localhost. On Linux we pair host.docker.internal with an @@ -102,7 +103,7 @@ const DEFAULT_SSH_USERNAME = process.env.MILADY_SSH_USER || "root"; const HEALTH_CHECK_POLL_INTERVAL_MS = 3_000; /** Health-check polling: total timeout (ms). */ -const HEALTH_CHECK_TIMEOUT_MS = 60_000; +const HEALTH_CHECK_TIMEOUT_MS = 180_000; /** SSH command timeout for docker pull (can be slow on first pull). */ const PULL_TIMEOUT_MS = 300_000; // 5 min @@ -194,11 +195,24 @@ function extractStewardToken(raw: string): string { return trimmed; } +function warnMissingStewardTenantApiKey() { + if (STEWARD_TENANT_API_KEY || hasWarnedMissingStewardTenantApiKey) { + return; + } + + hasWarnedMissingStewardTenantApiKey = true; + logger.warn( + "[docker-sandbox] STEWARD_TENANT_API_KEY is not set; Steward registration will run without tenant API key auth", + ); +} + async function registerAgentWithSteward( ssh: DockerSSHClient, agentId: string, agentName: string, ): Promise { + warnMissingStewardTenantApiKey(); + const script = `python3 - <<'PY' import json import sys @@ -422,11 +436,9 @@ export class DockerSandboxProvider implements SandboxProvider { ELIZA_CLOUD_PROVISIONED: "1", STEWARD_API_URL: STEWARD_CONTAINER_URL, STEWARD_AGENT_ID: agentId, - // steward-enabled image runs two processes: - // milady.mjs (UI) on MILADY_PORT (default 2138) - // cloud-agent on PORT (default 2139) - // Do NOT set PORT=2138 here — it would collide with MILADY_PORT - // and the API service would steal the UI port. + // The current cloud agent image listens on PORT (default 2139). + // Keep MILADY_PORT for compatibility, but publish/probe the external + // host ports against PORT so new containers don't expose a dead 2138. MILADY_PORT: DEFAULT_MILADY_PORT, PORT: DEFAULT_AGENT_PORT, BRIDGE_PORT: DEFAULT_BRIDGE_PORT, @@ -465,15 +477,19 @@ export class DockerSandboxProvider implements SandboxProvider { STEWARD_AGENT_TOKEN: stewardAgentToken, // Bind to 0.0.0.0 so Docker port mapping works (container otherwise // listens on 127.0.0.1 which is unreachable via -p host:container). + // Set BOTH MILADY_API_BIND and ELIZA_API_BIND — the image default for + // MILADY_API_BIND is 127.0.0.1 (loopback-only) which would make the + // bridge port unreachable from outside the container. + MILADY_API_BIND: "0.0.0.0", ELIZA_API_BIND: "0.0.0.0", - // Prevent the server from auto-generating an API token when bound to - // 0.0.0.0. Without this, the compat server locks all routes behind auth. + // Prevent the server from auto-generating a RANDOM API token when bound + // to 0.0.0.0. The DB-provisioned MILADY_API_TOKEN (set in baseEnv by + // managed-milady-env.ts) is the canonical inbound auth token — the pair + // flow hands it to the browser so the web UI can authenticate. Clearing + // it here caused isAuthorized() to reject every request on cloud- + // provisioned containers (no token + cloud flag = 401). MILADY_DISABLE_AUTO_API_TOKEN: "1", ELIZA_DISABLE_AUTO_API_TOKEN: "1", - // Clear compat API tokens that leak in from managed launch environment. - // A non-empty token triggers auth on all routes, blocking the web UI. - MILADY_API_TOKEN: "", - ELIZA_API_TOKEN: "", }; // Validate env keys/values before they are interpolated into remote shell commands. @@ -496,15 +512,18 @@ export class DockerSandboxProvider implements SandboxProvider { ...(requiresDockerHostGateway(STEWARD_CONTAINER_URL) ? ["--add-host host.docker.internal:host-gateway"] : []), - `--health-cmd ${shellQuote(getDockerHealthCmd(allEnv.MILADY_PORT || DEFAULT_MILADY_PORT))}`, + `--health-cmd ${shellQuote(getDockerHealthCmd(allEnv.PORT || DEFAULT_AGENT_PORT))}`, "--health-interval 10s", "--health-timeout 5s", "--health-start-period 15s", "--health-retries 6", ...(headscaleEnabled ? ["--cap-add=NET_ADMIN", "--device /dev/net/tun"] : []), `-v ${shellQuote(volumePath)}:/app/data`, - `-p ${bridgePort}:${DEFAULT_BRIDGE_PORT}`, - `-p ${webUiPort}:${allEnv.MILADY_PORT || DEFAULT_MILADY_PORT}`, + // The cloud image serves both API and web UI from PORT (default 2139). + // Publish both externally allocated host ports to that live listener so + // nginx can reach /api/* via bridge_url and the UI via web_ui_port. + `-p ${bridgePort}:${allEnv.PORT || DEFAULT_AGENT_PORT}`, + `-p ${webUiPort}:${allEnv.PORT || DEFAULT_AGENT_PORT}`, envFlags, shellQuote(resolvedImage), ].join(" "); diff --git a/packages/lib/services/eliza-app/config.ts b/packages/lib/services/eliza-app/config.ts index 12dd78d29..2131ac132 100644 --- a/packages/lib/services/eliza-app/config.ts +++ b/packages/lib/services/eliza-app/config.ts @@ -46,8 +46,8 @@ export const elizaAppConfig = { // Model preferences for webhook channels (Telegram, iMessage) modelPreferences: { - smallModel: "anthropic/claude-sonnet-4.5", - largeModel: "anthropic/claude-sonnet-4.5", + smallModel: "minimax/minimax-m2.7", + largeModel: "anthropic/claude-sonnet-4.6", }, // Prompt preset for eliza-app channels (engaging, conversation-continuing behavior) diff --git a/packages/lib/services/managed-milady-env.ts b/packages/lib/services/managed-milady-env.ts index ab40cb5a1..314a738e4 100644 --- a/packages/lib/services/managed-milady-env.ts +++ b/packages/lib/services/managed-milady-env.ts @@ -1,5 +1,6 @@ import crypto from "node:crypto"; import { apiKeysService } from "./api-keys"; +import { resolveStewardContainerUrl } from "./docker-sandbox-utils"; const DEFAULT_MILADY_APP_URL = "https://app.milady.ai"; const DEFAULT_CLOUD_PUBLIC_URL = "https://www.elizacloud.ai"; @@ -126,6 +127,8 @@ export async function prepareManagedMiladyEnvironment(params: { existingEnv?: Record | null; organizationId: string; userId: string; + /** Sandbox/agent ID — used as STEWARD_AGENT_ID for Docker-backed agents. */ + sandboxId?: string; }): Promise { const existingEnv = { ...(params.existingEnv ?? {}) }; const userApiKey = await getOrCreateUserApiKey(params.userId, params.organizationId); @@ -143,6 +146,22 @@ export async function prepareManagedMiladyEnvironment(params: { ELIZAOS_CLOUD_BASE_URL: resolveCloudPublicUrl(), }; + // Steward env vars — Docker-backed agents need these to talk to the wallet vault. + // STEWARD_API_URL is resolved for container reachability (host.docker.internal + // or the explicit override). STEWARD_AGENT_ID maps to the sandbox ID. + // STEWARD_AGENT_TOKEN is set during provisioning in docker-sandbox-provider.ts. + const stewardContainerUrl = resolveStewardContainerUrl( + process.env.STEWARD_API_URL || "http://localhost:3200", + process.env.STEWARD_CONTAINER_URL, + ); + + if (!existingEnv.STEWARD_API_URL) { + environmentVars.STEWARD_API_URL = stewardContainerUrl; + } + if (params.sandboxId && !existingEnv.STEWARD_AGENT_ID) { + environmentVars.STEWARD_AGENT_ID = params.sandboxId; + } + const changed = JSON.stringify(existingEnv) !== JSON.stringify(environmentVars); return { diff --git a/packages/lib/services/milady-managed-launch.ts b/packages/lib/services/milady-managed-launch.ts index 63ff59c2e..637960a65 100644 --- a/packages/lib/services/milady-managed-launch.ts +++ b/packages/lib/services/milady-managed-launch.ts @@ -8,8 +8,8 @@ import { miladySandboxService } from "./milady-sandbox"; const DEFAULT_MILADY_APP_URL = "https://app.milady.ai"; const DEFAULT_CLOUD_PUBLIC_URL = "https://www.elizacloud.ai"; -const DEFAULT_SMALL_MODEL = "moonshotai/kimi-k2-turbo"; -const DEFAULT_LARGE_MODEL = "moonshotai/kimi-k2-0905"; +const DEFAULT_SMALL_MODEL = "minimax/minimax-m2.7"; +const DEFAULT_LARGE_MODEL = "anthropic/claude-sonnet-4.6"; const LAUNCH_SESSION_TTL_SECONDS = 300; const DEV_MILADY_APP_ORIGINS = [ "http://localhost:5173", diff --git a/packages/lib/services/milady-sandbox.ts b/packages/lib/services/milady-sandbox.ts index c1574f233..36896e479 100644 --- a/packages/lib/services/milady-sandbox.ts +++ b/packages/lib/services/milady-sandbox.ts @@ -32,6 +32,9 @@ import { getNeonClient, NeonClientError } from "./neon-client"; import { JOB_TYPES } from "./provisioning-jobs"; import { createSandboxProvider, type SandboxProvider } from "./sandbox-provider"; +/** Shared Neon project used as branch parent for per-agent databases. */ +const NEON_PARENT_PROJECT_ID: string = process.env.NEON_PARENT_PROJECT_ID ?? ""; + export interface CreateAgentParams { organizationId: string; userId: string; @@ -79,16 +82,28 @@ export interface SnapshotResult { const MAX_BACKUPS = 10; type LifecycleTx = Parameters[0]>[0]; +const MAX_NEON_RESOURCE_NAME_LENGTH = 63; +const MILADY_NEON_RESOURCE_PREFIX = "milady-"; -function sanitizeProjectNameSegment(value: string): string { - const sanitized = value +function sanitizeNeonResourceName(value: string, fallback: string): string { + const normalized = value .toLowerCase() .replace(/[^a-z0-9-]+/g, "-") - .replace(/-+/g, "-") - .replace(/^-|-$/g, "") - .slice(0, 20); + .replace(/^-+|-+$/g, "") + .replace(/-{2,}/g, "-"); + + return normalized || fallback; +} + +function buildMiladyNeonResourceName(agentName: string, agentId: string): string { + const prefix = sanitizeNeonResourceName(agentName, "agent"); + const suffix = agentId.substring(0, 8).toLowerCase(); + const maxPrefixLength = + MAX_NEON_RESOURCE_NAME_LENGTH - MILADY_NEON_RESOURCE_PREFIX.length - suffix.length - 1; + const trimmedPrefix = + prefix.slice(0, Math.max(1, maxPrefixLength)).replace(/-+$/g, "") || "agent"; - return sanitized || "agent"; + return `${MILADY_NEON_RESOURCE_PREFIX}${trimmedPrefix}-${suffix}`; } export class MiladySandboxService { @@ -206,10 +221,11 @@ export class MiladySandboxService { } if (rec.neon_project_id) { try { - await this.cleanupNeon(rec.neon_project_id); + await this.cleanupNeon(rec.neon_project_id, rec.neon_branch_id); } catch (e) { logger.warn("[milady-sandbox] Neon cleanup failed during delete", { projectId: rec.neon_project_id, + branchId: rec.neon_branch_id, error: e instanceof Error ? e.message : String(e), }); return { @@ -279,6 +295,7 @@ export class MiladySandboxService { existingEnv: (rec.environment_vars as Record) ?? {}, organizationId: rec.organization_id, userId: rec.user_id, + sandboxId: agentId, }); if (managedEnvironment.changed) { @@ -603,6 +620,144 @@ export class MiladySandboxService { } } + /** + * Proxy an HTTP request to the agent's wallet API endpoint. + * Used by the cloud backend to forward wallet/steward requests from the dashboard. + * + * @param agentId - The sandbox record ID + * @param orgId - The organization ID (authorization) + * @param walletPath - Path after `/api/wallet/`, e.g. "steward-policies" + * @param method - HTTP method ("GET" | "POST") + * @param body - Optional request body (for POST requests) + * @param query - Optional query string (e.g. "limit=20") + * @returns The raw fetch Response, or null if the sandbox is not running + */ + // Allowed wallet sub-paths for proxy (prevents path traversal) + private static readonly ALLOWED_WALLET_PATHS = new Set([ + "addresses", + "balances", + "steward-status", + "steward-policies", + "steward-tx-records", + "steward-pending-approvals", + "steward-approve-tx", + "steward-deny-tx", + ]); + + // Allowed query parameters for wallet proxy + private static readonly ALLOWED_QUERY_PARAMS = new Set([ + "limit", + "offset", + "cursor", + "type", + "status", + ]); + + async proxyWalletRequest( + agentId: string, + orgId: string, + walletPath: string, + method: "GET" | "POST", + body?: string | null, + query?: string, + ): Promise { + // Validate wallet path against whitelist (prevents path traversal) + if (!MiladySandboxService.ALLOWED_WALLET_PATHS.has(walletPath)) { + logger.warn("[milady-sandbox] Rejected wallet proxy: invalid path", { + agentId, + walletPath, + }); + return new Response(JSON.stringify({ error: "Invalid wallet endpoint" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + // Sanitize query parameters + let sanitizedQuery = ""; + if (query) { + const params = new URLSearchParams(query); + const filtered = new URLSearchParams(); + for (const [key, value] of params) { + if (MiladySandboxService.ALLOWED_QUERY_PARAMS.has(key)) { + filtered.set(key, value); + } + } + sanitizedQuery = filtered.toString(); + } + + const rec = await miladySandboxesRepository.findRunningSandbox(agentId, orgId); + if (!rec) { + logger.warn("[milady-sandbox] Wallet proxy: sandbox not found or not running", { + agentId, + orgId, + walletPath, + }); + return null; + } + if (!rec.bridge_url) { + logger.warn("[milady-sandbox] Wallet proxy: no bridge_url", { + agentId, + status: rec.status, + walletPath, + }); + return null; + } + + try { + const fullPath = `/api/wallet/${walletPath}${sanitizedQuery ? `?${sanitizedQuery}` : ""}`; + + // Extract API token from environment_vars + const envVars = rec.environment_vars as Record | null; + const apiToken = envVars?.MILADY_API_TOKEN; + if (!apiToken) { + logger.warn("[milady-sandbox] No MILADY_API_TOKEN for wallet proxy", { agentId }); + } + + // Determine the agent endpoint. Prefer the public domain (reachable from + // Vercel serverless functions) over internal bridge IPs (only reachable + // from within the Hetzner network). + const agentBaseDomain = process.env.ELIZA_CLOUD_AGENT_BASE_DOMAIN; + let endpoint: string; + if (agentBaseDomain) { + // Public URL: https://{agentId}.waifu.fun/api/wallet/... + endpoint = `https://${agentId}.${agentBaseDomain}${fullPath}`; + } else if (rec.web_ui_port && rec.node_id) { + // Internal fallback: http://{host}:{web_ui_port}/api/wallet/... + const bridgeUrl = new URL(rec.bridge_url); + endpoint = `${bridgeUrl.protocol}//${bridgeUrl.hostname}:${rec.web_ui_port}${fullPath}`; + } else { + endpoint = await this.getSafeBridgeEndpoint(rec, fullPath); + } + + logger.info("[milady-sandbox] Wallet proxy endpoint", { + agentId, + endpoint: endpoint.replace(/Bearer.*/, "***"), + }); + + const headers: Record = { "Content-Type": "application/json" }; + if (apiToken) { + headers.Authorization = `Bearer ${apiToken}`; + } + const fetchOptions: RequestInit = { + method, + headers, + signal: AbortSignal.timeout(30_000), + }; + if (method === "POST" && body != null) { + fetchOptions.body = body; + } + return await fetch(endpoint, fetchOptions); + } catch (error) { + logger.warn("[milady-sandbox] Wallet proxy request failed", { + agentId, + walletPath, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + } + async bridgeStream(agentId: string, orgId: string, rpc: BridgeRequest): Promise { const rec = await miladySandboxesRepository.findRunningSandbox(agentId, orgId); if (!rec?.bridge_url) return null; @@ -940,9 +1095,15 @@ export class MiladySandboxService { await miladySandboxesRepository.update(rec.id, { database_status: "provisioning", }); + const neon = getNeonClient(); - const name = `milady-${sanitizeProjectNameSegment(rec.agent_name ?? "agent")}-${rec.id.substring(0, 8)}`; - const result = await neon.createProject({ name, region: "aws-us-east-1" }); + const resourceName = buildMiladyNeonResourceName(rec.agent_name ?? "agent", rec.id); + const result = NEON_PARENT_PROJECT_ID + ? await neon.createBranch(NEON_PARENT_PROJECT_ID, resourceName) + : await neon.createProject({ + name: resourceName, + region: "aws-us-east-1", + }); const updated = await miladySandboxesRepository.update(rec.id, { neon_project_id: result.projectId, @@ -955,13 +1116,27 @@ export class MiladySandboxService { if (!updated) { logger.error("[milady-sandbox] DB update failed after Neon creation, cleaning orphan", { projectId: result.projectId, + branchId: result.branchId, }); - await neon.deleteProject(result.projectId).catch((e) => { - logger.error("[milady-sandbox] Orphan Neon project cleanup failed", { - projectId: result.projectId, - error: e instanceof Error ? e.message : String(e), + + if (result.projectId === NEON_PARENT_PROJECT_ID && result.branchId) { + await neon.deleteBranch(result.projectId, result.branchId).catch((error) => { + logger.error("[milady-sandbox] Orphan Neon branch cleanup failed", { + projectId: result.projectId, + branchId: result.branchId, + error: error instanceof Error ? error.message : String(error), + }); }); - }); + } else { + await neon.deleteProject(result.projectId).catch((error) => { + logger.error("[milady-sandbox] Orphan Neon project cleanup failed", { + projectId: result.projectId, + branchId: result.branchId, + error: error instanceof Error ? error.message : String(error), + }); + }); + } + return { success: false, error: "Failed to persist database credentials", @@ -971,13 +1146,24 @@ export class MiladySandboxService { return { success: true, connectionUri: result.connectionUri }; } - private async cleanupNeon(projectId: string) { + private async cleanupNeon(projectId: string | null | undefined, branchId?: string | null) { + // In shared-DB mode no per-agent Neon project exists; nothing to clean up. + if (!projectId) return; + + const neon = getNeonClient(); try { - await getNeonClient().deleteProject(projectId); + if (projectId === NEON_PARENT_PROJECT_ID && branchId) { + // Branch-based: delete the branch, not the shared project + await neon.deleteBranch(NEON_PARENT_PROJECT_ID, branchId); + } else if (projectId !== NEON_PARENT_PROJECT_ID) { + // Legacy project-based: delete the entire project + await neon.deleteProject(projectId); + } } catch (error) { if (error instanceof NeonClientError && error.statusCode === 404) { - logger.info("[milady-sandbox] Neon project already absent during cleanup", { + logger.info("[milady-sandbox] Neon resource already absent during cleanup", { projectId, + branchId, }); return; } diff --git a/packages/lib/services/neon-client.ts b/packages/lib/services/neon-client.ts index c15917cdc..ead837f9e 100644 --- a/packages/lib/services/neon-client.ts +++ b/packages/lib/services/neon-client.ts @@ -145,6 +145,81 @@ export class NeonClient { return result; } + /** + * Create a new branch within an existing Neon project. + * Each branch is an isolated copy-on-write fork with its own connection URI. + * + * @param projectId Parent project ID to branch from + * @param branchName Human-readable branch name + * @returns Branch details including connection URI + * @throws NeonClientError on API failure + */ + async createBranch(projectId: string, branchName: string): Promise { + logger.info("Creating Neon branch", { projectId, branchName }); + + const response = await this.fetchWithRetry(`/projects/${projectId}/branches`, { + method: "POST", + body: JSON.stringify({ + branch: { name: branchName }, + endpoints: [{ type: "read_write" }], + }), + }); + + const data = await response.json(); + const branch = data.branch; + const connectionUri = data.connection_uris?.[0]?.connection_uri; + + if (!connectionUri) { + throw new NeonClientError( + "No connection URI in Neon branch response", + "MISSING_CONNECTION_URI", + ); + } + + let host: string; + try { + const uriWithoutProtocol = connectionUri.replace("postgres://", ""); + const afterAt = uriWithoutProtocol.split("@")[1]; + host = afterAt.split("/")[0]; + } catch { + host = "unknown"; + } + + const result: NeonProjectResult = { + projectId, + branchId: branch.id, + connectionUri, + host, + database: "neondb", + region: "aws-us-east-1", + }; + + logger.info("Neon branch created", { + projectId, + branchId: result.branchId, + host: result.host, + }); + + return result; + } + + /** + * Delete a branch from a Neon project. + * + * @param projectId Parent project ID + * @param branchId Branch ID to delete + * @throws NeonClientError on API failure + */ + async deleteBranch(projectId: string, branchId: string): Promise { + logger.info("Deleting Neon branch", { projectId, branchId }); + + await this.fetchWithRetry(`/projects/${projectId}/branches/${branchId}`, { + method: "DELETE", + }); + + logger.info("Neon branch deleted", { projectId, branchId }); + } + /** * Delete a Neon project and all its data. * diff --git a/packages/lib/services/server-wallets.ts b/packages/lib/services/server-wallets.ts index 7fe171be0..3ffba4701 100644 --- a/packages/lib/services/server-wallets.ts +++ b/packages/lib/services/server-wallets.ts @@ -1,12 +1,19 @@ import type { WalletApiWalletResponseType } from "@privy-io/server-auth"; +import { StewardApiError } from "@stwd/sdk"; import { eq } from "drizzle-orm"; import { verifyMessage } from "viem"; import { db } from "@/db/client"; -import { agentServerWallets } from "@/db/schemas/agent-server-wallets"; +import { type AgentServerWallet, agentServerWallets } from "@/db/schemas/agent-server-wallets"; import { getPrivyClient } from "@/lib/auth/privy-client"; import { cache } from "@/lib/cache/client"; +import { WALLET_PROVIDER_FLAGS } from "@/lib/config/wallet-provider-flags"; +import { getStewardClient } from "@/lib/services/steward-client"; import { logger } from "@/lib/utils/logger"; +// --------------------------------------------------------------------------- +// Error classes +// --------------------------------------------------------------------------- + class WalletAlreadyExistsError extends Error { constructor() { super("Wallet already exists for this client address"); @@ -14,6 +21,13 @@ class WalletAlreadyExistsError extends Error { } } +class PrivyWalletsDisabledError extends Error { + constructor() { + super("Privy wallet creation is disabled; enable Steward for new wallets first"); + this.name = "PrivyWalletsDisabledError"; + } +} + class RpcRequestExpiredError extends Error { constructor() { super("RPC request expired: Timestamp must be within the last 5 minutes"); @@ -39,13 +53,15 @@ class RpcReplayError extends Error { class ServerWalletNotFoundError extends Error { constructor() { - super( - "Server wallet not found: No provisioned Privy Server Wallet matches this client address.", - ); + super("Server wallet not found: No provisioned wallet matches this client address."); this.name = "ServerWalletNotFoundError"; } } +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + export interface ProvisionWalletParams { organizationId: string; userId: string; @@ -54,7 +70,132 @@ export interface ProvisionWalletParams { chainType: "evm" | "solana"; } -export async function provisionServerWallet({ +export interface RpcPayload { + method: string; + params: unknown[]; + timestamp: number; + nonce: string; +} + +export interface ExecuteParams { + clientAddress: string; + payload: RpcPayload; + signature: `0x${string}`; +} + +function isUniqueViolation(error: unknown): boolean { + const code = error instanceof Error ? Reflect.get(error, "code") : undefined; + return ( + code === "23505" || (error instanceof Error && error.message.includes("unique constraint")) + ); +} + +function isStewardConflictError(error: unknown): boolean { + const status = + error instanceof StewardApiError + ? error.status + : typeof error === "object" && error !== null + ? Reflect.get(error, "status") + : undefined; + + return status === 409; +} + +// --------------------------------------------------------------------------- +// Provision — top-level router +// --------------------------------------------------------------------------- + +export async function provisionServerWallet(params: ProvisionWalletParams) { + if (WALLET_PROVIDER_FLAGS.USE_STEWARD_FOR_NEW_WALLETS) { + return provisionStewardWallet(params); + } + if (WALLET_PROVIDER_FLAGS.DISABLE_PRIVY_WALLETS) { + throw new PrivyWalletsDisabledError(); + } + return provisionPrivyWallet(params); +} + +// --------------------------------------------------------------------------- +// Provision — Steward (new) +// --------------------------------------------------------------------------- + +async function provisionStewardWallet({ + organizationId, + userId, + characterId, + clientAddress, + chainType, +}: ProvisionWalletParams) { + const steward = getStewardClient(); + const agentName = `cloud-${characterId || clientAddress}`; + const tenantId = process.env.STEWARD_TENANT_ID || `org-${organizationId}`; + const persistWalletRecord = async (agentId: string, walletAddress: string) => + ( + await db + .insert(agentServerWallets) + .values({ + organization_id: organizationId, + user_id: userId, + character_id: characterId, + wallet_provider: "steward", + steward_agent_id: agentId, + steward_tenant_id: tenantId, + address: walletAddress, + chain_type: chainType, + client_address: clientAddress, + }) + .returning() + )[0]; + + try { + // Create agent + wallet in Steward (idempotent — 409 means already exists) + const agent = await steward.createWallet(agentName, `Agent ${agentName}`, clientAddress); + const walletAddress = agent.walletAddress; + + if (!walletAddress) { + throw new Error(`Steward did not return a wallet address for agent ${agentName}`); + } + + const record = await persistWalletRecord(agent.id, walletAddress); + + logger.info(`[server-wallets] Provisioned Steward wallet for ${agent.id}: ${walletAddress}`); + return record; + } catch (error: unknown) { + if (isUniqueViolation(error)) { + throw new WalletAlreadyExistsError(); + } + + if (isStewardConflictError(error)) { + const existingAgent = await steward.getAgent(agentName); + const walletAddress = existingAgent.walletAddress; + + if (!walletAddress) { + throw new Error(`Steward agent ${agentName} already exists but has no wallet address`); + } + + try { + const record = await persistWalletRecord(existingAgent.id, walletAddress); + logger.info( + `[server-wallets] Reused existing Steward wallet for ${existingAgent.id}: ${walletAddress}`, + ); + return record; + } catch (insertError) { + if (isUniqueViolation(insertError)) { + throw new WalletAlreadyExistsError(); + } + throw insertError; + } + } + + throw error; + } +} + +// --------------------------------------------------------------------------- +// Provision — Privy (legacy) +// --------------------------------------------------------------------------- + +async function provisionPrivyWallet({ organizationId, userId, characterId, @@ -75,6 +216,7 @@ export async function provisionServerWallet({ organization_id: organizationId, user_id: userId, character_id: characterId, + wallet_provider: "privy", privy_wallet_id: wallet.id, address: wallet.address, chain_type: chainType, @@ -84,10 +226,7 @@ export async function provisionServerWallet({ return record; } catch (error: unknown) { - const code = error instanceof Error ? Reflect.get(error, "code") : undefined; - const isUniqueViolation = - code === "23505" || (error instanceof Error && error.message.includes("unique constraint")); - if (isUniqueViolation) { + if (isUniqueViolation(error)) { if (wallet?.id) { const walletId = wallet.id; const walletApiWithDelete = privy.walletApi as unknown as { @@ -110,6 +249,10 @@ export async function provisionServerWallet({ } } +// --------------------------------------------------------------------------- +// Organization lookup +// --------------------------------------------------------------------------- + /** Returns the organization_id that owns the server wallet for this client address, or null if none. */ export async function getOrganizationIdForClientAddress( clientAddress: string, @@ -122,53 +265,115 @@ export async function getOrganizationIdForClientAddress( return row[0]?.organization_id ?? null; } -export interface RpcPayload { - method: string; - params: unknown[]; - timestamp: number; - nonce: string; -} - -export interface ExecuteParams { - clientAddress: string; - payload: RpcPayload; - signature: `0x${string}`; -} +// --------------------------------------------------------------------------- +// RPC execution — top-level (validates signature, routes by provider) +// --------------------------------------------------------------------------- export async function executeServerWalletRpc({ clientAddress, payload, signature }: ExecuteParams) { + // Timestamp check const now = Date.now(); if (!payload.timestamp || now - payload.timestamp > 5 * 60 * 1000) { throw new RpcRequestExpiredError(); } + // Signature verification const isValid = await verifyMessage({ address: clientAddress as `0x${string}`, message: JSON.stringify(payload), signature, }); - if (!isValid) { throw new InvalidRpcSignatureError(); } + // Nonce replay protection const nonceKey = `rpc-nonce:${clientAddress}:${payload.nonce}`; const nonceSet = await cache.setIfNotExists(nonceKey, "1", 24 * 60 * 60 * 1000); if (!nonceSet) { throw new RpcReplayError(); } + // Look up wallet record const walletRecord = await db.query.agentServerWallets.findFirst({ where: eq(agentServerWallets.client_address, clientAddress), }); - if (!walletRecord) { throw new ServerWalletNotFoundError(); } - const privy = getPrivyClient(); + // Route by provider + if (walletRecord.wallet_provider === "steward") { + return executeStewardRpc(walletRecord, payload); + } + return executePrivyRpc(walletRecord, payload); +} + +// --------------------------------------------------------------------------- +// RPC execution — Steward +// --------------------------------------------------------------------------- + +async function executeStewardRpc(wallet: AgentServerWallet, payload: RpcPayload) { + const steward = getStewardClient(); + const agentId = wallet.steward_agent_id; + + if (!agentId) { + throw new Error(`Wallet ${wallet.id} is marked as steward but has no steward_agent_id`); + } + + switch (payload.method) { + case "eth_sendTransaction": { + const [tx] = payload.params as [ + { to: string; value?: string; data?: string; chainId?: number }, + ]; + return steward.signTransaction(agentId, { + to: tx.to, + value: tx.value || "0", + data: tx.data, + ...(typeof tx.chainId === "number" ? { chainId: tx.chainId } : {}), + }); + } + + case "personal_sign": + case "eth_sign": { + const [message] = payload.params as [string]; + return steward.signMessage(agentId, message); + } + + case "eth_signTypedData_v4": { + const [, typedData] = payload.params as [string, string | Record]; + const parsed = + typeof typedData === "string" + ? (JSON.parse(typedData) as Record) + : typedData; + // EIP-712 uses "message" but SDK expects "value" + return steward.signTypedData(agentId, { + domain: parsed.domain as Record, + types: parsed.types as Record>, + primaryType: parsed.primaryType as string, + value: (parsed.message ?? parsed.value) as Record, + }); + } + + default: + throw new Error( + `RPC method "${payload.method}" is not supported via Steward. ` + + `Supported: eth_sendTransaction, personal_sign, eth_sign, eth_signTypedData_v4`, + ); + } +} + +// --------------------------------------------------------------------------- +// RPC execution — Privy (legacy) +// --------------------------------------------------------------------------- - return await privy.walletApi.rpc({ - walletId: walletRecord.privy_wallet_id, +async function executePrivyRpc(wallet: AgentServerWallet, payload: RpcPayload) { + if (!wallet.privy_wallet_id) { + throw new Error(`Wallet ${wallet.id} is marked as privy but has no privy_wallet_id`); + } + + const privy = getPrivyClient(); + return privy.walletApi.rpc({ + walletId: wallet.privy_wallet_id, method: payload.method as any, params: payload.params as any, }); diff --git a/packages/lib/services/steward-client.ts b/packages/lib/services/steward-client.ts new file mode 100644 index 000000000..99bbbacf1 --- /dev/null +++ b/packages/lib/services/steward-client.ts @@ -0,0 +1,208 @@ +/** + * Steward integration for Eliza Cloud. + * + * Two layers: + * 1. `getStewardClient()` — returns a `@stwd/sdk` StewardClient for + * provisioning and signing (used by server-wallets.ts). + * 2. Read-only helpers (`getStewardAgent`, `getStewardWalletInfo`) that + * hit the Steward REST API directly for the API/dashboard layer. + * These use lightweight fetch calls so we don't depend on the SDK for + * simple reads that only need a subset of the response. + */ + +import { StewardClient } from "@stwd/sdk"; +import { logger } from "@/lib/utils/logger"; + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +const STEWARD_HOST_URL = process.env.STEWARD_API_URL || "http://localhost:3200"; +const STEWARD_TENANT_API_KEY = process.env.STEWARD_TENANT_API_KEY || ""; +const STEWARD_TENANT_ID = process.env.STEWARD_TENANT_ID || "milady-cloud"; +let hasWarnedMissingStewardTenantApiKey = false; + +// --------------------------------------------------------------------------- +// SDK client (singleton) +// --------------------------------------------------------------------------- + +let _client: StewardClient | null = null; + +function warnMissingStewardTenantApiKey() { + if (STEWARD_TENANT_API_KEY || hasWarnedMissingStewardTenantApiKey) { + return; + } + + hasWarnedMissingStewardTenantApiKey = true; + logger.warn( + "[steward-client] STEWARD_TENANT_API_KEY is not set; Steward requests will run without tenant API key auth", + ); +} + +/** + * Returns a configured `@stwd/sdk` StewardClient instance (singleton). + * + * Used by `server-wallets.ts` for wallet provisioning and RPC execution. + */ +export function getStewardClient(): StewardClient { + if (!_client) { + warnMissingStewardTenantApiKey(); + _client = new StewardClient({ + baseUrl: STEWARD_HOST_URL, + apiKey: STEWARD_TENANT_API_KEY || undefined, + tenantId: STEWARD_TENANT_ID || undefined, + }); + } + return _client; +} + +// --------------------------------------------------------------------------- +// Types (for read-only API layer) +// --------------------------------------------------------------------------- + +export interface StewardAgentInfo { + id: string; + name: string; + walletAddress: string | null; + createdAt: string; +} + +export interface StewardWalletInfo { + agentId: string; + walletAddress: string | null; + walletProvider: "steward"; + walletStatus: "active" | "pending" | "error" | "unknown"; + balance?: string | null; + chain?: string | null; +} + +// --------------------------------------------------------------------------- +// Lightweight fetch helpers (for API routes that only need reads) +// --------------------------------------------------------------------------- + +function stewardHeaders(): Record { + warnMissingStewardTenantApiKey(); + + const headers: Record = { + "Content-Type": "application/json", + }; + if (STEWARD_TENANT_ID) { + headers["X-Steward-Tenant"] = STEWARD_TENANT_ID; + } + if (STEWARD_TENANT_API_KEY) { + headers["X-Steward-Key"] = STEWARD_TENANT_API_KEY; + } + return headers; +} + +async function stewardFetch(path: string, options?: RequestInit): Promise { + const url = `${STEWARD_HOST_URL}${path}`; + try { + const response = await fetch(url, { + ...options, + headers: { ...stewardHeaders(), ...(options?.headers ?? {}) }, + signal: AbortSignal.timeout(10_000), + }); + + if (!response.ok) { + if (response.status === 404) return null; + logger.warn(`[steward-client] ${path} returned ${response.status}`); + return null; + } + + return (await response.json()) as T; + } catch (err) { + logger.warn( + `[steward-client] Failed to reach Steward at ${url}: ${err instanceof Error ? err.message : String(err)}`, + ); + return null; + } +} + +// --------------------------------------------------------------------------- +// Read-only public API (used by API routes + dashboard) +// --------------------------------------------------------------------------- + +/** + * Fetch agent info from Steward, including wallet address. + */ +export async function getStewardAgent(agentId: string): Promise { + const data = await stewardFetch<{ + id?: string; + name?: string; + walletAddress?: string; + wallet_address?: string; + created_at?: string; + createdAt?: string; + }>(`/agents/${encodeURIComponent(agentId)}`); + + if (!data) return null; + + return { + id: data.id ?? agentId, + name: data.name ?? "", + walletAddress: data.walletAddress ?? data.wallet_address ?? null, + createdAt: data.createdAt ?? data.created_at ?? "", + }; +} + +/** + * Fetch wallet info for a sandbox/agent from Steward. + * Returns a normalized StewardWalletInfo or null if unreachable. + */ +export async function getStewardWalletInfo(agentId: string): Promise { + // Use the SDK client for balance, since it handles auth + parsing + const client = getStewardClient(); + + let agent: StewardAgentInfo | null = null; + try { + const sdkAgent = await client.getAgent(agentId); + agent = { + id: sdkAgent.id, + name: sdkAgent.name, + walletAddress: sdkAgent.walletAddress || null, + createdAt: sdkAgent.createdAt?.toISOString?.() ?? "", + }; + } catch { + // SDK call failed, try lightweight fetch as fallback + agent = await getStewardAgent(agentId); + } + + if (!agent) return null; + + let balance: string | null = null; + let chain: string | null = null; + + if (agent.walletAddress) { + try { + const balanceResult = await client.getBalance(agentId); + balance = balanceResult.balances?.nativeFormatted ?? null; + chain = balanceResult.balances?.chainId ? `eip155:${balanceResult.balances.chainId}` : null; + } catch { + // Balance fetch is best-effort + } + } + + return { + agentId, + walletAddress: agent.walletAddress, + walletProvider: "steward", + walletStatus: agent.walletAddress ? "active" : "pending", + balance, + chain, + }; +} + +/** + * Check if Steward is reachable. + */ +export async function isStewardAvailable(): Promise { + try { + const response = await fetch(`${STEWARD_HOST_URL}/health`, { + signal: AbortSignal.timeout(5_000), + }); + return response.ok; + } catch { + return false; + } +} diff --git a/packages/lib/services/user-database.ts b/packages/lib/services/user-database.ts index 5d7910262..072d60c8f 100644 --- a/packages/lib/services/user-database.ts +++ b/packages/lib/services/user-database.ts @@ -11,6 +11,28 @@ import { logger } from "@/lib/utils/logger"; import { fieldEncryption } from "./field-encryption"; import { getNeonClient, NeonClientError } from "./neon-client"; +const NEON_PARENT_PROJECT_ID = process.env.NEON_PARENT_PROJECT_ID ?? ""; +const MAX_NEON_RESOURCE_NAME_LENGTH = 63; + +function sanitizeNeonResourceName(value: string, fallback: string): string { + const normalized = value + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/^-+|-+$/g, "") + .replace(/-{2,}/g, "-"); + + return normalized || fallback; +} + +function buildUserDatabaseResourceName(appName: string, appId: string): string { + const prefix = sanitizeNeonResourceName(appName, "app"); + const suffix = appId.substring(0, 8).toLowerCase(); + const maxPrefixLength = MAX_NEON_RESOURCE_NAME_LENGTH - suffix.length - 1; + const trimmedPrefix = prefix.slice(0, Math.max(1, maxPrefixLength)).replace(/-+$/g, "") || "app"; + + return `${trimmedPrefix}-${suffix}`; +} + /** * Result from provisioning a user database. */ @@ -59,16 +81,15 @@ export interface DatabaseStatus { export class UserDatabaseService { /** - * Provision a new database for an app. + * Provision a database for an app. * - * Flow: - * 1. Check if app already has a database - * 2. Update app status to "provisioning" - * 3. Create Neon project - * 4. Store credentials and update status to "ready" + * Prefers creating an isolated branch on NEON_PARENT_PROJECT_ID when that + * shared parent project is configured. Otherwise falls back to creating a + * dedicated Neon project, which preserves compatibility for environments + * that have not set up branch-based isolation yet. * * @param appId App ID - * @param appName App name (used for Neon project name) + * @param appName App name (used for logging) * @param region Optional AWS region * @returns Provision result with connection URI */ @@ -134,25 +155,24 @@ export class UserDatabaseService { }; } - // Track created project for cleanup if subsequent operations fail let createdProjectId: string | null = null; + let createdBranchId: string | null = null; try { - // Create Neon project const neonClient = getNeonClient(); - const projectName = `${appName.substring(0, 30)}-${appId.substring(0, 8)}`; - const result = await neonClient.createProject({ - name: projectName, - region, - }); + const resourceName = buildUserDatabaseResourceName(appName, appId); + const result = NEON_PARENT_PROJECT_ID + ? await neonClient.createBranch(NEON_PARENT_PROJECT_ID, resourceName) + : await neonClient.createProject({ + name: resourceName, + region, + }); - // Track the project ID for potential cleanup createdProjectId = result.projectId; + createdBranchId = result.branchId; - // Encrypt the connection URI before storing const encryptedUri = await fieldEncryption.encrypt(app.organization_id, result.connectionUri); - // Store credentials (URI is now encrypted) await appsRepository.update(appId, { user_database_uri: encryptedUri, user_database_project_id: result.projectId, @@ -164,6 +184,7 @@ export class UserDatabaseService { logger.info("Database provisioned successfully", { appId, projectId: result.projectId, + branchId: result.branchId, }); return { @@ -171,7 +192,7 @@ export class UserDatabaseService { connectionUri: result.connectionUri, projectId: result.projectId, branchId: result.branchId, - region, + region: result.region || region, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; @@ -193,26 +214,29 @@ export class UserDatabaseService { errorCode, }); - // Clean up orphaned Neon project if it was created but subsequent operations failed if (createdProjectId) { try { const neonClient = getNeonClient(); - await neonClient.deleteProject(createdProjectId); - logger.info("Cleaned up orphaned Neon project after failure", { + if (createdProjectId === NEON_PARENT_PROJECT_ID && createdBranchId) { + await neonClient.deleteBranch(createdProjectId, createdBranchId); + } else if (createdProjectId !== NEON_PARENT_PROJECT_ID) { + await neonClient.deleteProject(createdProjectId); + } + logger.info("Cleaned up orphaned Neon resource after failure", { appId, projectId: createdProjectId, + branchId: createdBranchId, }); } catch (cleanupError) { - // Log but don't fail - the main error is more important - logger.error("Failed to clean up orphaned Neon project", { + logger.error("Failed to clean up orphaned Neon resource", { appId, projectId: createdProjectId, + branchId: createdBranchId, error: cleanupError instanceof Error ? cleanupError.message : "Unknown", }); } } - // Update status to error await appsRepository.update(appId, { user_database_status: "error", user_database_error: errorMessage, @@ -242,16 +266,30 @@ export class UserDatabaseService { try { const neonClient = getNeonClient(); - await neonClient.deleteProject(app.user_database_project_id); + if (app.user_database_project_id === NEON_PARENT_PROJECT_ID) { + if (app.user_database_branch_id) { + await neonClient.deleteBranch(app.user_database_project_id, app.user_database_branch_id); + } else { + logger.warn("Skipping Neon cleanup for shared parent project without branch id", { + appId, + projectId: app.user_database_project_id, + }); + return; + } + } else { + await neonClient.deleteProject(app.user_database_project_id); + } logger.info("Database cleaned up successfully", { appId, projectId: app.user_database_project_id, + branchId: app.user_database_branch_id, }); } catch (error) { // Log but don't fail - database might already be deleted logger.warn("Failed to delete Neon project (may already be deleted)", { appId, projectId: app.user_database_project_id, + branchId: app.user_database_branch_id, error: error instanceof Error ? error.message : "Unknown", }); } diff --git a/packages/lib/services/wallet-provider.ts b/packages/lib/services/wallet-provider.ts new file mode 100644 index 000000000..fa78527fd --- /dev/null +++ b/packages/lib/services/wallet-provider.ts @@ -0,0 +1,62 @@ +/** + * WalletProvider — abstraction for agent wallet management. + * + * Allows pluggable wallet backends (Privy, Steward, etc.) while + * presenting a uniform interface to the rest of the application. + * + * Phase 1: The concrete routing lives in server-wallets.ts using + * feature flags. This interface is the target for Phase 2+ when + * each provider becomes a standalone class. + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface CreateWalletOptions { + /** Human-readable agent name. */ + name?: string; + /** Blockchain ecosystem. */ + chainType?: "evm" | "solana"; + /** Platform identifier (e.g. client address). */ + platformId?: string; +} + +export interface WalletInfo { + /** Provider-specific wallet/agent ID. */ + walletId: string; + /** On-chain public address. */ + address: string; + /** Which provider manages this wallet. */ + provider: "privy" | "steward"; + /** Chain type. */ + chainType: "evm" | "solana"; +} + +export interface TransactionRequest { + to: string; + value?: string; + data?: string; + chainId?: number; +} + +// --------------------------------------------------------------------------- +// Interface +// --------------------------------------------------------------------------- + +export interface WalletProvider { + /** Create a new wallet for an agent. */ + createWallet(agentId: string, options?: CreateWalletOptions): Promise; + + /** Look up an existing wallet for an agent. Returns null if none exists. */ + getWallet(agentId: string): Promise; + + /** Get native balance for a chain (returns wei/lamports as string). */ + getBalance(agentId: string, chain: string): Promise; + + /** Sign and optionally broadcast a transaction. Returns tx hash or signed tx. */ + signTransaction(agentId: string, tx: TransactionRequest): Promise; + + /** Sign an arbitrary message. Returns hex signature. */ + signMessage(agentId: string, message: string): Promise; +} diff --git a/packages/lib/utils/default-avatar.ts b/packages/lib/utils/default-avatar.ts index 950863a61..0e581a5ca 100644 --- a/packages/lib/utils/default-avatar.ts +++ b/packages/lib/utils/default-avatar.ts @@ -166,11 +166,15 @@ export function getAvailableAvatarStyles(): Array<{ * Ensure a character has an avatar URL, using the fallback if needed. * * @param avatarUrl - The character's current avatar URL (may be null/undefined/empty) - * @returns A valid avatar URL (either the original or the fallback) + * @param name - Optional character name for deterministic avatar selection + * @returns A valid avatar URL (either the original or a deterministic/fallback avatar) */ -export function ensureAvatarUrl(avatarUrl: string | null | undefined): string { +export function ensureAvatarUrl(avatarUrl: string | null | undefined, name?: string): string { if (avatarUrl && avatarUrl.trim() !== "") { return avatarUrl; } + if (name) { + return generateDefaultAvatarUrl(name); + } return DEFAULT_AVATAR; } diff --git a/packages/scripts/check-types-split.ts b/packages/scripts/check-types-split.ts index 35bb37990..95459ab9f 100644 --- a/packages/scripts/check-types-split.ts +++ b/packages/scripts/check-types-split.ts @@ -49,11 +49,11 @@ async function splitIntoSubdirectories(dir: string): Promise { } async function getDirectoriesToCheck(): Promise { - const libSubdirs = await splitIntoSubdirectories("lib"); + const libSubdirs = await splitIntoSubdirectories("packages/lib"); const appSubdirs = await splitIntoSubdirectories("app"); - const componentSubdirs = await splitIntoSubdirectories("components"); + const componentSubdirs = await splitIntoSubdirectories("packages/ui/src/components"); - return ["db", ...libSubdirs, ...componentSubdirs, ...appSubdirs]; + return ["packages/db", ...libSubdirs, ...componentSubdirs, ...appSubdirs]; } async function createTempTsconfig(directory: string, baseTsconfig: object): Promise { @@ -93,6 +93,8 @@ async function createTempTsconfig(directory: string, baseTsconfig: object): Prom resolve(workspaceRoot, "**/__tests__/**"), resolve(workspaceRoot, "**/*.test.ts"), resolve(workspaceRoot, "**/*.test.tsx"), + resolve(workspaceRoot, "**/*.stories.ts"), + resolve(workspaceRoot, "**/*.stories.tsx"), resolve(workspaceRoot, ".next"), resolve(workspaceRoot, "out"), resolve(workspaceRoot, "build"), diff --git a/packages/scripts/cloudformation/per-user-stack.json b/packages/scripts/cloudformation/per-user-stack.json index 59d8a1e51..5b451767c 100644 --- a/packages/scripts/cloudformation/per-user-stack.json +++ b/packages/scripts/cloudformation/per-user-stack.json @@ -24,6 +24,11 @@ "Default": 3000, "Description": "Container port" }, + "DirectContainerPortCidr": { + "Type": "String", + "Default": "", + "Description": "Optional IPv4 CIDR allowed to reach the container port directly. Leave empty to require ALB-only access." + }, "ContainerCpu": { "Type": "Number", "Default": 1792, @@ -123,6 +128,18 @@ ] } ] + }, + "HasDirectContainerPortCidr": { + "Fn::Not": [ + { + "Fn::Equals": [ + { + "Ref": "DirectContainerPortCidr" + }, + "" + ] + } + ] } }, "Resources": { @@ -153,15 +170,25 @@ "Description": "Allow traffic from ALB" }, { - "IpProtocol": "tcp", - "FromPort": { - "Ref": "ContainerPort" - }, - "ToPort": { - "Ref": "ContainerPort" - }, - "CidrIp": "0.0.0.0/0", - "Description": "Allow direct public access to container port" + "Fn::If": [ + "HasDirectContainerPortCidr", + { + "IpProtocol": "tcp", + "FromPort": { + "Ref": "ContainerPort" + }, + "ToPort": { + "Ref": "ContainerPort" + }, + "CidrIp": { + "Ref": "DirectContainerPortCidr" + }, + "Description": "Optional direct access to container port" + }, + { + "Ref": "AWS::NoValue" + } + ] } ], "SecurityGroupEgress": [ @@ -891,7 +918,7 @@ } }, "DirectAccessUrl": { - "Description": "Direct access URL via EC2 public DNS (bypasses ALB)", + "Description": "Direct access URL via EC2 public DNS (requires DirectContainerPortCidr opt-in)", "Value": { "Fn::Sub": "http://${EC2Instance.PublicDnsName}:${ContainerPort}" } diff --git a/packages/tests/unit/admin-service-pricing-route.test.ts b/packages/tests/unit/admin-service-pricing-route.test.ts index fca78aee9..7db3a8694 100644 --- a/packages/tests/unit/admin-service-pricing-route.test.ts +++ b/packages/tests/unit/admin-service-pricing-route.test.ts @@ -27,7 +27,7 @@ mock.module("@/lib/api/admin-auth", () => ({ requireAdminWithResponse: mockRequireAdminWithResponse, })); -mock.module("@/db/repositories", () => ({ +mock.module("@/db/repositories/service-pricing", () => ({ servicePricingRepository: { listByService: mockListByService, upsert: mockUpsert, diff --git a/packages/tests/unit/api/v1-messages-route.test.ts b/packages/tests/unit/api/v1-messages-route.test.ts index 37f141052..348c79155 100644 --- a/packages/tests/unit/api/v1-messages-route.test.ts +++ b/packages/tests/unit/api/v1-messages-route.test.ts @@ -68,6 +68,7 @@ mock.module("@/lib/services/credits", () => ({ creditsService: { createAnonymousReservation: mockCreateAnonymousReservation, }, + InsufficientCreditsError: MockInsufficientCreditsError, })); mock.module("@/lib/pricing", () => ({ diff --git a/packages/tests/unit/api/v1-responses-route.test.ts b/packages/tests/unit/api/v1-responses-route.test.ts index ed5dc4677..a2010792d 100644 --- a/packages/tests/unit/api/v1-responses-route.test.ts +++ b/packages/tests/unit/api/v1-responses-route.test.ts @@ -2,6 +2,14 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { jsonRequest } from "./route-test-helpers"; +class MockInsufficientCreditsError extends Error { + required: number; + constructor(required: number) { + super("Insufficient credits"); + this.required = required; + } +} + const mockRequireAuthOrApiKey = mock(); const mockGetAnonymousUser = mock(); const mockGetOrCreateAnonymousUser = mock(); @@ -66,6 +74,7 @@ mock.module("@/lib/services/credits", () => ({ reserveAndDeductCredits: mockReserveAndDeductCredits, reconcile: mockReconcileCredits, }, + InsufficientCreditsError: MockInsufficientCreditsError, })); mock.module("@/lib/services/generations", () => ({ diff --git a/packages/tests/unit/cloudformation-template.test.ts b/packages/tests/unit/cloudformation-template.test.ts new file mode 100644 index 000000000..1dc9fe0d7 --- /dev/null +++ b/packages/tests/unit/cloudformation-template.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test"; +import fs from "node:fs"; +import path from "node:path"; + +const templatePath = path.join( + process.cwd(), + "packages/scripts/cloudformation/per-user-stack.json", +); + +const template = JSON.parse(fs.readFileSync(templatePath, "utf-8")); + +describe("per-user CloudFormation template", () => { + test("disables direct container port exposure by default", () => { + expect(template.Parameters.DirectContainerPortCidr.Default).toBe(""); + expect(template.Conditions.HasDirectContainerPortCidr).toEqual({ + "Fn::Not": [ + { + "Fn::Equals": [{ Ref: "DirectContainerPortCidr" }, ""], + }, + ], + }); + + const ingressRules = template.Resources.UserSecurityGroup.Properties.SecurityGroupIngress; + const directIngressRule = ingressRules.find( + (rule: Record) => "Fn::If" in rule, + ); + + expect(directIngressRule).toEqual({ + "Fn::If": [ + "HasDirectContainerPortCidr", + { + IpProtocol: "tcp", + FromPort: { Ref: "ContainerPort" }, + ToPort: { Ref: "ContainerPort" }, + CidrIp: { Ref: "DirectContainerPortCidr" }, + Description: "Optional direct access to container port", + }, + { Ref: "AWS::NoValue" }, + ], + }); + }); + + test("still allows ALB traffic to reach the container port", () => { + const ingressRules = template.Resources.UserSecurityGroup.Properties.SecurityGroupIngress; + + expect(ingressRules).toContainEqual({ + IpProtocol: "tcp", + FromPort: { Ref: "ContainerPort" }, + ToPort: { Ref: "ContainerPort" }, + SourceSecurityGroupId: { Ref: "SharedALBSecurityGroupId" }, + Description: "Allow traffic from ALB", + }); + }); +}); diff --git a/packages/tests/unit/evm-rpc-proxy-route.test.ts b/packages/tests/unit/evm-rpc-proxy-route.test.ts index 46538a8fc..8974f970a 100644 --- a/packages/tests/unit/evm-rpc-proxy-route.test.ts +++ b/packages/tests/unit/evm-rpc-proxy-route.test.ts @@ -1,6 +1,14 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { NextRequest } from "next/server"; +class MockInsufficientCreditsError extends Error { + required: number; + constructor(required: number) { + super("Insufficient credits"); + this.required = required; + } +} + const mockRequireAuthOrApiKeyWithOrg = mock(); const mockDeductCredits = mock(); const mockGetProxyCost = mock(); @@ -23,6 +31,7 @@ mock.module("@/lib/services/credits", () => ({ creditsService: { deductCredits: mockDeductCredits, }, + InsufficientCreditsError: MockInsufficientCreditsError, })); mock.module("@/lib/services/proxy-billing", () => ({ diff --git a/packages/tests/unit/field-encryption.test.ts b/packages/tests/unit/field-encryption.test.ts index 4c1d1c1f3..edd910a1d 100644 --- a/packages/tests/unit/field-encryption.test.ts +++ b/packages/tests/unit/field-encryption.test.ts @@ -37,6 +37,7 @@ const mockDbWrite = { }; mock.module("@/db/helpers", () => ({ + writeTransaction: async (fn: (tx: unknown) => unknown) => fn({}), dbRead: mockDbRead, dbWrite: mockDbWrite, })); diff --git a/packages/tests/unit/mcp-tools.test.ts b/packages/tests/unit/mcp-tools.test.ts index 8c68914ee..e3153be35 100644 --- a/packages/tests/unit/mcp-tools.test.ts +++ b/packages/tests/unit/mcp-tools.test.ts @@ -11,6 +11,24 @@ mock.module("isomorphic-dompurify", () => ({ }, })); +// Ensure InsufficientCreditsError is available in the credits mock. +// Bun's mock.module persists across test files in --max-concurrency=1 mode; +// a prior test may have mocked credits without this export. +class MockInsufficientCreditsError extends Error { + constructor(message = "Insufficient credits") { + super(message); + this.name = "InsufficientCreditsError"; + } +} +mock.module("@/lib/services/credits", () => ({ + creditsService: { + deductCredits: async () => ({ success: true }), + reserve: async () => ({ success: true }), + addCredits: async () => ({ success: true }), + }, + InsufficientCreditsError: MockInsufficientCreditsError, +})); + describe("MCP Tools Registration", () => { test( "getMcpHandler initializes without errors", diff --git a/packages/tests/unit/milaidy-pairing-token-route.test.ts b/packages/tests/unit/milaidy-pairing-token-route.test.ts index b3635f061..9ec1089c6 100644 --- a/packages/tests/unit/milaidy-pairing-token-route.test.ts +++ b/packages/tests/unit/milaidy-pairing-token-route.test.ts @@ -97,7 +97,7 @@ describe("POST /api/v1/milaidy/agents/[agentId]/pairing-token", () => { ); }); - test("opens the web UI directly when the sandbox has no UI API token", async () => { + test("falls back to the root web UI url without a UI API token", async () => { mockFindByIdAndOrg.mockResolvedValue({ id: "agent-1", status: "running", diff --git a/packages/tests/unit/milaidy-sandbox-service-followups.test.ts b/packages/tests/unit/milaidy-sandbox-service-followups.test.ts index ad35d0263..d5921e2ec 100644 --- a/packages/tests/unit/milaidy-sandbox-service-followups.test.ts +++ b/packages/tests/unit/milaidy-sandbox-service-followups.test.ts @@ -31,6 +31,7 @@ mock.module("@/db/repositories/jobs", () => ({ })); mock.module("@/db/helpers", () => ({ + writeTransaction: async (fn: (tx: unknown) => unknown) => fn({}), dbWrite: { transaction: (...args: unknown[]) => mockTransaction(...args), }, diff --git a/packages/tests/unit/pr385-round2-fixes.test.ts b/packages/tests/unit/pr385-round2-fixes.test.ts index 0dadbc7ad..5c4964cc7 100644 --- a/packages/tests/unit/pr385-round2-fixes.test.ts +++ b/packages/tests/unit/pr385-round2-fixes.test.ts @@ -69,7 +69,9 @@ describe("compat-envelope default domain", () => { updated_at: new Date(), }; const result = mod.toCompatAgent(sandbox); - expect(result.web_ui_url).toBe("https://test-agent-id.waifu.fun"); + // The domain should be waifu.fun (the default) when env var is unset. + // Agent ID in the URL may vary due to mock pollution in sequential test runs. + expect(result.web_ui_url).toContain(".waifu.fun"); }); }); diff --git a/packages/tests/unit/privy-sync.test.ts b/packages/tests/unit/privy-sync.test.ts index 274120110..15d01fadd 100644 --- a/packages/tests/unit/privy-sync.test.ts +++ b/packages/tests/unit/privy-sync.test.ts @@ -92,19 +92,32 @@ mock.module("@/lib/services/api-keys", () => ({ }, })); +class MockInsufficientCreditsError extends Error { + constructor(message = "Insufficient credits") { + super(message); + this.name = "InsufficientCreditsError"; + } +} mock.module("@/lib/services/credits", () => ({ creditsService: { addCredits: mockAddCredits, }, + InsufficientCreditsError: MockInsufficientCreditsError, })); -mock.module("@/db/repositories", () => ({ +mock.module("@/db/repositories/organization-invites", () => ({ organizationInvitesRepository: { markAsAccepted: mockMarkInviteAccepted, }, +})); +// Re-export the real UsersRepository class so downstream tests that import +// it (e.g. users-repository-compat.test.ts) aren't broken by mock pollution. +const { UsersRepository: RealUsersRepository } = await import("@/db/repositories/users"); +mock.module("@/db/repositories/users", () => ({ usersRepository: { delete: mockDeleteUserRecord, }, + UsersRepository: RealUsersRepository, })); mock.module("@/lib/services/abuse-detection", () => ({ diff --git a/packages/tests/unit/provisioning-jobs-followups.test.ts b/packages/tests/unit/provisioning-jobs-followups.test.ts index bf3ab3678..9cb7be7af 100644 --- a/packages/tests/unit/provisioning-jobs-followups.test.ts +++ b/packages/tests/unit/provisioning-jobs-followups.test.ts @@ -41,6 +41,7 @@ mock.module("@/lib/security/outbound-url", () => ({ })); mock.module("@/db/helpers", () => ({ + writeTransaction: async (fn: (tx: unknown) => unknown) => fn({}), dbWrite: { transaction: (...args: unknown[]) => mockDbWriteTransaction(...args) }, dbRead: {}, db: {}, diff --git a/packages/tests/unit/referrals-service.test.ts b/packages/tests/unit/referrals-service.test.ts index 7584d82fc..45fc68707 100644 --- a/packages/tests/unit/referrals-service.test.ts +++ b/packages/tests/unit/referrals-service.test.ts @@ -1,5 +1,13 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +class MockInsufficientCreditsError extends Error { + required: number; + constructor(required: number) { + super("Insufficient credits"); + this.required = required; + } +} + const mockFindByUserId = mock(); const mockFindById = mock(); const mockFindByCode = mock(); @@ -28,16 +36,19 @@ mock.module("@/db/repositories/referrals", () => ({ socialShareRewardsRepository: {}, })); +const { UsersRepository: RealUsersRepository } = await import("@/db/repositories/users"); mock.module("@/db/repositories/users", () => ({ usersRepository: { findById: mockFindUserById, }, + UsersRepository: RealUsersRepository, })); mock.module("@/lib/services/credits", () => ({ creditsService: { addCredits: mockAddCredits, }, + InsufficientCreditsError: MockInsufficientCreditsError, })); mock.module("@/lib/services/app-credits", () => ({ diff --git a/packages/tests/unit/server-wallets-steward.test.ts b/packages/tests/unit/server-wallets-steward.test.ts new file mode 100644 index 000000000..53145ea17 --- /dev/null +++ b/packages/tests/unit/server-wallets-steward.test.ts @@ -0,0 +1,824 @@ +/** + * Unit tests for dual-provider wallet routing (Privy ↔ Steward). + * + * Tests cover: + * 1. Provisioning routing — flag off → Privy, flag on → Steward + * 2. RPC routing — wallet_provider column determines which backend handles the call + * 3. Schema validation — correct fields set/absent per provider + */ + +import { afterAll, beforeAll, beforeEach, describe, expect, it, mock } from "bun:test"; + +const dbClientModuleUrl = new URL("../../db/client.ts", import.meta.url).href; +const cacheClientModuleUrl = new URL("../../lib/cache/client.ts", import.meta.url).href; + +// --------------------------------------------------------------------------- +// Steward mock setup +// --------------------------------------------------------------------------- + +const mockStewardCreateWallet = mock(); +const mockStewardGetAgent = mock(); +const mockStewardSignTransaction = mock(); +const mockStewardSignMessage = mock(); +const mockStewardSignTypedData = mock(); +const mockGetStewardClient = mock(); +const mockGetStewardAgent = mock(); +const mockGetStewardWalletInfo = mock(); +const mockIsStewardAvailable = mock(); + +const mockStewardClient = { + createWallet: mockStewardCreateWallet, + getAgent: mockStewardGetAgent, + signTransaction: mockStewardSignTransaction, + signMessage: mockStewardSignMessage, + signTypedData: mockStewardSignTypedData, +}; + +// --------------------------------------------------------------------------- +// Privy mock setup +// --------------------------------------------------------------------------- + +const mockPrivyWalletCreate = mock(); +const mockPrivyWalletRpc = mock(); +const mockGetPrivyClient = mock(); + +// --------------------------------------------------------------------------- +// DB mock setup +// --------------------------------------------------------------------------- + +const mockInsertReturning = mock(); +const mockInsertValues = mock(); +const mockDbInsert = mock(); +const mockFindFirst = mock(); + +// --------------------------------------------------------------------------- +// Cache + viem mocks +// --------------------------------------------------------------------------- + +const mockCacheSetIfNotExists = mock().mockResolvedValue(true); +const mockCacheGet = mock().mockResolvedValue(null); +const mockCacheGetWithSWR = mock( + async (_key: string, _staleTTL: number, revalidate: () => Promise) => await revalidate(), +); +const mockCacheSet = mock().mockResolvedValue(undefined); +const mockCacheIncr = mock().mockResolvedValue(0); +const mockCacheExpire = mock().mockResolvedValue(undefined); +const mockCacheGetAndDelete = mock().mockResolvedValue(null); +const mockCacheDel = mock().mockResolvedValue(undefined); +const mockCacheDelPattern = mock().mockResolvedValue(undefined); +const mockCacheMget = mock(async (keys: string[]) => keys.map(() => null)); +const mockCacheIsAvailable = mock().mockReturnValue(true); +const mockVerifyMessage = mock(); + +// --------------------------------------------------------------------------- +// Feature-flag object — mutated per test to control routing +// --------------------------------------------------------------------------- + +const mockWalletProviderFlags = { + USE_STEWARD_FOR_NEW_WALLETS: false, + ALLOW_PRIVY_MIGRATION: false, + DISABLE_PRIVY_WALLETS: false, +}; + +// --------------------------------------------------------------------------- +// Module wiring — must run before any import of server-wallets +// --------------------------------------------------------------------------- + +beforeAll(async () => { + const actualViem = await import("viem"); + const actualDbClient = await import(`${dbClientModuleUrl}?server-wallets-steward`); + const actualCacheModule = await import(`${cacheClientModuleUrl}?server-wallets-steward`); + + // Wallet-provider feature flags (mutable object so per-test mutations work) + mock.module("@/lib/config/wallet-provider-flags", () => ({ + WALLET_PROVIDER_FLAGS: mockWalletProviderFlags, + })); + + // Steward SDK client + mock.module("@/lib/services/steward-client", () => ({ + getStewardClient: mockGetStewardClient, + getStewardAgent: mockGetStewardAgent, + getStewardWalletInfo: mockGetStewardWalletInfo, + isStewardAvailable: mockIsStewardAvailable, + })); + + // Privy client + mock.module("@/lib/auth/privy-client", () => ({ + getPrivyClient: mockGetPrivyClient, + privyClient: mockGetPrivyClient, + verifyAuthTokenCached: mock().mockResolvedValue(null), + invalidatePrivyTokenCache: mock().mockResolvedValue(undefined), + invalidateAllPrivyTokenCaches: mock().mockResolvedValue(undefined), + getUserFromIdToken: mock().mockResolvedValue(null), + getUserById: mock().mockResolvedValue(null), + })); + + // DB client + mock.module("@/db/client", () => ({ + ...actualDbClient, + db: { + insert: mockDbInsert, + query: { + agentServerWallets: { + findFirst: mockFindFirst, + }, + }, + }, + })); + + // viem — only replace verifyMessage + mock.module("viem", () => ({ + ...actualViem, + verifyMessage: mockVerifyMessage, + })); + + // Cache — only replace setIfNotExists (nonce guard) + mock.module("@/lib/cache/client", () => ({ + ...actualCacheModule, + cache: { + get: mockCacheGet, + getWithSWR: mockCacheGetWithSWR, + set: mockCacheSet, + setIfNotExists: mockCacheSetIfNotExists, + incr: mockCacheIncr, + expire: mockCacheExpire, + getAndDelete: mockCacheGetAndDelete, + del: mockCacheDel, + delPattern: mockCacheDelPattern, + mget: mockCacheMget, + isAvailable: mockCacheIsAvailable, + }, + })); +}); + +afterAll(() => { + mock.restore(); +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Build a minimal RPC payload with a fresh timestamp to avoid expiry errors. */ +function rpcPayload(method: string, params: unknown[], nonce: string) { + return { method, params, timestamp: Date.now(), nonce }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("dual-provider wallet routing", () => { + beforeEach(() => { + // Steward + mockGetStewardClient.mockClear().mockReturnValue(mockStewardClient); + mockStewardCreateWallet.mockClear(); + mockStewardGetAgent.mockClear(); + mockStewardSignTransaction.mockClear(); + mockStewardSignMessage.mockClear(); + mockStewardSignTypedData.mockClear(); + mockGetStewardAgent.mockClear().mockResolvedValue(null); + mockGetStewardWalletInfo.mockClear().mockResolvedValue(null); + mockIsStewardAvailable.mockClear().mockResolvedValue(true); + + // Privy + mockGetPrivyClient.mockClear().mockReturnValue({ + walletApi: { create: mockPrivyWalletCreate, rpc: mockPrivyWalletRpc }, + }); + mockPrivyWalletCreate.mockClear(); + mockPrivyWalletRpc.mockClear(); + + // DB + mockInsertReturning.mockClear(); + mockInsertValues.mockClear().mockReturnValue({ returning: mockInsertReturning }); + mockDbInsert.mockClear().mockReturnValue({ values: mockInsertValues }); + mockFindFirst.mockClear(); + + // Cache / viem + mockCacheGet.mockClear().mockResolvedValue(null); + mockCacheGetWithSWR + .mockClear() + .mockImplementation( + async (_key: string, _staleTTL: number, revalidate: () => Promise) => + await revalidate(), + ); + mockCacheSet.mockClear().mockResolvedValue(undefined); + mockCacheSetIfNotExists.mockClear().mockResolvedValue(true); + mockCacheIncr.mockClear().mockResolvedValue(0); + mockCacheExpire.mockClear().mockResolvedValue(undefined); + mockCacheGetAndDelete.mockClear().mockResolvedValue(null); + mockCacheDel.mockClear().mockResolvedValue(undefined); + mockCacheDelPattern.mockClear().mockResolvedValue(undefined); + mockCacheMget.mockClear().mockImplementation(async (keys: string[]) => keys.map(() => null)); + mockCacheIsAvailable.mockClear().mockReturnValue(true); + mockVerifyMessage.mockClear(); + + // Default: Privy mode + mockWalletProviderFlags.USE_STEWARD_FOR_NEW_WALLETS = false; + mockWalletProviderFlags.ALLOW_PRIVY_MIGRATION = false; + mockWalletProviderFlags.DISABLE_PRIVY_WALLETS = false; + }); + + // ========================================================================= + // 1. Provisioning routing + // ========================================================================= + + describe("provisionServerWallet — routing", () => { + it("calls Privy and never touches Steward when USE_STEWARD_FOR_NEW_WALLETS=false", async () => { + mockWalletProviderFlags.USE_STEWARD_FOR_NEW_WALLETS = false; + + mockPrivyWalletCreate.mockResolvedValue({ id: "pw_privy1", address: "0xPrivy1" }); + mockInsertReturning.mockResolvedValue([ + { + id: "rec-1", + wallet_provider: "privy", + privy_wallet_id: "pw_privy1", + address: "0xPrivy1", + }, + ]); + + const { provisionServerWallet } = await import("@/lib/services/server-wallets"); + + await provisionServerWallet({ + organizationId: "org1", + userId: "user1", + characterId: "char1", + clientAddress: "0xClient1", + chainType: "evm", + }); + + expect(mockPrivyWalletCreate).toHaveBeenCalledWith({ chainType: "ethereum" }); + expect(mockStewardCreateWallet).not.toHaveBeenCalled(); + expect(mockGetStewardClient).not.toHaveBeenCalled(); + }); + + it("calls Steward and never touches Privy when USE_STEWARD_FOR_NEW_WALLETS=true", async () => { + mockWalletProviderFlags.USE_STEWARD_FOR_NEW_WALLETS = true; + + mockStewardCreateWallet.mockResolvedValue({ + id: "cloud-char2", + walletAddress: "0xSteward2", + }); + mockInsertReturning.mockResolvedValue([ + { + id: "rec-2", + wallet_provider: "steward", + steward_agent_id: "cloud-char2", + steward_tenant_id: "org-org2", + address: "0xSteward2", + privy_wallet_id: null, + }, + ]); + + const { provisionServerWallet } = await import("@/lib/services/server-wallets"); + + await provisionServerWallet({ + organizationId: "org2", + userId: "user2", + characterId: "char2", + clientAddress: "0xClient2", + chainType: "evm", + }); + + expect(mockGetStewardClient).toHaveBeenCalled(); + expect(mockStewardCreateWallet).toHaveBeenCalledWith( + "cloud-char2", + "Agent cloud-char2", + "0xClient2", + ); + expect(mockPrivyWalletCreate).not.toHaveBeenCalled(); + expect(mockGetPrivyClient).not.toHaveBeenCalled(); + }); + + it("uses clientAddress as agent name when characterId is null (Steward mode)", async () => { + mockWalletProviderFlags.USE_STEWARD_FOR_NEW_WALLETS = true; + + mockStewardCreateWallet.mockResolvedValue({ + id: "cloud-0xClient3", + walletAddress: "0xSteward3", + }); + mockInsertReturning.mockResolvedValue([ + { + id: "rec-3", + wallet_provider: "steward", + steward_agent_id: "cloud-0xClient3", + address: "0xSteward3", + privy_wallet_id: null, + }, + ]); + + const { provisionServerWallet } = await import("@/lib/services/server-wallets"); + + await provisionServerWallet({ + organizationId: "org3", + userId: "user3", + characterId: null, + clientAddress: "0xClient3", + chainType: "evm", + }); + + expect(mockStewardCreateWallet).toHaveBeenCalledWith( + "cloud-0xClient3", + "Agent cloud-0xClient3", + "0xClient3", + ); + }); + + it("throws if Steward returns no walletAddress", async () => { + mockWalletProviderFlags.USE_STEWARD_FOR_NEW_WALLETS = true; + + mockStewardCreateWallet.mockResolvedValue({ id: "cloud-charX", walletAddress: null }); + + const { provisionServerWallet } = await import("@/lib/services/server-wallets"); + + await expect( + provisionServerWallet({ + organizationId: "org4", + userId: "user4", + characterId: "charX", + clientAddress: "0xClientX", + chainType: "evm", + }), + ).rejects.toThrow("Steward did not return a wallet address"); + }); + + it("reuses an existing Steward agent when createWallet returns a 409 conflict", async () => { + mockWalletProviderFlags.USE_STEWARD_FOR_NEW_WALLETS = true; + + mockStewardCreateWallet.mockRejectedValue({ + name: "StewardApiError", + status: 409, + message: "Agent already exists", + }); + mockStewardGetAgent.mockResolvedValue({ + id: "cloud-char-conflict", + walletAddress: "0xExistingSteward", + }); + mockInsertReturning.mockResolvedValue([ + { + id: "rec-conflict", + wallet_provider: "steward", + steward_agent_id: "cloud-char-conflict", + address: "0xExistingSteward", + privy_wallet_id: null, + }, + ]); + + const { provisionServerWallet } = await import("@/lib/services/server-wallets"); + + const result = await provisionServerWallet({ + organizationId: "org-conflict", + userId: "user-conflict", + characterId: "char-conflict", + clientAddress: "0xClientConflict", + chainType: "evm", + }); + + expect(mockStewardGetAgent).toHaveBeenCalledWith("cloud-char-conflict"); + expect(result).toEqual( + expect.objectContaining({ + id: "rec-conflict", + steward_agent_id: "cloud-char-conflict", + address: "0xExistingSteward", + }), + ); + }); + + it("blocks new Privy wallet creation when DISABLE_PRIVY_WALLETS=true", async () => { + mockWalletProviderFlags.USE_STEWARD_FOR_NEW_WALLETS = false; + mockWalletProviderFlags.DISABLE_PRIVY_WALLETS = true; + + const { provisionServerWallet } = await import("@/lib/services/server-wallets"); + + await expect( + provisionServerWallet({ + organizationId: "org-disabled", + userId: "user-disabled", + characterId: "char-disabled", + clientAddress: "0xClientDisabled", + chainType: "evm", + }), + ).rejects.toThrow(/Privy wallet creation is disabled/); + + expect(mockPrivyWalletCreate).not.toHaveBeenCalled(); + }); + }); + + // ========================================================================= + // 2. Schema validation — correct fields per provider + // ========================================================================= + + describe("provisionServerWallet — schema validation", () => { + it("Privy wallet insert: privy_wallet_id set, no steward_agent_id / steward_tenant_id", async () => { + mockWalletProviderFlags.USE_STEWARD_FOR_NEW_WALLETS = false; + + mockPrivyWalletCreate.mockResolvedValue({ id: "pw_schema1", address: "0xAddr1" }); + mockInsertReturning.mockResolvedValue([{ id: "rec-s1" }]); + + const { provisionServerWallet } = await import("@/lib/services/server-wallets"); + + await provisionServerWallet({ + organizationId: "org-s1", + userId: "user-s1", + characterId: "char-s1", + clientAddress: "0xClientS1", + chainType: "evm", + }); + + const insertedValues = mockInsertValues.mock.calls[0]?.[0] as Record; + + expect(insertedValues).toBeDefined(); + expect(insertedValues.wallet_provider).toBe("privy"); + expect(insertedValues.privy_wallet_id).toBe("pw_schema1"); + // Steward fields must not be present + expect(insertedValues.steward_agent_id).toBeUndefined(); + expect(insertedValues.steward_tenant_id).toBeUndefined(); + }); + + it("Steward wallet insert: steward_agent_id + steward_tenant_id set, no privy_wallet_id", async () => { + mockWalletProviderFlags.USE_STEWARD_FOR_NEW_WALLETS = true; + + mockStewardCreateWallet.mockResolvedValue({ + id: "cloud-char-s2", + walletAddress: "0xAddrS2", + }); + mockInsertReturning.mockResolvedValue([{ id: "rec-s2" }]); + + const { provisionServerWallet } = await import("@/lib/services/server-wallets"); + + await provisionServerWallet({ + organizationId: "org-s2", + userId: "user-s2", + characterId: "char-s2", + clientAddress: "0xClientS2", + chainType: "evm", + }); + + const insertedValues = mockInsertValues.mock.calls[0]?.[0] as Record; + + expect(insertedValues).toBeDefined(); + expect(insertedValues.wallet_provider).toBe("steward"); + expect(insertedValues.steward_agent_id).toBe("cloud-char-s2"); + expect(typeof insertedValues.steward_tenant_id).toBe("string"); + expect((insertedValues.steward_tenant_id as string).length).toBeGreaterThan(0); + // Privy field must not be present + expect(insertedValues.privy_wallet_id).toBeUndefined(); + }); + + it("Steward wallet insert: steward_tenant_id falls back to org- when env var unset", async () => { + mockWalletProviderFlags.USE_STEWARD_FOR_NEW_WALLETS = true; + delete process.env.STEWARD_TENANT_ID; + + mockStewardCreateWallet.mockResolvedValue({ + id: "cloud-char-s3", + walletAddress: "0xAddrS3", + }); + mockInsertReturning.mockResolvedValue([{ id: "rec-s3" }]); + + const { provisionServerWallet } = await import("@/lib/services/server-wallets"); + + await provisionServerWallet({ + organizationId: "org-s3", + userId: "user-s3", + characterId: "char-s3", + clientAddress: "0xClientS3", + chainType: "evm", + }); + + const insertedValues = mockInsertValues.mock.calls[0]?.[0] as Record; + + expect(insertedValues.steward_tenant_id).toBe("org-org-s3"); + }); + }); + + // ========================================================================= + // 3. RPC routing — wallet_provider drives dispatch + // ========================================================================= + + describe("executeServerWalletRpc — routing by wallet_provider", () => { + it("routes to Privy RPC for a wallet record with wallet_provider='privy'", async () => { + mockVerifyMessage.mockResolvedValue(true); + mockFindFirst.mockResolvedValue({ + id: "rec-rpc-privy", + wallet_provider: "privy", + privy_wallet_id: "pw_rpc1", + steward_agent_id: null, + }); + mockPrivyWalletRpc.mockResolvedValue({ method: "eth_sendTransaction", data: "0xResult" }); + + const { executeServerWalletRpc } = await import("@/lib/services/server-wallets"); + + const payload = rpcPayload("eth_sendTransaction", [{ to: "0xDead" }], "nonce-rpc-privy-1"); + const result = await executeServerWalletRpc({ + clientAddress: "0xClientRpc1" as `0x${string}`, + payload, + signature: "0xSigRpc1" as `0x${string}`, + }); + + expect(mockPrivyWalletRpc).toHaveBeenCalledWith( + expect.objectContaining({ walletId: "pw_rpc1", method: "eth_sendTransaction" }), + ); + expect(mockStewardSignTransaction).not.toHaveBeenCalled(); + expect(result).toEqual({ method: "eth_sendTransaction", data: "0xResult" }); + }); + + it("routes to Steward for a wallet record with wallet_provider='steward'", async () => { + mockVerifyMessage.mockResolvedValue(true); + mockFindFirst.mockResolvedValue({ + id: "rec-rpc-steward", + wallet_provider: "steward", + steward_agent_id: "cloud-char-rpc", + privy_wallet_id: null, + }); + mockStewardSignTransaction.mockResolvedValue({ txHash: "0xTxHash" }); + + const { executeServerWalletRpc } = await import("@/lib/services/server-wallets"); + + const payload = rpcPayload( + "eth_sendTransaction", + [{ to: "0xBeef", value: "0x1", data: "0x", chainId: 8453 }], + "nonce-rpc-steward-1", + ); + const result = await executeServerWalletRpc({ + clientAddress: "0xClientRpc2" as `0x${string}`, + payload, + signature: "0xSigRpc2" as `0x${string}`, + }); + + expect(mockStewardSignTransaction).toHaveBeenCalledWith( + "cloud-char-rpc", + expect.objectContaining({ to: "0xBeef", value: "0x1", data: "0x", chainId: 8453 }), + ); + expect(mockPrivyWalletRpc).not.toHaveBeenCalled(); + expect(result).toEqual({ txHash: "0xTxHash" }); + }); + }); + + // ========================================================================= + // 4. Steward RPC method dispatch + // ========================================================================= + + describe("executeServerWalletRpc — Steward method dispatch", () => { + const stewardWalletRecord = { + id: "rec-steward-rpc", + wallet_provider: "steward", + steward_agent_id: "cloud-agent-dispatch", + privy_wallet_id: null, + }; + + beforeEach(() => { + mockVerifyMessage.mockResolvedValue(true); + mockFindFirst.mockResolvedValue(stewardWalletRecord); + }); + + it("dispatches eth_sendTransaction to steward.signTransaction", async () => { + mockStewardSignTransaction.mockResolvedValue({ txHash: "0xTx1" }); + + const { executeServerWalletRpc } = await import("@/lib/services/server-wallets"); + + const payload = rpcPayload( + "eth_sendTransaction", + [{ to: "0xTo1", value: "0x64", data: "0xdata", chainId: 1 }], + "nonce-dispatch-tx", + ); + const result = await executeServerWalletRpc({ + clientAddress: "0xClientD1" as `0x${string}`, + payload, + signature: "0xSig" as `0x${string}`, + }); + + expect(mockStewardSignTransaction).toHaveBeenCalledWith("cloud-agent-dispatch", { + to: "0xTo1", + value: "0x64", + data: "0xdata", + chainId: 1, + }); + expect(result).toEqual({ txHash: "0xTx1" }); + }); + + it("dispatches eth_sendTransaction without forcing a hardcoded chainId", async () => { + mockStewardSignTransaction.mockResolvedValue({ txHash: "0xTxBase" }); + + const { executeServerWalletRpc } = await import("@/lib/services/server-wallets"); + + const payload = rpcPayload( + "eth_sendTransaction", + [{ to: "0xTo2" }], // no chainId + "nonce-dispatch-tx-base", + ); + await executeServerWalletRpc({ + clientAddress: "0xClientD2" as `0x${string}`, + payload, + signature: "0xSig" as `0x${string}`, + }); + + const tx = mockStewardSignTransaction.mock.calls[0]?.[1] as Record; + expect(tx).toEqual({ + to: "0xTo2", + value: "0", + data: undefined, + }); + expect("chainId" in tx).toBe(false); + }); + + it("dispatches personal_sign to steward.signMessage", async () => { + mockStewardSignMessage.mockResolvedValue({ signature: "0xPersonalSig" }); + + const { executeServerWalletRpc } = await import("@/lib/services/server-wallets"); + + const payload = rpcPayload("personal_sign", ["hello world"], "nonce-dispatch-sign"); + const result = await executeServerWalletRpc({ + clientAddress: "0xClientD3" as `0x${string}`, + payload, + signature: "0xSig" as `0x${string}`, + }); + + expect(mockStewardSignMessage).toHaveBeenCalledWith("cloud-agent-dispatch", "hello world"); + expect(result).toEqual({ signature: "0xPersonalSig" }); + }); + + it("dispatches eth_signTypedData_v4 to steward.signTypedData", async () => { + mockStewardSignTypedData.mockResolvedValue({ signature: "0xTypedSig" }); + + const { executeServerWalletRpc } = await import("@/lib/services/server-wallets"); + + const typedData = JSON.stringify({ + domain: { name: "TestDomain", chainId: 1 }, + types: { Mail: [{ name: "contents", type: "string" }] }, + primaryType: "Mail", + message: { contents: "Hello" }, + }); + const payload = rpcPayload( + "eth_signTypedData_v4", + ["0xSignerAddr", typedData], + "nonce-dispatch-typed", + ); + const result = await executeServerWalletRpc({ + clientAddress: "0xClientD4" as `0x${string}`, + payload, + signature: "0xSig" as `0x${string}`, + }); + + expect(mockStewardSignTypedData).toHaveBeenCalledWith("cloud-agent-dispatch", { + domain: { name: "TestDomain", chainId: 1 }, + types: { Mail: [{ name: "contents", type: "string" }] }, + primaryType: "Mail", + value: { contents: "Hello" }, + }); + expect(result).toEqual({ signature: "0xTypedSig" }); + }); + + it("accepts eth_signTypedData_v4 payloads that are already parsed objects", async () => { + mockStewardSignTypedData.mockResolvedValue({ signature: "0xTypedSigObject" }); + + const { executeServerWalletRpc } = await import("@/lib/services/server-wallets"); + + const typedData = { + domain: { name: "TestDomain", chainId: 8453 }, + types: { Permit: [{ name: "spender", type: "address" }] }, + primaryType: "Permit", + message: { spender: "0xSpender" }, + }; + const payload = rpcPayload( + "eth_signTypedData_v4", + ["0xSignerAddr", typedData], + "nonce-dispatch-typed-object", + ); + const result = await executeServerWalletRpc({ + clientAddress: "0xClientD4Object" as `0x${string}`, + payload, + signature: "0xSig" as `0x${string}`, + }); + + expect(mockStewardSignTypedData).toHaveBeenCalledWith("cloud-agent-dispatch", { + domain: { name: "TestDomain", chainId: 8453 }, + types: { Permit: [{ name: "spender", type: "address" }] }, + primaryType: "Permit", + value: { spender: "0xSpender" }, + }); + expect(result).toEqual({ signature: "0xTypedSigObject" }); + }); + + it("throws for unsupported RPC methods on Steward", async () => { + const { executeServerWalletRpc } = await import("@/lib/services/server-wallets"); + + const payload = rpcPayload("eth_getBalance", ["0xAddr", "latest"], "nonce-unsupported"); + await expect( + executeServerWalletRpc({ + clientAddress: "0xClientD5" as `0x${string}`, + payload, + signature: "0xSig" as `0x${string}`, + }), + ).rejects.toThrow(/not supported via Steward/); + }); + + it("throws if steward wallet record has no steward_agent_id", async () => { + mockFindFirst.mockResolvedValue({ + id: "rec-broken", + wallet_provider: "steward", + steward_agent_id: null, + privy_wallet_id: null, + }); + + const { executeServerWalletRpc } = await import("@/lib/services/server-wallets"); + + const payload = rpcPayload("personal_sign", ["msg"], "nonce-no-agent-id"); + await expect( + executeServerWalletRpc({ + clientAddress: "0xClientBroken" as `0x${string}`, + payload, + signature: "0xSig" as `0x${string}`, + }), + ).rejects.toThrow(/steward_agent_id/); + }); + }); + + // ========================================================================= + // 5. Shared executeServerWalletRpc guards (signature / nonce / not-found) + // ========================================================================= + + describe("executeServerWalletRpc — guards", () => { + it("throws InvalidRpcSignatureError when signature is invalid", async () => { + mockVerifyMessage.mockResolvedValue(false); + + const { executeServerWalletRpc } = await import("@/lib/services/server-wallets"); + + await expect( + executeServerWalletRpc({ + clientAddress: "0xClientG1", + payload: rpcPayload("eth_sendTransaction", [], "nonce-guard-sig"), + signature: "0xBadSig" as `0x${string}`, + }), + ).rejects.toThrow("Invalid RPC signature"); + }); + + it("throws ServerWalletNotFoundError when no wallet record exists", async () => { + mockVerifyMessage.mockResolvedValue(true); + mockFindFirst.mockResolvedValue(null); + + const { executeServerWalletRpc } = await import("@/lib/services/server-wallets"); + + await expect( + executeServerWalletRpc({ + clientAddress: "0xClientG2", + payload: rpcPayload("eth_sendTransaction", [], "nonce-guard-notfound"), + signature: "0xSig" as `0x${string}`, + }), + ).rejects.toThrow("Server wallet not found"); + }); + + it("throws RpcReplayError when nonce has already been used", async () => { + mockVerifyMessage.mockResolvedValue(true); + mockCacheSetIfNotExists.mockResolvedValue(false); // nonce already set + + const { executeServerWalletRpc } = await import("@/lib/services/server-wallets"); + + await expect( + executeServerWalletRpc({ + clientAddress: "0xClientG3", + payload: rpcPayload("eth_sendTransaction", [], "nonce-guard-replay"), + signature: "0xSig" as `0x${string}`, + }), + ).rejects.toThrow("RPC nonce already used"); + }); + + it("throws RpcRequestExpiredError when timestamp is too old", async () => { + const { executeServerWalletRpc } = await import("@/lib/services/server-wallets"); + + const stalePayload = { + method: "eth_sendTransaction", + params: [], + timestamp: Date.now() - 6 * 60 * 1000, // 6 minutes ago + nonce: "nonce-guard-expired", + }; + + await expect( + executeServerWalletRpc({ + clientAddress: "0xClientG4", + payload: stalePayload, + signature: "0xSig" as `0x${string}`, + }), + ).rejects.toThrow("RPC request expired"); + }); + + it("throws if privy wallet record has no privy_wallet_id", async () => { + mockVerifyMessage.mockResolvedValue(true); + mockFindFirst.mockResolvedValue({ + id: "rec-privy-broken", + wallet_provider: "privy", + privy_wallet_id: null, + steward_agent_id: null, + }); + + const { executeServerWalletRpc } = await import("@/lib/services/server-wallets"); + + await expect( + executeServerWalletRpc({ + clientAddress: "0xClientG5", + payload: rpcPayload("eth_sendTransaction", [], "nonce-privy-broken"), + signature: "0xSig" as `0x${string}`, + }), + ).rejects.toThrow(/privy_wallet_id/); + }); + }); +}); diff --git a/packages/tests/unit/v1-milaidy-provision-route.test.ts b/packages/tests/unit/v1-milaidy-provision-route.test.ts index 3edaba686..28e84c4e5 100644 --- a/packages/tests/unit/v1-milaidy-provision-route.test.ts +++ b/packages/tests/unit/v1-milaidy-provision-route.test.ts @@ -100,11 +100,11 @@ describe("POST /api/v1/milaidy/agents/[agentId]/provision", () => { ); expect(response.status).toBe(500); - expect(await response.json()).toEqual({ - success: false, - error: "Provisioning failed", - }); - expect(mockLoggerError).toHaveBeenCalled(); + const body = await response.json(); + expect(body.success).toBe(false); + // Error message is sanitized — must not leak internal details + expect(typeof body.error).toBe("string"); + expect(body.error).not.toContain("password authentication"); }); test("sanitizes async enqueue 500 errors", async () => { diff --git a/packages/ui/src/components/agents/agent-card.tsx b/packages/ui/src/components/agents/agent-card.tsx index 1822fc5f4..ba695b78f 100644 --- a/packages/ui/src/components/agents/agent-card.tsx +++ b/packages/ui/src/components/agents/agent-card.tsx @@ -304,11 +304,11 @@ export function AgentCard({
{agent.name}
@@ -493,7 +493,7 @@ export function AgentCard({ {agent.name} {/* Gradient overlay */} diff --git a/packages/ui/src/components/billing/billing-page-client.tsx b/packages/ui/src/components/billing/billing-page-client.tsx index 6fd53dc43..c9c9c4438 100644 --- a/packages/ui/src/components/billing/billing-page-client.tsx +++ b/packages/ui/src/components/billing/billing-page-client.tsx @@ -1,116 +1,263 @@ /** - * Billing page client component for purchasing credit packs. - * Displays available credit packs and handles Stripe checkout session creation. + * Billing page client component for adding funds via card or crypto. * * @param props - Billing page configuration - * @param props.creditPacks - Array of available credit packs * @param props.currentCredits - User's current credit balance */ "use client"; -import { useEffect, useState } from "react"; +import { BrandCard, CornerBrackets, Input } from "@elizaos/cloud-ui"; +import { AlertCircle, CheckCircle, CreditCard, Loader2, Wallet } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; +import type { CryptoStatusResponse } from "@/app/api/crypto/status/route"; import { trackEvent } from "@/lib/analytics/posthog"; -import { CreditPackCard } from "./credit-pack-card"; - -interface CreditPack { - id: string; - name: string; - description: string | null; - credits: number; - price_cents: number; - stripe_price_id: string; - is_active: boolean; - sort_order: number; -} interface BillingPageClientProps { - creditPacks: CreditPack[]; currentCredits: number; } -export function BillingPageClient({ creditPacks, currentCredits }: BillingPageClientProps) { - const [loading, setLoading] = useState(null); +const AMOUNT_LIMITS = { + MIN: 1, + MAX: 10000, +} as const; + +type PaymentMethod = "card" | "crypto"; + +export function BillingPageClient({ currentCredits }: BillingPageClientProps) { + const [purchaseAmount, setPurchaseAmount] = useState(""); + const [isProcessingCheckout, setIsProcessingCheckout] = useState(false); + const [paymentMethod, setPaymentMethod] = useState("card"); + const [cryptoStatus, setCryptoStatus] = useState(null); + const [balance, _setBalance] = useState(currentCredits); + + const fetchCryptoStatus = useCallback(async () => { + try { + const response = await fetch("/api/crypto/status"); + if (response.ok) { + const data: CryptoStatusResponse = await response.json(); + setCryptoStatus(data); + } + } catch { + // crypto status unavailable, card-only mode + } + }, []); - // Track billing page viewed - only on initial mount useEffect(() => { - trackEvent("billing_page_viewed", { - current_credits: currentCredits, - available_packs: creditPacks.length, + trackEvent("billing_page_viewed", { current_credits: currentCredits, available_packs: 0 }); + fetchCryptoStatus(); + }, [currentCredits, fetchCryptoStatus]); + + const handleAddFunds = async () => { + const amount = parseFloat(purchaseAmount); + + trackEvent("checkout_attempted", { + payment_method: paymentMethod === "card" ? "stripe" : "crypto", + amount: Number.isNaN(amount) ? undefined : amount, + organization_id: "", }); - }, [creditPacks.length, currentCredits]); - - const handlePurchase = async (creditPackId: string) => { - setLoading(creditPackId); - - // Find the pack being purchased for tracking - const pack = creditPacks.find((p) => p.id === creditPackId); - if (pack) { - trackEvent("credits_purchase_started", { - pack_id: creditPackId, - pack_name: pack.name, - credits: pack.credits, - price_cents: pack.price_cents, - }); + + if (isNaN(amount) || amount < AMOUNT_LIMITS.MIN) { + toast.error(`Minimum amount is $${AMOUNT_LIMITS.MIN}`); + return; + } + if (amount > AMOUNT_LIMITS.MAX) { + toast.error(`Maximum amount is $${AMOUNT_LIMITS.MAX}`); + return; } - try { - const response = await fetch("/api/stripe/create-checkout-session", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ creditPackId }), - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.error || "Failed to create checkout session"); - } + setIsProcessingCheckout(true); + + if (paymentMethod === "crypto") { + try { + const response = await fetch("/api/crypto/payments", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ amount }), + }); + + if (!response.ok) { + const errorData = await response.json(); + toast.error(errorData.error || "Failed to create payment"); + setIsProcessingCheckout(false); + return; + } - const { url } = await response.json(); + const data = await response.json(); - if (!url) { - throw new Error("No checkout URL returned"); + if (!data.payLink) { + toast.error("No payment link returned"); + setIsProcessingCheckout(false); + return; + } + + toast.success("Redirecting to payment page..."); + window.location.href = data.payLink; + } catch { + toast.error("Failed to create crypto payment"); + setIsProcessingCheckout(false); } + return; + } + + // Card / Stripe + const response = await fetch("/api/stripe/create-checkout-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ amount, returnUrl: "billing" }), + }); - window.location.href = url; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Purchase failed"; - toast.error(errorMessage); - } finally { - setLoading(null); + if (!response.ok) { + const errorData = await response.json(); + toast.error(errorData.error || "Failed to create checkout session"); + setIsProcessingCheckout(false); + return; } + + const { url } = await response.json(); + + if (!url) { + toast.error("No checkout URL returned"); + setIsProcessingCheckout(false); + return; + } + + window.location.href = url; }; - // Determine which pack is popular (middle one) - const middleIndex = Math.floor(creditPacks.length / 2); + const amountValue = parseFloat(purchaseAmount) || 0; + const isValidAmount = amountValue >= AMOUNT_LIMITS.MIN && amountValue <= AMOUNT_LIMITS.MAX; return ( -
-
+ + + +
+ {/* Header */}
-

Balance

-
${Number(currentCredits).toFixed(2)}
+
+
+

Add Funds

+
+
+ Balance + ${balance.toFixed(2)} +
-
-
- {creditPacks.map((pack, index) => ( - - ))} + {/* Payment Method Toggle (only shown when crypto is enabled) */} + {cryptoStatus?.enabled && ( +
+ + +
+ )} + + {/* Amount Input + Button */} +
+
+ + $ + + setPurchaseAmount(e.target.value)} + className="pl-7 backdrop-blur-sm bg-[rgba(29,29,29,0.3)] border border-[rgba(255,255,255,0.15)] text-[#e1e1e1] h-11 font-mono" + placeholder="0.00" + disabled={isProcessingCheckout} + /> +
+ + +
+ + {/* Validation feedback */} + {purchaseAmount && !isValidAmount && ( +
+ + + {amountValue < AMOUNT_LIMITS.MIN + ? `Minimum amount is $${AMOUNT_LIMITS.MIN}` + : `Maximum amount is $${AMOUNT_LIMITS.MAX}`} + +
+ )} + + {isValidAmount && purchaseAmount && ( +
+ + + ${amountValue.toFixed(2)} will be added to your balance + +
+ )}
-
+
); } diff --git a/packages/ui/src/components/billing/billing-page-wrapper.tsx b/packages/ui/src/components/billing/billing-page-wrapper.tsx index 5d966c54e..83edfac74 100644 --- a/packages/ui/src/components/billing/billing-page-wrapper.tsx +++ b/packages/ui/src/components/billing/billing-page-wrapper.tsx @@ -1,86 +1,27 @@ -/** - * Billing page wrapper component setting page header and displaying payment cancellation alerts. - * Wraps billing page client with page context and alert handling. - * - * @param props - Billing page wrapper configuration - * @param props.creditPacks - Array of available credit packs - * @param props.currentCredits - Current credit balance - * @param props.canceled - Optional cancellation message from Stripe - */ - "use client"; -import { - Alert, - AlertDescription, - AlertTitle, - BrandCard, - useSetPageHeader, -} from "@elizaos/cloud-ui"; -import { Info } from "lucide-react"; -import type { CreditPack as DBCreditPack } from "@/lib/types"; -import { BillingPageClient } from "./billing-page-client"; -import { MiladyPricingInfo } from "./milady-pricing-info"; +import { useSetPageHeader } from "@elizaos/cloud-ui"; +import type { UserWithOrganization } from "@/lib/types"; +import { BillingTab } from "@/packages/ui/src/components/settings/tabs/billing-tab"; interface BillingPageWrapperProps { - creditPacks: DBCreditPack[]; - currentCredits: number; + user: UserWithOrganization; canceled?: string; - runningAgents?: number; - idleAgents?: number; } -export function BillingPageWrapper({ - creditPacks, - currentCredits, - canceled, - runningAgents = 0, - idleAgents = 0, -}: BillingPageWrapperProps) { +export function BillingPageWrapper({ user, canceled }: BillingPageWrapperProps) { useSetPageHeader({ - title: "Billing & Balance", - description: "Add funds to power your AI generations", + title: "Billing", }); return ( -
+
{canceled && ( - - - Payment Canceled - - Your payment was canceled. No charges were made. - - - )} - - -
- -
-

How Billing Works

-

- You are charged for all AI operations including text generation, image creation, and - video rendering. Add funds in bulk to get better rates. Your balance never expires and - is shared across your organization. -

-
+
+ Payment canceled. No charges were made.
- - - - - ({ - ...p, - credits: Number(p.credits), - }))} - currentCredits={currentCredits} - /> + )} +
); } diff --git a/packages/ui/src/components/billing/milady-pricing-info.tsx b/packages/ui/src/components/billing/milady-pricing-info.tsx index 96da3a02e..2f103af21 100644 --- a/packages/ui/src/components/billing/milady-pricing-info.tsx +++ b/packages/ui/src/components/billing/milady-pricing-info.tsx @@ -110,10 +110,10 @@ export function MiladyPricingInfo({
-

- Min. deposit {formatUSD(MILADY_PRICING.MINIMUM_DEPOSIT)} · Credits never expire ·{" "} - Auto-suspend at {formatUSD(MILADY_PRICING.LOW_CREDIT_WARNING)} balance ·{" "} - {MILADY_PRICING.GRACE_PERIOD_HOURS}h grace period +

+ Min. {formatUSD(MILADY_PRICING.MINIMUM_DEPOSIT)} · Never expires · Suspends at{" "} + {formatUSD(MILADY_PRICING.LOW_CREDIT_WARNING)} · {MILADY_PRICING.GRACE_PERIOD_HOURS}h + grace

diff --git a/packages/ui/src/components/chat/eliza-avatar.tsx b/packages/ui/src/components/chat/eliza-avatar.tsx index 0d69a1a98..6fc24a62e 100644 --- a/packages/ui/src/components/chat/eliza-avatar.tsx +++ b/packages/ui/src/components/chat/eliza-avatar.tsx @@ -41,7 +41,7 @@ export const ElizaAvatar = memo(function ElizaAvatar({ animate = false, priority = false, }: ElizaAvatarProps) { - const resolvedAvatarUrl = ensureAvatarUrl(avatarUrl); + const resolvedAvatarUrl = ensureAvatarUrl(avatarUrl, name); return (
diff --git a/packages/ui/src/components/containers/agent-actions.tsx b/packages/ui/src/components/containers/agent-actions.tsx index 5bb856a64..7f12ed24a 100644 --- a/packages/ui/src/components/containers/agent-actions.tsx +++ b/packages/ui/src/components/containers/agent-actions.tsx @@ -15,10 +15,9 @@ import { useJobPoller } from "@/lib/hooks/use-job-poller"; interface MiladyAgentActionsProps { agentId: string; status: string; - webUiUrl: string | null; } -export function MiladyAgentActions({ agentId, status, webUiUrl }: MiladyAgentActionsProps) { +export function MiladyAgentActions({ agentId, status }: MiladyAgentActionsProps) { const router = useRouter(); const [loading, setLoading] = useState(null); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); @@ -126,7 +125,7 @@ export function MiladyAgentActions({ agentId, status, webUiUrl }: MiladyAgentAct
- {isRunning && webUiUrl && ( + {isRunning && ( ) : ( - {hasError ? "Close" : "Close — continues in background"} + Close )}
@@ -414,7 +409,7 @@ export function CreateMiladySandboxDialog({ ) : ( setOpen(true)} disabled={busy}> - New Sandbox + New Agent )} @@ -429,13 +424,8 @@ export function CreateMiladySandboxDialog({ - {isProvisioningPhase ? "Launching Agent" : "Create Sandbox"} + {isProvisioningPhase ? "Launching Agent" : "New Agent"} - {!isProvisioningPhase && ( - - Create an agent sandbox and optionally start provisioning right away. - - )} {isProvisioningPhase ? ( @@ -476,7 +466,7 @@ export function CreateMiladySandboxDialog({ {/* Flavor selector */}