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..c22fac959 --- /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 ("* /5 * * * *" in vercel.json) + * 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; + stuckSinceMinutes: 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. + stuckSinceMinutes: STUCK_THRESHOLD_MINUTES, // minimum — actual may be longer + })); + + 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/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/cron/process-provisioning-jobs/route.ts b/app/api/v1/cron/process-provisioning-jobs/route.ts index b1aab752b..b74b6e26f 100644 --- a/app/api/v1/cron/process-provisioning-jobs/route.ts +++ b/app/api/v1/cron/process-provisioning-jobs/route.ts @@ -1,115 +1,12 @@ -import { timingSafeEqual } from "crypto"; -import { NextRequest, NextResponse } from "next/server"; -import { provisioningJobService } from "@/lib/services/provisioning-jobs"; -import { logger } from "@/lib/utils/logger"; +import { NextResponse } from "next/server"; export const dynamic = "force-dynamic"; -export const maxDuration = 120; // Provisioning can take up to ~90s per job - -function verifyCronSecret(request: NextRequest): boolean { - const authHeader = request.headers.get("authorization"); - const cronSecret = process.env.CRON_SECRET; - - if (!cronSecret) { - logger.error("[Provisioning Jobs] CRON_SECRET not configured - rejecting request for security"); - return false; - } - - const providedSecret = authHeader?.replace("Bearer ", "") || ""; - const providedBuffer = Buffer.from(providedSecret, "utf8"); - const secretBuffer = Buffer.from(cronSecret, "utf8"); - - // Reject immediately on length mismatch — padding to max-length would - // make timingSafeEqual always compare equal-length buffers but the - // zero-padded tail leaks nothing useful; however, strict length-equality - // is the canonical safe pattern and avoids any ambiguity. - if (providedBuffer.length !== secretBuffer.length) { - return false; - } - - return timingSafeEqual(providedBuffer, secretBuffer); -} - -/** - * Process Provisioning Jobs Cron Handler - * - * Claims and executes pending provisioning jobs from the `jobs` table. - * Uses FOR UPDATE SKIP LOCKED pattern (via JobsRepository) to prevent - * double-processing when multiple cron invocations overlap. - * - * Schedule: Every minute (matches deployment-monitor) - * Batch size: 5 jobs per invocation - * - * Also recovers stale jobs (stuck in_progress > 5 minutes) and retries - * them with exponential backoff. - */ -async function handleProcessProvisioningJobs(request: NextRequest) { - try { - if (!process.env.CRON_SECRET) { - return NextResponse.json( - { - success: false, - error: "Server configuration error: CRON_SECRET not set", - }, - { status: 500 }, - ); - } - - if (!verifyCronSecret(request)) { - logger.warn("[Provisioning Jobs] Unauthorized request", { - ip: request.headers.get("x-forwarded-for"), - timestamp: new Date().toISOString(), - }); - return NextResponse.json({ success: false, error: "Unauthorized" }, { status: 401 }); - } - - logger.info("[Provisioning Jobs] Starting job processing cycle"); - - const result = await provisioningJobService.processPendingJobs(5); - - if (result.claimed > 0) { - logger.info("[Provisioning Jobs] Processing complete", { - claimed: result.claimed, - succeeded: result.succeeded, - failed: result.failed, - }); - } - - return NextResponse.json({ - success: true, - data: { - ...result, - timestamp: new Date().toISOString(), - }, - }); - } catch (error) { - logger.error( - "[Provisioning Jobs] Failed:", - error instanceof Error ? error.message : String(error), - ); - - return NextResponse.json( - { - success: false, - error: error instanceof Error ? error.message : "Provisioning job processing failed", - }, - { status: 500 }, - ); - } -} - -/** - * GET /api/v1/cron/process-provisioning-jobs - * Protected by CRON_SECRET. - */ -export async function GET(request: NextRequest) { - return handleProcessProvisioningJobs(request); -} /** - * POST /api/v1/cron/process-provisioning-jobs - * Protected by CRON_SECRET (for manual testing). + * DISABLED — Provisioning is handled exclusively by the standalone VPS worker. + * This route is kept as a no-op to prevent 404s from any lingering cron invocations. + * The VPS worker polls the jobs table directly and has SSH access to Docker nodes. */ -export async function POST(request: NextRequest) { - return handleProcessProvisioningJobs(request); +export async function POST() { + return NextResponse.json({ success: true, message: "Provisioning handled by VPS worker", skipped: true }); } 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]/provision/route.ts b/app/api/v1/milady/agents/[agentId]/provision/route.ts index fd2cc6bf1..4e56a2a86 100644 --- a/app/api/v1/milady/agents/[agentId]/provision/route.ts +++ b/app/api/v1/milady/agents/[agentId]/provision/route.ts @@ -66,7 +66,10 @@ export async function POST( try { const { user } = await requireAuthOrApiKeyWithOrg(request); const { agentId } = await params; - const sync = request.nextUrl.searchParams.get("sync") === "true"; + // Always use async job queue — sync provisioning is disabled in production + // because the VPS worker handles SSH/Docker operations that can't run in + // serverless functions. The sync path remains in code for local dev only. + const sync = false; logger.info("[milady-api] Provision requested", { agentId, 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/milady/agents/[id]/page.tsx b/app/dashboard/milady/agents/[id]/page.tsx index 7201b9f6e..9697a4166 100644 --- a/app/dashboard/milady/agents/[id]/page.tsx +++ b/app/dashboard/milady/agents/[id]/page.tsx @@ -21,6 +21,7 @@ import { adminService } from "@/lib/services/admin"; import { miladySandboxService } from "@/lib/services/milady-sandbox"; import { MiladyAgentActions } from "@/packages/ui/src/components/containers/agent-actions"; import { DockerLogsViewer } from "@/packages/ui/src/components/containers/docker-logs-viewer"; +import { MiladyAgentTabs } from "@/packages/ui/src/components/containers/milady-agent-tabs"; import { MiladyBackupsPanel } from "@/packages/ui/src/components/containers/milady-backups-panel"; import { MiladyConnectButton } from "@/packages/ui/src/components/containers/milady-connect-button"; import { MiladyLogsViewer } from "@/packages/ui/src/components/containers/milady-logs-viewer"; @@ -221,143 +222,148 @@ export default async function MiladyAgentDetailPage({ params }: PageProps) { - {/* ── 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. -

