diff --git a/README.md b/README.md index 54f3487d..ac74618f 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,15 @@ CLI-first banking for agents starts with the `zero` CLI. ```bash curl -fsSL https://zerofinance.ai/install | bash -# Authenticate +# Authenticate (agent-native API flow) +zero auth agentlogin \ + --email finance-agent@acme.com \ + --company-name "Acme Inc" \ + --admin-token $ZERO_FINANCE_ADMIN_TOKEN + +# Note: defaults to creating one Ethereum wallet when wallets are not specified + +# Or browser login for humans zero auth connect # Or: zero auth login --api-key sk_live_xxx diff --git a/packages/cli/README.md b/packages/cli/README.md index 2e40b93f..9f100018 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -19,7 +19,15 @@ The CLI binary is `zero` (alias: `zero-bank`). ## Quick start ```bash -# Authenticate +# Authenticate (agent-native, API-only) +zero auth agentlogin \ + --email finance-agent@acme.com \ + --company-name "Acme Inc" \ + --admin-token $ZERO_FINANCE_ADMIN_TOKEN + +# Note: defaults to creating one Ethereum wallet when wallets are not specified + +# Or browser login for humans zero auth connect # Or: zero auth login --api-key sk_live_xxx @@ -38,6 +46,7 @@ zero invoices send --invoice-id inv_xxx ## Common commands +- `zero auth agentlogin` — Privy + API key provisioning over API - `zero auth connect` — browser-based login - `zero auth login` — store API key manually - `zero balance` — spendable, earning, and idle balances diff --git a/packages/cli/src/client.ts b/packages/cli/src/client.ts index b358f0fb..deaf4995 100644 --- a/packages/cli/src/client.ts +++ b/packages/cli/src/client.ts @@ -89,7 +89,7 @@ export async function apiRequest( if (!apiKey && !options.adminToken) { throw new Error( - 'Missing API key. Run `zero auth connect` or `zero auth login --api-key `', + 'Missing API key. Run `zero auth agentlogin --email ` or `zero auth login --api-key `', ); } diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index 4e1acb62..2cdfebd7 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -51,7 +51,7 @@ export async function requireConfig() { const config = await loadConfig(); if (!config.apiKey) { throw new Error( - 'Missing API key. Run `zero auth connect` or `zero auth login --api-key `', + 'Missing API key. Run `zero auth agentlogin --email ` or `zero auth login --api-key `', ); } return { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index a42087bb..3497b4a1 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -39,6 +39,7 @@ function enableDebug(debug?: boolean) { const execFileAsync = promisify(execFile); const DEFAULT_BASE_URL = 'https://www.0.finance'; const CONNECT_TIMEOUT_MS = 120_000; +const AGENT_LOGIN_DEFAULT_KEY_NAME = 'Agent API Login'; function createStateToken() { return randomBytes(16).toString('hex'); @@ -152,6 +153,12 @@ async function waitForToken( } async function promptForApiKey() { + if (!stdinStream.isTTY) { + throw new Error( + 'Cannot prompt for API key in non-interactive mode. Use `zero auth login --api-key ` or `zero auth agentlogin --email `.', + ); + } + const prompt = readline.createInterface({ input: stdinStream, output: stdoutStream, @@ -209,6 +216,12 @@ async function runAuthConnect(options: { } if (!apiKey) { + if (!stdinStream.isTTY) { + throw new Error( + 'Browser callback timed out in non-interactive mode. Use `zero auth agentlogin --email --admin-token ` or provide an API key with `zero auth login --api-key `.', + ); + } + console.log('If the browser flow did not complete, paste your API key.'); apiKey = await promptForApiKey(); } @@ -233,6 +246,145 @@ function resolveAdminToken(token?: string) { return token || process.env.ZERO_FINANCE_ADMIN_TOKEN || ''; } +type AgentLoginOptions = { + baseUrl?: string; + email?: string; + phone?: string; + privyUserId?: string; + workspaceName?: string; + companyName?: string; + beneficiaryType?: 'business' | 'individual'; + firstName?: string; + lastName?: string; + apiKeyName?: string; + apiKeyExpiresAt?: string; + createDirectSigner?: boolean; + starterAccounts?: boolean; + walletsJson?: string; + adminToken?: string; +}; + +type AgentLoginResponse = { + privy_user_id: string; + workspace_id: string; + workspace_name?: string | null; + api_key: string; + api_key_id: string; + wallet?: { + address?: string | null; + requested?: unknown[]; + provisioned?: boolean; + provisioning_error?: string | null; + }; + kyb?: { + status: string; + sub_status?: string | null; + flow_link?: string | null; + marked_done?: boolean; + }; + starter_accounts?: { + attempted?: boolean; + created?: boolean; + skipped_reason?: string | null; + destination_address?: string | null; + }; +}; + +async function runAgentLogin(options: AgentLoginOptions) { + const adminToken = resolveAdminToken(options.adminToken); + if (!adminToken) { + throw new Error( + 'Admin token required. Pass --admin-token or set ZERO_FINANCE_ADMIN_TOKEN.', + ); + } + + if (!options.email && !options.phone && !options.privyUserId) { + throw new Error('Provide --email, --phone, or --privy-user-id'); + } + + if ( + options.beneficiaryType && + options.beneficiaryType !== 'business' && + options.beneficiaryType !== 'individual' + ) { + throw new Error('beneficiary type must be `business` or `individual`'); + } + + const wallets = options.walletsJson + ? await readJsonFile(options.walletsJson) + : undefined; + + if (wallets !== undefined && !Array.isArray(wallets)) { + throw new Error('--wallets-json must contain a JSON array'); + } + + const baseUrl = options.baseUrl || DEFAULT_BASE_URL; + const payload = { + email: options.email, + phone: options.phone, + privy_user_id: options.privyUserId, + workspace_name: options.workspaceName, + company_name: options.companyName, + beneficiary_type: options.beneficiaryType, + first_name: options.firstName, + last_name: options.lastName, + api_key_name: options.apiKeyName || AGENT_LOGIN_DEFAULT_KEY_NAME, + api_key_expires_at: options.apiKeyExpiresAt, + create_direct_signer: options.createDirectSigner, + create_starter_accounts: options.starterAccounts, + wallets, + }; + + const data = await apiRequest('/api/cli/agent-login', { + method: 'POST', + body: payload, + adminToken, + baseUrl, + apiKey: '', + }); + + if (!data?.api_key) { + throw new Error('Agent login response did not include an API key'); + } + + await saveConfig({ apiKey: data.api_key, baseUrl }); + + output({ + success: true, + method: 'agent_api', + workspace_id: data.workspace_id, + workspace_name: data.workspace_name ?? null, + privy_user_id: data.privy_user_id, + api_key_id: data.api_key_id, + wallet: data.wallet, + kyb: data.kyb, + starter_accounts: data.starter_accounts, + next: 'Run `zero auth whoami` to verify the connection.', + }); +} + +function configureAgentLoginCommand(command: Command) { + return command + .option('--base-url ', 'Base URL for the API', DEFAULT_BASE_URL) + .option('--email ', 'Agent email') + .option('--phone ', 'Agent phone number') + .option('--privy-user-id ', 'Use an existing Privy user ID') + .option('--workspace-name ', 'Workspace display name') + .option('--company-name ', 'Company name for KYB context') + .option('--beneficiary-type ', 'business or individual') + .option('--first-name ', 'First name for individual setups') + .option('--last-name ', 'Last name for individual setups') + .option('--api-key-name ', 'Generated API key label') + .option('--api-key-expires-at ', 'API key expiration ISO date') + .option( + '--wallets-json ', + 'Wallets JSON array (defaults to one Ethereum wallet)', + ) + .option('--create-direct-signer', 'Create Privy direct signer') + .option('--no-starter-accounts', 'Skip starter virtual account setup') + .option('--admin-token ', 'Admin token'); +} + async function readJsonFile(path: string) { const content = await fs.readFile(path, 'utf8'); return JSON.parse(content); @@ -286,31 +438,25 @@ configCmd const auth = program.command('auth').description('Authentication'); -auth - .option('--base-url ', 'Base URL for the API', DEFAULT_BASE_URL) - .option('--no-browser', 'Do not open browser automatically') - .option('--manual', 'Paste API key manually instead of callback') - .action(async (opts) => { - await runAuthConnect({ - baseUrl: opts.baseUrl, - browser: opts.browser, - manual: opts.manual, +function configureBrowserConnectCommand(command: Command) { + return command + .option('--base-url ', 'Base URL for the API', DEFAULT_BASE_URL) + .option('--no-browser', 'Do not open browser automatically') + .option('--manual', 'Paste API key manually instead of callback') + .action(async (opts) => { + await runAuthConnect({ + baseUrl: opts.baseUrl, + browser: opts.browser, + manual: opts.manual, + }); }); - }); +} -auth - .command('connect') - .description('Open the browser and connect the CLI') - .option('--base-url ', 'Base URL for the API', DEFAULT_BASE_URL) - .option('--no-browser', 'Do not open browser automatically') - .option('--manual', 'Paste API key manually instead of callback') - .action(async (opts) => { - await runAuthConnect({ - baseUrl: opts.baseUrl, - browser: opts.browser, - manual: opts.manual, - }); - }); +configureBrowserConnectCommand( + auth + .command('connect', { isDefault: true }) + .description('Open the browser and connect the CLI'), +); auth .command('login') @@ -322,6 +468,30 @@ auth output({ success: true }); }); +configureAgentLoginCommand( + auth + .command('agentlogin') + .description('Provision an agent account and save API key'), +).action(async (opts) => { + await runAgentLogin({ + baseUrl: opts.baseUrl, + email: opts.email, + phone: opts.phone, + privyUserId: opts.privyUserId, + workspaceName: opts.workspaceName, + companyName: opts.companyName, + beneficiaryType: opts.beneficiaryType, + firstName: opts.firstName, + lastName: opts.lastName, + apiKeyName: opts.apiKeyName, + apiKeyExpiresAt: opts.apiKeyExpiresAt, + createDirectSigner: opts.createDirectSigner, + starterAccounts: opts.starterAccounts, + walletsJson: opts.walletsJson, + adminToken: opts.adminToken, + }); +}); + auth .command('whoami') .description('Show workspace context for current API key') @@ -338,19 +508,31 @@ auth output({ success: true }); }); -program - .command('login') - .description('Open the browser and connect the CLI') - .option('--base-url ', 'Base URL for the API', DEFAULT_BASE_URL) - .option('--no-browser', 'Do not open browser automatically') - .option('--manual', 'Paste API key manually instead of callback') - .action(async (opts) => { - await runAuthConnect({ - baseUrl: opts.baseUrl, - browser: opts.browser, - manual: opts.manual, - }); +configureBrowserConnectCommand( + program.command('login').description('Open the browser and connect the CLI'), +); + +configureAgentLoginCommand( + program.command('agentlogin').description('Agent-native login via Privy API'), +).action(async (opts) => { + await runAgentLogin({ + baseUrl: opts.baseUrl, + email: opts.email, + phone: opts.phone, + privyUserId: opts.privyUserId, + workspaceName: opts.workspaceName, + companyName: opts.companyName, + beneficiaryType: opts.beneficiaryType, + firstName: opts.firstName, + lastName: opts.lastName, + apiKeyName: opts.apiKeyName, + apiKeyExpiresAt: opts.apiKeyExpiresAt, + createDirectSigner: opts.createDirectSigner, + starterAccounts: opts.starterAccounts, + walletsJson: opts.walletsJson, + adminToken: opts.adminToken, }); +}); const bank = program.command('bank').description('Bank operations'); diff --git a/packages/web/src/app/(landing)/ai/ai-landing.tsx b/packages/web/src/app/(landing)/ai/ai-landing.tsx index 8c4828b9..b654b5d8 100644 --- a/packages/web/src/app/(landing)/ai/ai-landing.tsx +++ b/packages/web/src/app/(landing)/ai/ai-landing.tsx @@ -77,7 +77,9 @@ function HeroCodeTabs() { <>
# Install + connect
curl -fsSL https://zerofinance.ai/install | bash - zero auth connect + + zero auth agentlogin --email finance-agent@acme.com +
# Use from scripts
zero balance @@ -94,7 +96,9 @@ function HeroCodeTabs() { curl -fsSL https://zerofinance.ai/install | bash
# Authenticate
- zero auth connect + + zero auth agentlogin --email finance-agent@acme.com +
# Debug + run
zero --debug auth whoami @@ -107,7 +111,7 @@ function HeroCodeTabs() { <>
# Install + connect
curl -fsSL https://zerofinance.ai/install | bash - zero auth connect + zero auth agentlogin --email finance-agent@acme.com
# Workflows
zero balance @@ -207,12 +211,19 @@ export function AILanding() {
-
+
- Connect the CLI + Login + + + + Agent Login - Get Started + Agent Login

- Install → Connect → Script + Install → Login → Script

@@ -355,7 +366,8 @@ export function AILanding() { Connect to your account

- Browser-based connect or API key, plus MCP support. + Use `agentlogin` for API-first auth or browser login for + humans.

@@ -538,10 +550,10 @@ export function AILanding() {

- Get Started + Agent Login
- Connect the CLI + Agent Login
- Connect the CLI + Agent Login + + Login + - Get Started + Agent Login
diff --git a/packages/web/src/app/(onboarding)/welcome/page.tsx b/packages/web/src/app/(onboarding)/welcome/page.tsx index ce084c87..fe88fada 100644 --- a/packages/web/src/app/(onboarding)/welcome/page.tsx +++ b/packages/web/src/app/(onboarding)/welcome/page.tsx @@ -313,10 +313,10 @@ export default function WelcomePage() { - Connect the CLI + Agent Login
diff --git a/packages/web/src/app/(public)/agent-login/page.tsx b/packages/web/src/app/(public)/agent-login/page.tsx new file mode 100644 index 00000000..af68753c --- /dev/null +++ b/packages/web/src/app/(public)/agent-login/page.tsx @@ -0,0 +1,145 @@ +import Link from 'next/link'; +import { ArrowRight, Bot, KeyRound, ShieldCheck, User } from 'lucide-react'; +import GeneratedComponent from '@/app/(landing)/welcome-gradient'; + +const CURL_SNIPPET = `curl -X POST https://www.0.finance/api/cli/agent-login \\ + -H "Content-Type: application/json" \\ + -H "x-admin-token: $ZERO_FINANCE_ADMIN_TOKEN" \\ + -d '{ + "email": "finance-agent@acme.com", + "workspace_name": "Acme Finance Agent", + "company_name": "Acme Inc", + "beneficiary_type": "business" + }'`; + +const CLI_SNIPPET = `zero auth agentlogin \\ + --email finance-agent@acme.com \\ + --workspace-name "Acme Finance Agent" \\ + --company-name "Acme Inc" \\ + --beneficiary-type business \\ + --admin-token $ZERO_FINANCE_ADMIN_TOKEN`; + +export default function AgentLoginPage() { + return ( +
+ + +
+
+

+ Authentication +

+

+ Human login and + agent-native login +

+

+ Humans use the normal Privy sign-in flow. Agents can provision and + authenticate fully through API without getting stuck in browser-only + connect loops. +

+
+ +
+
+
+
+ +
+
+

+ Human +

+

Login

+
+
+

+ Use this for team members approving proposals and reviewing + activity in the dashboard. +

+ + Login + + +
+ +
+
+
+ +
+
+

+ Agent +

+

+ Agent Login +

+
+
+

+ Provision a Privy user, create workspace API keys, and return KYB + status in one API call. By default it also provisions an Ethereum + wallet for the agent. +

+ + CLI reference + + +
+
+ +
+
+ + API-first agent provisioning +
+
+            {CURL_SNIPPET}
+          
+
+ +
+
+ + CLI shortcut +
+
+            {CLI_SNIPPET}
+          
+
+ +
+
+ +
+

+ KYB-aware response +

+

+ The response includes `kyb.status`, `kyb.flow_link`, and starter + account provisioning details so agents can decide whether to run + KYB steps or proceed in starter mode. +

+
+
+
+ +
+ + Back to landing + +
+
+
+ ); +} diff --git a/packages/web/src/app/(public)/signin/signin-content.tsx b/packages/web/src/app/(public)/signin/signin-content.tsx index 153e74f4..abb7eedb 100644 --- a/packages/web/src/app/(public)/signin/signin-content.tsx +++ b/packages/web/src/app/(public)/signin/signin-content.tsx @@ -503,6 +503,14 @@ export default function SignInContent() { ← Back to Landing
+
+ + Agent Login (API-first) + +
diff --git a/packages/web/src/app/api/cli/[...slug]/route.ts b/packages/web/src/app/api/cli/[...slug]/route.ts index eeca51bd..2cbe2fdd 100644 --- a/packages/web/src/app/api/cli/[...slug]/route.ts +++ b/packages/web/src/app/api/cli/[...slug]/route.ts @@ -23,12 +23,19 @@ import { dismissProposal, } from '@/server/mcp/tools'; import { createApiKey } from '@/lib/mcp/api-key'; -import { createPrivyUser, pregeneratePrivyWallets } from '@/server/cli/privy'; +import { + createPrivyUser, + getPrivyUser, + type PrivyWalletRequest, + pregeneratePrivyWallets, +} from '@/server/cli/privy'; import { ensureUserWorkspace } from '@/server/utils/workspace'; import { db } from '@/db'; import { actionProposals, + userFundingSources, userProfilesTable, + userSafes, users, webhookEndpoints, workspaces, @@ -53,6 +60,7 @@ import { import { listVaults } from '@/server/earn/vault-registry'; import { getWorkspaceYieldPolicy } from '@/server/services/yield-policy'; import { getWorkspaceSafes } from '@/server/earn/multi-chain-safe-manager'; +import { createStarterVirtualAccounts } from '@/server/services/align-starter-accounts'; import { dispatchWebhookEvent, logAuditEvent, @@ -61,6 +69,8 @@ import { import { getSpendableBalanceByWorkspace } from '@/server/services/spendable-balance'; export const dynamic = 'force-dynamic'; +const AGENT_LOGIN_KEY_NAME = 'Agent API Login'; +const DEFAULT_AGENT_WALLETS = [{ chain_type: 'ethereum' }] as const; type McpTextResult = { content: Array<{ type: string; text: string }>; @@ -92,6 +102,94 @@ function jsonResponse(payload: unknown, status = 200) { return NextResponse.json(payload, { status }); } +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object') { + return null; + } + return value as Record; +} + +function normalizeOptionalString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function extractAddressFromWallet(value: unknown): string | null { + const wallet = asRecord(value); + if (!wallet) return null; + const address = wallet.address; + return typeof address === 'string' && isAddress(address) ? address : null; +} + +function extractAddressFromLinkedAccounts(value: unknown): string | null { + if (!Array.isArray(value)) { + return null; + } + + for (const account of value) { + const entry = asRecord(account); + if (!entry) continue; + + const address = entry.address; + if (typeof address !== 'string' || !isAddress(address)) { + continue; + } + + const type = entry.type; + const chainType = entry.chain_type ?? entry.chainType; + if ( + type === 'wallet' || + chainType === 'ethereum' || + chainType === undefined + ) { + return address; + } + } + + return null; +} + +function extractPrivyDestinationAddress(privyUser: unknown): string | null { + const root = asRecord(privyUser); + if (!root) { + return null; + } + + const nestedUser = asRecord(root.user); + + return ( + extractAddressFromWallet(root.wallet) || + extractAddressFromWallet(nestedUser?.wallet) || + extractAddressFromLinkedAccounts(root.linked_accounts) || + extractAddressFromLinkedAccounts(nestedUser?.linked_accounts) + ); +} + +function parseBeneficiaryType( + value: unknown, +): 'business' | 'individual' | null { + if (value === undefined || value === null) { + return null; + } + + if (value !== 'business' && value !== 'individual') { + throw new Error('beneficiary_type must be `business` or `individual`'); + } + + return value; +} + +function resolveAgentWalletRequests(value: unknown): PrivyWalletRequest[] { + if (!Array.isArray(value) || value.length === 0) { + return DEFAULT_AGENT_WALLETS.map((wallet) => ({ ...wallet })); + } + + return value as PrivyWalletRequest[]; +} + function errorResponse(message: string, status = 400) { return jsonResponse({ error: message }, status); } @@ -1395,6 +1493,238 @@ export async function POST( ); } + if (slug[0] === 'agent-login') { + requireAdmin(request); + const body = await request.json(); + + const email = normalizeOptionalString(body.email); + const phone = normalizeOptionalString(body.phone); + const providedPrivyUserId = normalizeOptionalString(body.privy_user_id); + const workspaceName = normalizeOptionalString(body.workspace_name); + const companyName = normalizeOptionalString(body.company_name); + const firstName = normalizeOptionalString(body.first_name); + const lastName = normalizeOptionalString(body.last_name); + const destinationAddressInput = normalizeOptionalString( + body.destination_address, + ); + const beneficiaryType = parseBeneficiaryType(body.beneficiary_type); + const createStarterAccounts = body.create_starter_accounts !== false; + const walletRequests = resolveAgentWalletRequests(body.wallets); + + if (!email && !phone && !providedPrivyUserId) { + return errorResponse('email, phone, or privy_user_id is required'); + } + + const linkedAccounts = [] as Array<{ + type: 'email' | 'phone'; + address: string; + }>; + if (email) { + linkedAccounts.push({ type: 'email', address: email }); + } + if (phone) { + linkedAccounts.push({ type: 'phone', address: phone }); + } + + let privyUser: unknown = null; + let privyDid = providedPrivyUserId; + + if (!privyDid) { + privyUser = await createPrivyUser({ + linked_accounts: linkedAccounts, + wallets: walletRequests, + ...(body.create_direct_signer !== undefined + ? { create_direct_signer: body.create_direct_signer } + : {}), + ...(body.custom_metadata + ? { custom_metadata: body.custom_metadata } + : {}), + }); + + const privyUserRecord = asRecord(privyUser); + const nestedUser = asRecord(privyUserRecord?.user); + const createdUserId = + normalizeOptionalString(privyUserRecord?.id) || + normalizeOptionalString(nestedUser?.id); + + if (!createdUserId) { + return errorResponse('Privy user creation failed'); + } + + privyDid = createdUserId; + } else { + privyUser = await getPrivyUser(privyDid); + } + + const { workspaceId } = await ensureUserWorkspace(db, privyDid, email); + + const workspacePatch: Partial = {}; + if (workspaceName) workspacePatch.name = workspaceName; + if (companyName) workspacePatch.companyName = companyName; + if (firstName) workspacePatch.firstName = firstName; + if (lastName) workspacePatch.lastName = lastName; + if (beneficiaryType) { + workspacePatch.beneficiaryType = beneficiaryType; + workspacePatch.workspaceType = + beneficiaryType === 'business' ? 'business' : 'personal'; + } + + if (Object.keys(workspacePatch).length > 0) { + await db + .update(workspaces) + .set(workspacePatch) + .where(eq(workspaces.id, workspaceId)); + } + + const existingProfile = await db.query.userProfilesTable.findFirst({ + where: eq(userProfilesTable.privyDid, privyDid), + }); + + if (!existingProfile) { + await db.insert(userProfilesTable).values({ + privyDid, + email: email ?? null, + skippedOrCompletedOnboardingStepper: false, + workspaceId, + }); + } else if (email && existingProfile.email !== email) { + await db + .update(userProfilesTable) + .set({ email }) + .where(eq(userProfilesTable.privyDid, privyDid)); + } + + const keyName = + normalizeOptionalString(body.api_key_name) || AGENT_LOGIN_KEY_NAME; + const expiresAtInput = normalizeOptionalString(body.api_key_expires_at); + const { rawKey, keyId } = await createApiKey({ + workspaceId, + name: keyName, + createdBy: privyDid, + expiresAt: expiresAtInput ? new Date(expiresAtInput) : undefined, + }); + + const primarySafe = await db.query.userSafes.findFirst({ + where: and( + eq(userSafes.userDid, privyDid), + eq(userSafes.workspaceId, workspaceId), + eq(userSafes.safeType, 'primary'), + ), + columns: { safeAddress: true }, + }); + + let destinationAddress = + (destinationAddressInput && isAddress(destinationAddressInput) + ? destinationAddressInput + : null) || + primarySafe?.safeAddress || + extractPrivyDestinationAddress(privyUser); + + let walletProvisioned = false; + let walletProvisionError: string | null = null; + + if (!destinationAddress && privyDid) { + try { + const walletProvisionResult = await pregeneratePrivyWallets({ + user_id: privyDid, + wallets: walletRequests, + ...(body.create_direct_signer !== undefined + ? { create_direct_signer: body.create_direct_signer } + : {}), + }); + + privyUser = walletProvisionResult; + const resolvedAddress = extractPrivyDestinationAddress(privyUser); + + if (resolvedAddress) { + destinationAddress = resolvedAddress; + walletProvisioned = true; + } else { + walletProvisionError = + 'Wallet provisioning did not return an address'; + } + } catch (error) { + walletProvisionError = + error instanceof Error + ? error.message + : 'Wallet provisioning failed'; + } + } + + const existingStarterAccount = + await db.query.userFundingSources.findFirst({ + where: and( + eq(userFundingSources.workspaceId, workspaceId), + eq(userFundingSources.accountTier, 'starter'), + eq(userFundingSources.sourceProvider, 'align'), + ), + columns: { id: true }, + }); + + let starterAccountsCreated = false; + let starterAccountsSkippedReason: string | null = null; + + if (!createStarterAccounts) { + starterAccountsSkippedReason = 'Skipped by request'; + } else if (existingStarterAccount) { + starterAccountsSkippedReason = 'Starter accounts already exist'; + } else if (!destinationAddress) { + starterAccountsSkippedReason = 'No destination address available'; + } else { + const starterResult = await createStarterVirtualAccounts({ + userId: privyDid, + workspaceId, + destinationAddress, + }); + starterAccountsCreated = !!starterResult; + if (!starterAccountsCreated) { + starterAccountsSkippedReason = + 'Starter account provisioning unavailable'; + } + } + + const workspace = await db.query.workspaces.findFirst({ + where: eq(workspaces.id, workspaceId), + columns: { + name: true, + kycStatus: true, + kycSubStatus: true, + kycFlowLink: true, + kycMarkedDone: true, + }, + }); + + return jsonResponse( + { + mode: 'agent_api', + privy_user_id: privyDid, + workspace_id: workspaceId, + workspace_name: workspace?.name ?? null, + api_key: rawKey, + api_key_id: keyId, + kyb: { + status: workspace?.kycStatus ?? 'none', + sub_status: workspace?.kycSubStatus ?? null, + flow_link: workspace?.kycFlowLink ?? null, + marked_done: workspace?.kycMarkedDone ?? false, + }, + starter_accounts: { + attempted: createStarterAccounts, + created: starterAccountsCreated, + skipped_reason: starterAccountsSkippedReason, + destination_address: destinationAddress ?? null, + }, + wallet: { + address: destinationAddress ?? null, + requested: walletRequests, + provisioned: walletProvisioned, + provisioning_error: walletProvisionError, + }, + }, + 201, + ); + } + if (slug[0] === 'users') { requireAdmin(request); const body = await request.json(); diff --git a/packages/web/src/server/cli/privy.ts b/packages/web/src/server/cli/privy.ts index fd60c7f1..87cad371 100644 --- a/packages/web/src/server/cli/privy.ts +++ b/packages/web/src/server/cli/privy.ts @@ -1,4 +1,4 @@ -type PrivyWalletRequest = { +export type PrivyWalletRequest = { chain_type: | 'ethereum' | 'solana' @@ -74,6 +74,29 @@ export async function createPrivyUser(params: PrivyCreateUserParams) { return response.json(); } +export async function getPrivyUser(userId: string) { + const { appId, appSecret } = getPrivyCredentials(); + + const response = await fetch( + `https://auth.privy.io/api/v1/users/${encodeURIComponent(userId)}`, + { + method: 'GET', + headers: { + Authorization: buildAuthHeader(appId, appSecret), + 'privy-app-id': appId, + 'Content-Type': 'application/json', + }, + }, + ); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Privy get user failed: ${body}`); + } + + return response.json(); +} + export async function pregeneratePrivyWallets( params: PrivyPregenerateWalletsParams, ) { diff --git a/packages/web/src/server/utils/workspace.ts b/packages/web/src/server/utils/workspace.ts index dea2dfe8..58c5df57 100644 --- a/packages/web/src/server/utils/workspace.ts +++ b/packages/web/src/server/utils/workspace.ts @@ -5,7 +5,7 @@ import { workspaceMembers, type WorkspaceMember, } from '@/db/schema'; -import { eq, and, ne, desc } from 'drizzle-orm'; +import { eq, and, ne, desc, sql } from 'drizzle-orm'; // Drizzle types for db/transaction are quite involved; use a minimal surface instead. type DatabaseExecutor = any; @@ -172,6 +172,44 @@ async function resolveWorkspaceId( return workspace.id; } +async function bootstrapUserWorkspace( + tx: DatabaseExecutor, + userId: string, + userEmail?: string | null, +) { + const workspaceId = randomUUID(); + const workspaceName = `${userId.slice(0, 8)}'s Workspace`; + + await tx.execute(sql` + WITH inserted_user AS ( + INSERT INTO users (privy_did, primary_workspace_id, email) + VALUES (${userId}, ${workspaceId}::uuid, ${userEmail ?? null}) + ON CONFLICT (privy_did) DO NOTHING + RETURNING privy_did + ), + inserted_workspace AS ( + INSERT INTO workspaces (id, name, created_by) + VALUES (${workspaceId}::uuid, ${workspaceName}, ${userId}) + ON CONFLICT (id) DO NOTHING + RETURNING id + ) + SELECT 1; + `); + + const userRows = await tx + .select() + .from(users) + .where(eq(users.privyDid, userId)) + .limit(1); + + const user = userRows[0]; + if (!user) { + throw new Error('Failed to create user workspace bootstrap records'); + } + + return user; +} + export async function ensureUserWorkspace( dbExecutor: DatabaseExecutor, userId: string, @@ -245,78 +283,10 @@ export async function ensureUserWorkspace( .delete(workspaceMembers) .where(eq(workspaceMembers.id, existingMembership.id)); - const tempWorkspaceId = randomUUID(); - - const insertedUsers = await tx - .insert(users) - .values({ - privyDid: userId, - primaryWorkspaceId: tempWorkspaceId, - email: userEmail, - }) - .onConflictDoNothing() - .returning(); - - if (insertedUsers.length === 0) { - const existing = await tx - .select() - .from(users) - .where(eq(users.privyDid, userId)) - .limit(1); - user = existing[0]; - } else { - const [workspace] = await tx - .insert(workspaces) - .values({ - id: tempWorkspaceId, - name: `${userId.slice(0, 8)}'s Workspace`, - createdBy: userId, - }) - .returning(); - - if (!workspace) { - throw new Error('Failed to create workspace for user'); - } - - user = insertedUsers[0]; - } + user = await bootstrapUserWorkspace(tx, userId, userEmail); } } else { - const tempWorkspaceId = randomUUID(); - - const insertedUsers = await tx - .insert(users) - .values({ - privyDid: userId, - primaryWorkspaceId: tempWorkspaceId, - email: userEmail, - }) - .onConflictDoNothing() - .returning(); - - if (insertedUsers.length === 0) { - const existing = await tx - .select() - .from(users) - .where(eq(users.privyDid, userId)) - .limit(1); - user = existing[0]; - } else { - const [workspace] = await tx - .insert(workspaces) - .values({ - id: tempWorkspaceId, - name: `${userId.slice(0, 8)}'s Workspace`, - createdBy: userId, - }) - .returning(); - - if (!workspace) { - throw new Error('Failed to create workspace for user'); - } - - user = insertedUsers[0]; - } + user = await bootstrapUserWorkspace(tx, userId, userEmail); } } diff --git a/packages/web/test-artifacts/privy-agent-native-login/REPORT.md b/packages/web/test-artifacts/privy-agent-native-login/REPORT.md new file mode 100644 index 00000000..47da2c6a --- /dev/null +++ b/packages/web/test-artifacts/privy-agent-native-login/REPORT.md @@ -0,0 +1,136 @@ +# Privy Agent-Native Login Report + +Date: 2026-02-23 +Branch: `feat/privy-agent-native-auth` + +## Goal + +Make authentication work cleanly for both audiences: + +1. Humans: a clear `Login` path. +2. Agents: an API-first `Agent Login` path that provisions Privy + API keys without browser-only flow dependencies. + +## What Changed + +### 1) Website login split (human vs agent) + +- Landing header now has: + - `Login` -> `/signin` + - `Agent Login` -> `/agent-login` +- Landing CTAs previously pointing to `/cli/connect` now point to `/agent-login`. +- Onboarding success page CLI CTA now points to `/agent-login`. +- Sign-in page includes a direct `Agent Login (API-first)` link. + +### 2) New agent login page + +- Added `/agent-login` page with: + - Human login CTA (`/signin`) + - Agent login docs/snippets + - cURL example for `POST /api/cli/agent-login` + - CLI example for `zero auth agentlogin` + - KYB status explanation + +### 3) New API endpoint: `POST /api/cli/agent-login` + +- Requires `x-admin-token`. +- Supports agent provisioning inputs: + - `email` / `phone` / `privy_user_id` + - `workspace_name`, `company_name`, `beneficiary_type` + - `first_name`, `last_name` + - `wallets`, `create_direct_signer` + - `api_key_name`, `api_key_expires_at` + - `create_starter_accounts`, `destination_address` +- Behavior: + - Creates or reuses Privy user identity + - Ensures local workspace + profile records + - Creates an API key + - Attempts starter account provisioning (Align) when possible + - Returns KYB status payload (`kyb.status`, `kyb.flow_link`, etc.) + +### 4) CLI command: `zero auth agentlogin` + +- Added `auth agentlogin` and top-level alias `agentlogin`. +- Calls `/api/cli/agent-login`, stores returned API key in CLI config, and prints workspace/KYB metadata. +- Updated missing-key guidance to include `agentlogin`. + +### 5) CLI non-interactive hang fix + +- Browser connect flow now fails fast in non-interactive sessions instead of hanging on input prompt. +- `zero auth connect --manual --no-browser` now exits with actionable guidance if stdin is non-interactive. +- Refactored `auth` command wiring so `zero auth connect ...` routes correctly as a real subcommand. + +### 6) Workspace/user bootstrap fix for true API-native onboarding + +- Fixed `ensureUserWorkspace` bootstrap path to handle the circular `users` <-> `workspaces` foreign key requirement in one SQL statement. +- Implemented a CTE-based insert that creates both records atomically for brand-new agent users. +- This unblocked full agent-native login for first-time users (no human/browser fallback required). + +### 7) Wallet creation by default for agent login + +- `agent-login` now defaults wallet requests to one Ethereum wallet when `wallets` is not provided. +- Existing Privy users are fetched to detect existing wallet addresses. +- If no wallet address is present, wallet pre-generation is attempted automatically. +- Response now includes `wallet.address`, `wallet.requested`, and provisioning state. + +## Testing Evidence + +## Type safety/build + +- `pnpm --filter @zero-finance/web typecheck` -> pass +- `pnpm --filter agent-bank build` -> pass + +## CLI behavior checks + +- `node packages/cli/dist/index.js auth agentlogin --help` -> shows new agent-native flags +- `node packages/cli/dist/index.js agentlogin --help` -> top-level alias works +- `node packages/cli/dist/index.js auth connect --manual --no-browser` -> no hang; exits with explicit non-interactive guidance + +## API route smoke test + +- `POST /api/cli/agent-login` without admin token returns: + - HTTP 401 + - `{"error":"Unauthorized: invalid admin token"}` + +## Full E2E agent-native login proof (no human confirmation) + +- Environment setup: + - `docker compose -f docker-compose.lite.yml up -d` + - `pnpm --filter @zero-finance/web db:migrate:lite` + - `pnpm --filter @zero-finance/web dev:lite` +- Agent-native login command: + - `node packages/cli/dist/index.js auth agentlogin --email agent-e2e-@example.com --workspace-name "Agent E2E Workspace" --company-name "Agent E2E Inc" --beneficiary-type business --admin-token --base-url http://127.0.0.1:3000` +- Result: + - `success: true` + - `method: "agent_api"` + - `workspace_name: "Agent E2E Workspace"` + - `privy_user_id` returned + - `api_key_id` returned + - `wallet.address` returned + - `kyb.status: "none"` +- Follow-up auth verification: + - `node packages/cli/dist/index.js auth whoami` + - Returned matching `workspace_id`, `workspace_name`, and `key_id` +- No browser prompt or human confirmation was required in this E2E flow. + +## Default wallet E2E proof + +- Command run without `--wallets-json`: + - `node packages/cli/dist/index.js auth agentlogin --email agent-wallet-default-@example.com --workspace-name "Agent Wallet Default" --company-name "Wallet Default Inc" --beneficiary-type business --admin-token --base-url http://127.0.0.1:3000` +- Result included: + - `wallet.requested: [{"chain_type":"ethereum"}]` + - non-null `wallet.address` + - `starter_accounts.destination_address` equal to `wallet.address` + +## UI verification screenshots + +- `landing-login-agent-login.png` + - Confirms split header buttons (`Login`, `Agent Login`) and updated landing CTAs. +- `agent-login-page.png` + - Confirms dedicated API-first agent login page and command examples. +- `signin-with-agent-login-link.png` + - Confirms human sign-in page now links to agent-native path. + +## Notes + +- Browser-based CLI connect route (`/cli/connect`) remains available for compatibility with existing CLI browser auth flow. +- Human website paths no longer require that route as the primary entrypoint. diff --git a/packages/web/test-artifacts/privy-agent-native-login/SECURITY-REPORT.md b/packages/web/test-artifacts/privy-agent-native-login/SECURITY-REPORT.md new file mode 100644 index 00000000..416d1d61 --- /dev/null +++ b/packages/web/test-artifacts/privy-agent-native-login/SECURITY-REPORT.md @@ -0,0 +1,106 @@ +# Agent-Pure Auth Security Report + +Date: 2026-02-23 +Scope: `zero auth agentlogin` and `POST /api/cli/agent-login` + +## Executive Summary + +Agent-pure login removes browser friction and enables deterministic automation, but it shifts trust from end-user interaction to machine credentials (`x-admin-token`, API keys, CI/runtime secret handling). + +Security posture improves for automation reliability and replay control, while key-management and privilege-boundary risks become more important than phishing/interactive-session risks. + +## Human vs Agent-Pure Flow + +### Human interactive flow + +- Entry: `/signin` and browser-mediated Privy auth. +- Primary trust anchor: end-user interaction + Privy session. +- Typical risk profile: + - lower automation abuse potential + - higher social-engineering/phishing exposure + - stronger user presence signal + +### Agent-pure flow + +- Entry: `zero auth agentlogin` -> `POST /api/cli/agent-login`. +- Primary trust anchor: server-side admin token + generated workspace API key. +- Typical risk profile: + - strong automation reliability and deterministic behavior + - minimal phishing surface (no browser handoff) + - higher secret theft/blast-radius risk if token scopes are broad + +## Security Effects of Agent-Pure Flow + +1. **Reduced interactive attack surface** + +- No browser callback loop or manual copy/paste auth steps. +- Less opportunity for phishing through fake connect pages. + +2. **Increased credential governance requirements** + +- Admin token misuse can create identities and keys at scale. +- Requires strict storage, rotation, and environment isolation. + +3. **Improved machine verifiability** + +- API responses include KYB and provisioning state, making policy checks explicit and auditable. +- Easier to gate downstream actions on machine-readable status. + +4. **Shift from user-intent validation to policy validation** + +- Human confirmation is removed by design. +- Compensating controls must enforce intent through role-bound tokens, logging, and limits. + +## Controls in This PR + +- Admin-gated endpoint (`x-admin-token`) for agent provisioning. +- Workspace-scoped API keys returned and stored by CLI. +- Structured response includes `kyb` and starter-account status for policy decisions. +- Non-interactive browser connect fails fast (prevents hanging auth states). +- Agent login now provisions a wallet by default (Ethereum), removing "auth succeeded but no wallet" dead-ends. + +## New Risks to Watch + +1. **Admin token overreach** + +- If a shared admin token leaks, attacker can bootstrap many agent identities and API keys. + +2. **Unattended key persistence** + +- CLI config stores returned API keys; host compromise exposes active credentials. + +3. **Provisioning abuse at scale** + +- Automated endpoint allows high-frequency account creation if rate controls are absent. + +4. **Policy bypass through weak runtime controls** + +- If workflows do not enforce KYB/KYC preconditions, automation can proceed farther than intended. + +## Recommended Hardening + +1. **Token scope + TTL** + +- Introduce short-lived, narrowly scoped admin provisioning tokens. + +2. **Endpoint rate limiting + anomaly detection** + +- Rate limit `/api/cli/agent-login` by token/IP and alert on bursts. + +3. **Strong audit trails** + +- Log provisioning actor, source, workspace, and resulting key IDs. + +4. **Key lifecycle controls** + +- Enforce expiration defaults for generated API keys. +- Automate rotation and revocation for inactive keys. + +5. **Policy gates on KYB/KYC state** + +- Require explicit state checks before privileged banking actions. + +6. **Secret handling standards** + +- Keep admin tokens only in secret managers (not committed env files). +- Separate prod/staging tokens and restrict workstation usage. diff --git a/packages/web/test-artifacts/privy-agent-native-login/agent-login-page.png b/packages/web/test-artifacts/privy-agent-native-login/agent-login-page.png new file mode 100644 index 00000000..4d97ff3f Binary files /dev/null and b/packages/web/test-artifacts/privy-agent-native-login/agent-login-page.png differ diff --git a/packages/web/test-artifacts/privy-agent-native-login/landing-login-agent-login.png b/packages/web/test-artifacts/privy-agent-native-login/landing-login-agent-login.png new file mode 100644 index 00000000..e65ed279 Binary files /dev/null and b/packages/web/test-artifacts/privy-agent-native-login/landing-login-agent-login.png differ diff --git a/packages/web/test-artifacts/privy-agent-native-login/signin-with-agent-login-link.png b/packages/web/test-artifacts/privy-agent-native-login/signin-with-agent-login-link.png new file mode 100644 index 00000000..4ada1412 Binary files /dev/null and b/packages/web/test-artifacts/privy-agent-native-login/signin-with-agent-login-link.png differ