; + +interface MiladyWebUiUrlOptions { + baseDomain?: string | null; + allowExampleFallback?: boolean; + path?: string; +} + +function normalizeAgentBaseDomain(baseDomain?: string | null): string | null { + if (!baseDomain) { + return null; + } + + const normalizedDomain = baseDomain + .trim() + .replace(/^https?:\/\//, "") + .replace(/\/.*$/, "") + .replace(/\.+$/, ""); + + return normalizedDomain || null; +} + +function applyPath(baseUrl: string, path = "/"): string { + if (!path || path === "/") { + return baseUrl; + } + + const url = new URL(baseUrl); + const normalizedPath = new URL(path, "https://milady.local"); + + url.pathname = normalizedPath.pathname; + url.search = normalizedPath.search; + url.hash = normalizedPath.hash; + + return url.toString(); +} + +export function getMiladyAgentPublicWebUiUrl( + sandbox: Pick, + options: MiladyWebUiUrlOptions = {}, +): string | null { + if (!sandbox.headscale_ip) { + return null; + } + + const normalizedDomain = normalizeAgentBaseDomain( + options.baseDomain ?? + process.env.ELIZA_CLOUD_AGENT_BASE_DOMAIN ?? + (options.allowExampleFallback ? DEFAULT_AGENT_BASE_DOMAIN : null), + ); + if (!normalizedDomain) { + return null; + } + + return applyPath(`https://${sandbox.id}.${normalizedDomain}`, options.path); +} + +export function getMiladyAgentDirectWebUiUrl( + sandbox: MiladyWebUiTarget, + options: Pick = {}, +): string | null { + if (!sandbox.headscale_ip) { + return null; + } + + const port = sandbox.web_ui_port ?? sandbox.bridge_port; + if (!port) { + return null; + } + + return applyPath(`http://${sandbox.headscale_ip}:${port}`, options.path); +} + +export function getPreferredMiladyAgentWebUiUrl( + sandbox: MiladyWebUiTarget, + options: MiladyWebUiUrlOptions = {}, +): string | null { + return ( + getMiladyAgentPublicWebUiUrl(sandbox, options) ?? getMiladyAgentDirectWebUiUrl(sandbox, options) + ); +} diff --git a/packages/db/repositories/milady-sandboxes.ts b/packages/db/repositories/milady-sandboxes.ts index 8a976c4a4..544fdffcd 100644 --- a/packages/db/repositories/milady-sandboxes.ts +++ b/packages/db/repositories/milady-sandboxes.ts @@ -82,7 +82,10 @@ export class MiladySandboxesRepository { } async findRunningSandbox(id: string, orgId: string): Promise { - 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/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/milady-sandbox.ts b/packages/lib/services/milady-sandbox.ts index c1574f233..2c99f3d8f 100644 --- a/packages/lib/services/milady-sandbox.ts +++ b/packages/lib/services/milady-sandbox.ts @@ -32,6 +32,10 @@ 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 ?? "snowy-waterfall-29926749"; // env var required in prod + export interface CreateAgentParams { organizationId: string; userId: string; @@ -206,10 +210,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 { @@ -603,6 +608,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; @@ -941,11 +1084,11 @@ export class MiladySandboxService { 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 branchName = `milady-${sanitizeProjectNameSegment(rec.agent_name ?? "agent")}-${rec.id.substring(0, 8)}`; + const result = await neon.createBranch(NEON_PARENT_PROJECT_ID, branchName); const updated = await miladySandboxesRepository.update(rec.id, { - neon_project_id: result.projectId, + neon_project_id: NEON_PARENT_PROJECT_ID, neon_branch_id: result.branchId, database_uri: result.connectionUri, database_status: "ready", @@ -953,12 +1096,16 @@ export class MiladySandboxService { }); if (!updated) { - logger.error("[milady-sandbox] DB update failed after Neon creation, cleaning orphan", { - projectId: result.projectId, - }); - await neon.deleteProject(result.projectId).catch((e) => { - logger.error("[milady-sandbox] Orphan Neon project cleanup failed", { - projectId: result.projectId, + logger.error( + "[milady-sandbox] DB update failed after Neon branch creation, cleaning orphan", + { + projectId: NEON_PARENT_PROJECT_ID, + branchId: result.branchId, + }, + ); + await neon.deleteBranch(NEON_PARENT_PROJECT_ID, result.branchId).catch((e) => { + logger.error("[milady-sandbox] Orphan Neon branch cleanup failed", { + branchId: result.branchId, error: e instanceof Error ? e.message : String(e), }); }); @@ -971,13 +1118,21 @@ export class MiladySandboxService { return { success: true, connectionUri: result.connectionUri }; } - private async cleanupNeon(projectId: string) { + private async cleanupNeon(projectId: string, branchId?: string | null) { + const neon = getNeonClient(); try { - await getNeonClient().deleteProject(projectId); + if (projectId === NEON_PARENT_PROJECT_ID && branchId) { + // New branch-based: delete the branch, not the project + await neon.deleteBranch(NEON_PARENT_PROJECT_ID, branchId); + } else { + // 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/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/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/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-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/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/create-milady-sandbox-dialog.tsx b/packages/ui/src/components/containers/create-milady-sandbox-dialog.tsx index c34edfca8..753b0194e 100644 --- a/packages/ui/src/components/containers/create-milady-sandbox-dialog.tsx +++ b/packages/ui/src/components/containers/create-milady-sandbox-dialog.tsx @@ -4,7 +4,6 @@ import { BrandButton, Dialog, DialogContent, - DialogDescription, DialogFooter, DialogHeader, DialogTitle, @@ -22,11 +21,7 @@ import { type ReactNode, useEffect, useState } from "react"; import { toast } from "sonner"; import { AGENT_FLAVORS, getDefaultFlavor, getFlavorById } from "@/lib/constants/agent-flavors"; import { MILADY_PRICING } from "@/lib/constants/milady-pricing"; -import { - formatHourlyRate, - formatMonthlyEstimate, - formatUSD, -} from "@/lib/constants/milady-pricing-display"; +import { formatHourlyRate, formatUSD } from "@/lib/constants/milady-pricing-display"; import { openWebUIWithPairing } from "@/lib/hooks/open-web-ui"; import { type SandboxStatus, useSandboxStatusPoll } from "@/lib/hooks/use-sandbox-status-poll"; @@ -218,7 +213,7 @@ function ProvisioningProgress({ ) : ( - {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 */}