diff --git a/.env.example b/.env.example index ff71a632..18a3a66a 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,10 @@ PINATA_JWT="your-pinata-jwt-token" NEXT_PUBLIC_BLOCKFROST_API_KEY_MAINNET="your-blockfrost-mainnet-api-key" NEXT_PUBLIC_BLOCKFROST_API_KEY_PREPROD="your-blockfrost-preprod-api-key" +# Snapshot Auth Token +# Used to authenticate the balance snapshot batch endpoint +# SNAPSHOT_AUTH_TOKEN="your-snapshot-auth-token" + # Optional: Skip environment validation during builds # Useful for Docker builds where env vars are set at runtime # SKIP_ENV_VALIDATION=true diff --git a/prisma/migrations/20260304000000_add_pending_bot_and_claim_token/migration.sql b/prisma/migrations/20260304000000_add_pending_bot_and_claim_token/migration.sql new file mode 100644 index 00000000..f8fc96c9 --- /dev/null +++ b/prisma/migrations/20260304000000_add_pending_bot_and_claim_token/migration.sql @@ -0,0 +1,47 @@ +-- CreateEnum +CREATE TYPE "PendingBotStatus" AS ENUM ('UNCLAIMED', 'CLAIMED'); + +-- CreateTable +CREATE TABLE "PendingBot" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "paymentAddress" TEXT NOT NULL, + "stakeAddress" TEXT, + "requestedScopes" TEXT NOT NULL, + "status" "PendingBotStatus" NOT NULL DEFAULT 'UNCLAIMED', + "claimedBy" TEXT, + "secretCipher" TEXT, + "pickedUp" BOOLEAN NOT NULL DEFAULT false, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PendingBot_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "BotClaimToken" ( + "id" TEXT NOT NULL, + "pendingBotId" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "attempts" INTEGER NOT NULL DEFAULT 0, + "expiresAt" TIMESTAMP(3) NOT NULL, + "consumedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "BotClaimToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "PendingBot_paymentAddress_idx" ON "PendingBot"("paymentAddress"); + +-- CreateIndex +CREATE INDEX "PendingBot_expiresAt_idx" ON "PendingBot"("expiresAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "BotClaimToken_pendingBotId_key" ON "BotClaimToken"("pendingBotId"); + +-- CreateIndex +CREATE INDEX "BotClaimToken_tokenHash_idx" ON "BotClaimToken"("tokenHash"); + +-- AddForeignKey +ALTER TABLE "BotClaimToken" ADD CONSTRAINT "BotClaimToken_pendingBotId_fkey" FOREIGN KEY ("pendingBotId") REFERENCES "PendingBot"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 41ce09bd..23d45677 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -221,3 +221,39 @@ model WalletBotAccess { @@index([walletId]) @@index([botId]) } + +enum PendingBotStatus { + UNCLAIMED + CLAIMED +} + +model PendingBot { + id String @id @default(cuid()) + name String + paymentAddress String + stakeAddress String? + requestedScopes String // JSON array of requested scopes + status PendingBotStatus @default(UNCLAIMED) + claimedBy String? // ownerAddress of the claiming human + secretCipher String? // Encrypted secret (set on claim, cleared on pickup) + pickedUp Boolean @default(false) + expiresAt DateTime + createdAt DateTime @default(now()) + claimToken BotClaimToken? + + @@index([paymentAddress]) + @@index([expiresAt]) +} + +model BotClaimToken { + id String @id @default(cuid()) + pendingBotId String @unique + pendingBot PendingBot @relation(fields: [pendingBotId], references: [id], onDelete: Cascade) + tokenHash String // SHA-256 hash of the claim code + attempts Int @default(0) + expiresAt DateTime + consumedAt DateTime? + createdAt DateTime @default(now()) + + @@index([tokenHash]) +} diff --git a/scripts/bot-ref/README.md b/scripts/bot-ref/README.md index 45938cfb..10225ab2 100644 --- a/scripts/bot-ref/README.md +++ b/scripts/bot-ref/README.md @@ -4,19 +4,30 @@ Minimal client to test the multisig v1 bot API. Use it from the Cursor agent or ## Config -One JSON blob (from the "Create bot" UI or manually): +Use config in two phases: + +1. Registration/claim phase (before credentials exist): ```json { "baseUrl": "http://localhost:3000", - "botKeyId": "", - "secret": "", + "paymentAddress": "" +} +``` + +2. Authenticated phase (after pickup): + +```json +{ + "baseUrl": "http://localhost:3000", + "botKeyId": "", + "secret": "", "paymentAddress": "" } ``` - **baseUrl**: API base (e.g. `http://localhost:3000` for dev). -- **botKeyId** / **secret**: From the Create bot dialog (copy the JSON blob, fill `paymentAddress`). +- **botKeyId** / **secret**: Returned by `GET /api/v1/botPickupSecret` after a human claims the bot. - **paymentAddress**: The bot’s **own** Cardano payment address (a wallet the bot controls, not the owner’s address). One bot, one address. Required for `auth` and for all authenticated calls. Provide config in one of these ways: @@ -36,7 +47,29 @@ cd scripts/bot-ref npm install ``` -### 1. Register / get token +### 1. Register -> claim -> pickup -> auth + +1. Bot self-registers and receives a claim code: + +```bash +curl -sS -X POST http://localhost:3000/api/v1/botRegister \ + -H "Content-Type: application/json" \ + -d '{"name":"Reference Bot","paymentAddress":"addr1_xxx","scopes":["multisig:read"]}' +``` + +Response includes `pendingBotId` and `claimCode`. + +2. Human claims the bot in the app by entering `pendingBotId` and `claimCode`. + +3. Bot picks up credentials: + +```bash +curl -sS "http://localhost:3000/api/v1/botPickupSecret?pendingBotId=" +``` + +Response includes `botKeyId` and `secret`. + +4. Set config with `botKeyId`, `secret`, and `paymentAddress`, then authenticate to get a JWT: ```bash BOT_CONFIG='{"baseUrl":"http://localhost:3000","botKeyId":"YOUR_KEY","secret":"YOUR_SECRET","paymentAddress":"addr1_xxx"}' npx tsx bot-client.ts auth @@ -77,7 +110,7 @@ npx tsx bot-client.ts freeUtxos npx tsx bot-client.ts botMe ``` -Returns the bot’s own info: `botId`, `paymentAddress`, `displayName`, `botName`, **`ownerAddress`** (the address of the human who created the bot). No `paymentAddress` in config needed for this command. +Returns the bot’s own info: `botId`, `paymentAddress`, `displayName`, `botName`, **`ownerAddress`** (the address of the human who claimed the bot). No `paymentAddress` in config needed for this command. ### 6. Owner info @@ -111,13 +144,14 @@ From **repo root**: `npx tsx scripts/bot-ref/generate-bot-wallet.ts` — creates cd scripts/bot-ref && npx tsx create-wallet-us.ts ``` -Uses the owner’s address from `botMe` and the bot’s address from config. **The bot must have its own wallet and address** (not the same as the owner). Set `paymentAddress` in `bot-config.json` to the bot’s Cardano address, register it with POST /api/v1/botAuth, then run the script. +Uses the owner’s address from `botMe` and the bot’s address from config. **The bot must have its own wallet and address** (not the same as the owner). Set `paymentAddress` in `bot-config.json` to the bot’s Cardano address, complete register -> claim -> pickup, then run `auth` and this script. ## Cursor agent testing -1. Create a bot in the app (User page → Create bot). Copy the JSON blob and add the bot’s `paymentAddress`. -2. Save as `scripts/bot-ref/bot-config.json` (or pass via `BOT_CONFIG`). -3. Run auth and use the token: +1. Self-register the bot (`POST /api/v1/botRegister`) and capture `pendingBotId` + `claimCode`. +2. Claim it in the app using that ID/code (User page -> Claim a bot). +3. Call `GET /api/v1/botPickupSecret?pendingBotId=...` and place `botKeyId` + `secret` in `scripts/bot-ref/bot-config.json` with the bot `paymentAddress`. +4. Run auth and use the token: ```bash cd /path/to/multisig/scripts/bot-ref @@ -130,7 +164,7 @@ The reference client only uses **bot-key auth** (POST /api/v1/botAuth). Wallet-b ## Governance bot flow -For governance automation, grant these bot scopes when creating the bot key: +For governance automation, request and approve these bot scopes during register/claim: - `governance:read` to call `GET /api/v1/governanceActiveProposals` - `ballot:write` to call `POST /api/v1/botBallotsUpsert` diff --git a/scripts/bot-ref/bot-client.ts b/scripts/bot-ref/bot-client.ts index 5803b691..57d8e980 100644 --- a/scripts/bot-ref/bot-client.ts +++ b/scripts/bot-ref/bot-client.ts @@ -4,6 +4,8 @@ * Used by Cursor agent and local scripts to test bot flows. * * Usage: + * BOT_CONFIG='{"baseUrl":"http://localhost:3000","paymentAddress":"addr1_..."}' npx tsx bot-client.ts register "Reference Bot" multisig:read + * BOT_CONFIG='{"baseUrl":"http://localhost:3000"}' npx tsx bot-client.ts pickup * BOT_CONFIG='{"baseUrl":"http://localhost:3000","botKeyId":"...","secret":"...","paymentAddress":"addr1_..."}' npx tsx bot-client.ts auth * npx tsx bot-client.ts walletIds * npx tsx bot-client.ts pendingTransactions @@ -11,16 +13,20 @@ export type BotConfig = { baseUrl: string; - botKeyId: string; - secret: string; - paymentAddress: string; + botKeyId?: string; + secret?: string; + paymentAddress?: string; }; export async function loadConfig(): Promise { const fromEnv = process.env.BOT_CONFIG; if (fromEnv) { try { - return JSON.parse(fromEnv) as BotConfig; + const parsed = JSON.parse(fromEnv) as BotConfig; + if (!parsed.baseUrl || typeof parsed.baseUrl !== "string") { + throw new Error("baseUrl is required in config"); + } + return parsed; } catch (e) { throw new Error("BOT_CONFIG is invalid JSON: " + (e as Error).message); } @@ -31,7 +37,11 @@ export async function loadConfig(): Promise { const fullPath = path.startsWith("/") ? path : join(process.cwd(), path); try { const raw = readFileSync(fullPath, "utf8"); - return JSON.parse(raw) as BotConfig; + const parsed = JSON.parse(raw) as BotConfig; + if (!parsed.baseUrl || typeof parsed.baseUrl !== "string") { + throw new Error("baseUrl is required in config"); + } + return parsed; } catch (e) { throw new Error(`Failed to load config from ${path}: ${(e as Error).message}`); } @@ -43,6 +53,9 @@ function ensureSlash(url: string): string { /** Authenticate with bot key + payment address; returns JWT. */ export async function botAuth(config: BotConfig): Promise<{ token: string; botId: string }> { + if (!config.botKeyId || !config.secret || !config.paymentAddress) { + throw new Error("auth requires botKeyId, secret, and paymentAddress in config"); + } const base = ensureSlash(config.baseUrl); const res = await fetch(`${base}/api/v1/botAuth`, { method: "POST", @@ -61,6 +74,45 @@ export async function botAuth(config: BotConfig): Promise<{ token: string; botId return { token: data.token, botId: data.botId }; } +/** Register a pending bot and receive a claim code for human claim in UI. */ +export async function registerBot( + baseUrl: string, + body: { + name: string; + paymentAddress: string; + requestedScopes: string[]; + stakeAddress?: string; + }, +): Promise<{ pendingBotId: string; claimCode: string; claimExpiresAt: string }> { + const base = ensureSlash(baseUrl); + const res = await fetch(`${base}/api/v1/botRegister`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`botRegister failed ${res.status}: ${text}`); + } + return (await res.json()) as { pendingBotId: string; claimCode: string; claimExpiresAt: string }; +} + +/** Pickup claimed bot credentials once human claim is complete. */ +export async function pickupBotSecret( + baseUrl: string, + pendingBotId: string, +): Promise<{ botKeyId: string; secret: string; paymentAddress: string }> { + const base = ensureSlash(baseUrl); + const res = await fetch( + `${base}/api/v1/botPickupSecret?pendingBotId=${encodeURIComponent(pendingBotId)}`, + ); + if (!res.ok) { + const text = await res.text(); + throw new Error(`botPickupSecret failed ${res.status}: ${text}`); + } + return (await res.json()) as { botKeyId: string; secret: string; paymentAddress: string }; +} + /** Get wallet IDs for the bot (requires prior auth; pass JWT). */ export async function getWalletIds(baseUrl: string, token: string, address: string): Promise<{ walletId: string; walletName: string }[]> { const base = ensureSlash(baseUrl); @@ -190,8 +242,10 @@ async function main() { const config = await loadConfig(); const cmd = process.argv[2]; if (!cmd) { - console.error("Usage: bot-client.ts [args]"); - console.error(" auth - register/login and print token"); + console.error("Usage: bot-client.ts [args]"); + console.error(" register [scope1,scope2,...] [paymentAddress] - create pending bot + claim code"); + console.error(" pickup - pickup botKeyId + secret after human claim"); + console.error(" auth - authenticate and print token"); console.error(" walletIds - list wallet IDs (requires auth first; set BOT_TOKEN)"); console.error(" pendingTransactions "); console.error(" freeUtxos "); @@ -202,9 +256,56 @@ async function main() { process.exit(1); } + if (cmd === "register") { + const name = process.argv[3]; + const scopesArg = process.argv[4] ?? "multisig:read"; + const paymentAddress = process.argv[5] ?? config.paymentAddress; + + if (!name) { + console.error("Usage: bot-client.ts register [scope1,scope2,...] [paymentAddress]"); + process.exit(1); + } + + if (!paymentAddress) { + console.error("paymentAddress is required for register (arg or config)."); + process.exit(1); + } + + const requestedScopes = scopesArg + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + + if (requestedScopes.length === 0) { + console.error("At least one scope is required for register."); + process.exit(1); + } + + const result = await registerBot(config.baseUrl, { + name, + paymentAddress, + requestedScopes, + }); + console.log(JSON.stringify(result, null, 2)); + console.error("Human must now claim this bot in UI using pendingBotId + claimCode."); + return; + } + + if (cmd === "pickup") { + const pendingBotId = process.argv[3]; + if (!pendingBotId) { + console.error("Usage: bot-client.ts pickup "); + process.exit(1); + } + const creds = await pickupBotSecret(config.baseUrl, pendingBotId); + console.log(JSON.stringify(creds, null, 2)); + console.error("Store botKeyId + secret in config, then run 'auth'."); + return; + } + if (cmd === "auth") { - if (!config.paymentAddress) { - console.error("paymentAddress is required in config for auth."); + if (!config.paymentAddress || !config.botKeyId || !config.secret) { + console.error("auth requires paymentAddress, botKeyId, and secret in config."); process.exit(1); } const { token, botId } = await botAuth(config); diff --git a/scripts/bot-ref/bot-config.sample.json b/scripts/bot-ref/bot-config.sample.json index b04538ca..1ee3e7a3 100644 --- a/scripts/bot-ref/bot-config.sample.json +++ b/scripts/bot-ref/bot-config.sample.json @@ -1,6 +1,7 @@ { "baseUrl": "http://localhost:3000", - "botKeyId": "", - "secret": "", - "paymentAddress": "" + "paymentAddress": "addr1_your_bot_payment_address_here", + "pendingBotId": "optional_pending_bot_id_from_register", + "botKeyId": "set_after_pickup", + "secret": "set_after_pickup" } diff --git a/src/components/common/overall-layout/layout.tsx b/src/components/common/overall-layout/layout.tsx index 68344637..2f2da2f8 100644 --- a/src/components/common/overall-layout/layout.tsx +++ b/src/components/common/overall-layout/layout.tsx @@ -731,4 +731,4 @@ export default function RootLayout({ )} ); -} +} \ No newline at end of file diff --git a/src/components/common/overall-layout/mobile-wrappers/logout-wrapper.tsx b/src/components/common/overall-layout/mobile-wrappers/logout-wrapper.tsx index 9648969b..41550ee4 100644 --- a/src/components/common/overall-layout/mobile-wrappers/logout-wrapper.tsx +++ b/src/components/common/overall-layout/mobile-wrappers/logout-wrapper.tsx @@ -17,6 +17,12 @@ export default function LogoutWrapper({ mode, onAction }: LogoutWrapperProps) { const setPastUtxosEnabled = useUserStore((state) => state.setPastUtxosEnabled); async function handleLogout() { + try { + await fetch("/api/auth/wallet-session", { method: "DELETE" }); + } catch (error) { + console.error("[Logout] Failed to clear wallet session cookie:", error); + } + // Disconnect regular wallet if connected if (connected) { disconnect(); diff --git a/src/components/common/overall-layout/mobile-wrappers/user-dropdown-wrapper.tsx b/src/components/common/overall-layout/mobile-wrappers/user-dropdown-wrapper.tsx index 46b5b8af..0d8f1a4a 100644 --- a/src/components/common/overall-layout/mobile-wrappers/user-dropdown-wrapper.tsx +++ b/src/components/common/overall-layout/mobile-wrappers/user-dropdown-wrapper.tsx @@ -173,6 +173,12 @@ export default function UserDropDownWrapper({ } async function handleLogout() { + try { + await fetch("/api/auth/wallet-session", { method: "DELETE" }); + } catch (error) { + console.error("[UserDropdown] Failed to clear wallet session cookie:", error); + } + // Disconnect regular wallet if connected if (connected) { disconnect(); diff --git a/src/components/common/overall-layout/user-drop-down.tsx b/src/components/common/overall-layout/user-drop-down.tsx index 9e5a2c0f..adf631b0 100644 --- a/src/components/common/overall-layout/user-drop-down.tsx +++ b/src/components/common/overall-layout/user-drop-down.tsx @@ -137,7 +137,12 @@ export default function UserDropDown() { )} { + onClick={async () => { + try { + await fetch("/api/auth/wallet-session", { method: "DELETE" }); + } catch (error) { + console.error("[Logout] Failed to clear wallet session cookie:", error); + } disconnect(); setPastWallet(undefined); router.push("/"); diff --git a/src/components/pages/homepage/wallets/new-wallet/index.tsx b/src/components/pages/homepage/wallets/new-wallet/index.tsx index 7c64b581..e4514d83 100644 --- a/src/components/pages/homepage/wallets/new-wallet/index.tsx +++ b/src/components/pages/homepage/wallets/new-wallet/index.tsx @@ -164,7 +164,7 @@ export default function PageNewWallet() { ); const { data: botKeys } = api.bot.listBotKeys.useQuery( - {}, + { requesterAddress: userAddress ?? "" }, { enabled: !!userAddress && !pathIsWalletInvite }, ); const botsWithAddress = (botKeys ?? []).filter( diff --git a/src/components/pages/user/BotManagementCard.tsx b/src/components/pages/user/BotManagementCard.tsx index e1afa210..fd1eca27 100644 --- a/src/components/pages/user/BotManagementCard.tsx +++ b/src/components/pages/user/BotManagementCard.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { Bot, Plus, Trash2, Copy, Loader2, Pencil } from "lucide-react"; +import { Bot, Trash2, Loader2, Pencil, Link } from "lucide-react"; import CardUI from "@/components/ui/card-content"; import RowLabelInfo from "@/components/ui/row-label-info"; import { Button } from "@/components/ui/button"; @@ -22,57 +22,67 @@ import { Label } from "@/components/ui/label"; import { Checkbox } from "@/components/ui/checkbox"; import { BOT_SCOPES, type BotScope } from "@/lib/auth/botKey"; import { Badge } from "@/components/ui/badge"; +import useUserWallets from "@/hooks/useUserWallets"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { useUserStore } from "@/lib/zustand/user"; const READ_SCOPE = "multisig:read" as const; +const BOT_WALLET_ROLES = ["observer", "cosigner"] as const; +type BotWalletRole = (typeof BOT_WALLET_ROLES)[number]; export default function BotManagementCard() { const { toast } = useToast(); - const [createOpen, setCreateOpen] = useState(false); - const [newName, setNewName] = useState(""); - const [newScopes, setNewScopes] = useState([]); - const [createdSecret, setCreatedSecret] = useState(null); - const [createdBotKeyId, setCreatedBotKeyId] = useState(null); const [editOpen, setEditOpen] = useState(false); const [editingBotKeyId, setEditingBotKeyId] = useState(null); const [editScopes, setEditScopes] = useState([]); - const { data: botKeys, isLoading } = api.bot.listBotKeys.useQuery({}); + // Claim dialog state + const [claimOpen, setClaimOpen] = useState(false); + const [claimStep, setClaimStep] = useState<"enterCode" | "review" | "success">("enterCode"); + const [pendingBotId, setPendingBotId] = useState(""); + const [claimCode, setClaimCode] = useState(""); + const [pendingBotInfo, setPendingBotInfo] = useState<{ + name: string; + paymentAddress: string; + requestedScopes: string[]; + } | null>(null); + const [approvedScopes, setApprovedScopes] = useState([]); + const [claimResult, setClaimResult] = useState<{ + botKeyId: string; + botId: string; + name: string; + scopes: BotScope[]; + } | null>(null); + const [lookupError, setLookupError] = useState(null); + const [selectedWalletByBot, setSelectedWalletByBot] = useState>({}); + const [selectedRoleByBot, setSelectedRoleByBot] = useState>({}); + const userAddress = useUserStore((state) => state.userAddress); + + const { data: botKeys, isLoading } = api.bot.listBotKeys.useQuery( + { requesterAddress: userAddress ?? "" }, + { enabled: !!userAddress }, + ); + const { wallets: userWallets, isLoading: isLoadingWallets } = useUserWallets(); const editingBotKey = botKeys?.find((key) => key.id === editingBotKeyId) ?? null; const utils = api.useUtils(); - const handleCloseCreate = () => { - setCreateOpen(false); - setNewName(""); - setNewScopes([]); - setCreatedSecret(null); - setCreatedBotKeyId(null); - }; - const handleCloseEdit = () => { setEditOpen(false); setEditingBotKeyId(null); setEditScopes([]); }; - const createBotKey = api.bot.createBotKey.useMutation({ - onSuccess: (data) => { - setCreatedSecret(data.secret); - setCreatedBotKeyId(data.botKeyId); - void utils.bot.listBotKeys.invalidate(); - toast({ - title: "Bot created", - description: "Copy the secret now; it will not be shown again.", - duration: 5000, - }); - }, - onError: (err) => { - toast({ - title: "Error", - description: err.message, - variant: "destructive", - }); - }, - }); + const handleCloseClaim = () => { + setClaimOpen(false); + setClaimStep("enterCode"); + setPendingBotId(""); + setClaimCode(""); + setPendingBotInfo(null); + setApprovedScopes([]); + setClaimResult(null); + setLookupError(null); + }; + const revokeBotKey = api.bot.revokeBotKey.useMutation({ onSuccess: () => { toast({ title: "Bot revoked" }); @@ -101,35 +111,86 @@ export default function BotManagementCard() { }, }); - const handleCopy = async (text: string, label: string) => { - try { - await navigator.clipboard.writeText(text); + const claimBot = api.bot.claimBot.useMutation({ + onSuccess: (data) => { + setClaimResult(data); + setClaimStep("success"); + void utils.bot.listBotKeys.invalidate(); toast({ - title: "Copied", - description: `${label} copied to clipboard`, - duration: 3000, + title: "Bot claimed", + description: `${data.name} is now linked to your account.`, }); - } catch { + }, + onError: (err) => { + const messages: Record = { + bot_not_found: "Bot not found or registration expired.", + bot_already_claimed: "This bot has already been claimed.", + invalid_or_expired_claim_code: "Invalid or expired claim code.", + claim_locked_out: "Too many failed attempts. Ask the bot to re-register.", + }; toast({ - title: "Error", - description: "Failed to copy", + title: "Claim failed", + description: messages[err.message] ?? err.message, variant: "destructive", }); - } - }; + }, + }); + + const grantBotAccess = api.bot.grantBotAccess.useMutation({ + onSuccess: () => { + toast({ title: "Wallet access saved", description: "Bot role was updated for the selected multisig." }); + void utils.bot.listBotKeys.invalidate(); + }, + onError: (err) => { + toast({ + title: "Grant failed", + description: err.message, + variant: "destructive", + }); + }, + }); - const handleCreate = () => { - if (!newName.trim() || newScopes.length === 0) { + const revokeBotAccess = api.bot.revokeBotAccess.useMutation({ + onSuccess: () => { + toast({ title: "Wallet access revoked" }); + void utils.bot.listBotKeys.invalidate(); + }, + onError: (err) => { toast({ - title: "Invalid input", - description: "Name and at least one scope required", + title: "Revoke failed", + description: err.message, variant: "destructive", }); - return; + }, + }); + + const handleLookupAndAdvance = async () => { + setLookupError(null); + try { + const info = await utils.bot.lookupPendingBot.fetch({ pendingBotId: pendingBotId.trim() }); + if (info.status !== "UNCLAIMED") { + setLookupError("This bot has already been claimed."); + return; + } + setPendingBotInfo(info); + const validScopes = info.requestedScopes.filter( + (s): s is BotScope => BOT_SCOPES.includes(s as BotScope), + ); + setApprovedScopes(validScopes); + setClaimStep("review"); + } catch { + setLookupError("Bot not found or registration expired."); } - createBotKey.mutate({ name: newName.trim(), scope: newScopes }); }; + const toggleClaimScope = (scope: BotScope) => { + setApprovedScopes((prev) => + prev.includes(scope) ? prev.filter((s) => s !== scope) : [...prev, scope], + ); + }; + + const missingReadScopeInClaim = approvedScopes.length > 0 && !approvedScopes.includes(READ_SCOPE); + const openEditDialog = (botKeyId: string, scopes: readonly BotScope[]) => { setEditingBotKeyId(botKeyId); setEditScopes([...scopes]); @@ -138,13 +199,12 @@ export default function BotManagementCard() { const handleSaveScopes = () => { if (!editingBotKeyId || editScopes.length === 0) return; - updateBotKeyScopes.mutate({ botKeyId: editingBotKeyId, scope: editScopes }); - }; - - const toggleScope = (scope: BotScope) => { - setNewScopes((prev) => - prev.includes(scope) ? prev.filter((s) => s !== scope) : [...prev, scope], - ); + if (!userAddress) return; + updateBotKeyScopes.mutate({ + botKeyId: editingBotKeyId, + requesterAddress: userAddress, + scope: editScopes, + }); }; const toggleEditScope = (scope: BotScope) => { @@ -153,116 +213,191 @@ export default function BotManagementCard() { ); }; - const missingReadScopeInCreate = newScopes.length > 0 && !newScopes.includes(READ_SCOPE); const missingReadScopeInEdit = editScopes.length > 0 && !editScopes.includes(READ_SCOPE); return (
Bots - { setCreateOpen(open); if (!open) handleCloseCreate(); }}> - - - - - - Create bot - - Create a bot key. The secret is shown only once; store it securely. - - - {createdSecret && createdBotKeyId ? ( + +
+ { + if (!open) handleCloseClaim(); + }} + > + + {claimStep === "enterCode" && ( + <> + + + + Claim a bot + + + Enter the bot ID and claim code from your bot's output. + +
-

Copy the JSON blob now. The secret will not be shown again.

-

- Pass this to your bot (or save as bot-config.json). Set paymentAddress to the bot's Cardano address before calling POST /api/v1/botAuth. -

-
-                    {JSON.stringify(
-                      { botKeyId: createdBotKeyId, secret: createdSecret, paymentAddress: "" },
-                      null,
-                      2,
-                    )}
-                  
+
+ + setPendingBotId(e.target.value)} + /> +
+
+ + setClaimCode(e.target.value)} + /> +
+ {lookupError && ( +

{lookupError}

+ )} +
+ + -

- Then use POST /api/v1/botAuth with this JSON (with paymentAddress set). The bot can also sign in via getNonce + authSigner using that wallet. See scripts/bot-ref/ for a reference client. -

- - - -
- ) : ( - <> -
- - setNewName(e.target.value)} + + + )} + + {claimStep === "review" && pendingBotInfo && ( + <> + + + + Claim a bot + + + Review the bot's details and approve its requested permissions. + + +
+
+ +
- +
- {BOT_SCOPES.map((scope) => ( -
- toggleScope(scope)} - /> - -
- ))} + {BOT_SCOPES.map((scope) => { + const requested = pendingBotInfo.requestedScopes.includes(scope); + return ( +
+ toggleClaimScope(scope)} + disabled={!requested} + /> + +
+ ); + })}
- {missingReadScopeInCreate && ( + {missingReadScopeInClaim && (

Warning: without multisig:read, POST /api/v1/botAuth authentication will fail for this bot key.

)}
- - - - - )} - - -
+
+ + + + + + )} + + {claimStep === "success" && claimResult && ( + <> + + Bot claimed successfully + + “{claimResult.name}” is now linked to your account. The bot will automatically pick up its credentials. + + +
+ + +
+ Scopes +
+ {claimResult.scopes.map((scope) => ( + + {scope} + + ))} +
+
+
+ + + + + )} + + { @@ -321,7 +456,7 @@ export default function BotManagementCard() { Loading bots… ) : !botKeys?.length ? ( -

No bots yet. Create one to allow API access with a bot key or wallet sign-in.

+

No bots yet. Register a bot and claim it to enable API access.

) : (
    {botKeys.map((key) => { @@ -347,10 +482,11 @@ export default function BotManagementCard() { className="text-destructive hover:text-destructive" onClick={() => { if (confirm("Revoke this bot? The bot will no longer be able to authenticate.")) { - revokeBotKey.mutate({ botKeyId: key.id }); + if (!userAddress) return; + revokeBotKey.mutate({ botKeyId: key.id, requesterAddress: userAddress }); } }} - disabled={revokeBotKey.isPending} + disabled={revokeBotKey.isPending || !userAddress} > @@ -376,16 +512,148 @@ export default function BotManagementCard() { )} {key.botUser ? ( + (() => { + const botUser = key.botUser; + return ( <> - {key.botUser.displayName && ( - + {botUser.displayName && ( + )} + +
    +

    Wallet access

    + {isLoadingWallets ? ( +

    Loading multisigs...

    + ) : !userWallets?.length ? ( +

    No multisigs available for access grants.

    + ) : ( + <> +
    + {(() => { + const selectedWalletId = selectedWalletByBot[botUser.id] ?? userWallets[0]?.id ?? ""; + const currentAccess = key.botWalletAccesses?.find( + (access) => access.walletId === selectedWalletId, + ); + const selectedWallet = userWallets.find((wallet) => wallet.id === selectedWalletId); + const canBeCosigner = Boolean( + selectedWallet?.signersAddresses?.includes(botUser.paymentAddress), + ); + const selectedRole = selectedRoleByBot[botUser.id] ?? currentAccess?.role ?? "observer"; + const effectiveSelectedRole = + selectedRole === "cosigner" && !canBeCosigner ? "observer" : selectedRole; + + return ( + <> + + + + +

    + {currentAccess + ? `Current access on selected wallet: ${currentAccess.role}` + : "Current access on selected wallet: none"} +

    + {!canBeCosigner && ( +

    + Cosigner is only available when the bot payment address is included in this wallet's signer list. +

    + )} + + ); + })()} +
    +

    + Select a wallet and role to grant or update bot access. +

    + + )} +
    + ); + })() ) : (

    Not registered yet. Use botAuth with this key to register the bot wallet.

    )} diff --git a/src/components/pages/wallet/governance/drep/registerDrep.tsx b/src/components/pages/wallet/governance/drep/registerDrep.tsx index 1c46af35..0c789ac3 100644 --- a/src/components/pages/wallet/governance/drep/registerDrep.tsx +++ b/src/components/pages/wallet/governance/drep/registerDrep.tsx @@ -1,7 +1,6 @@ import { Plus } from "lucide-react"; import { useState, useCallback } from "react"; import useAppWallet from "@/hooks/useAppWallet"; -import { useWallet } from "@meshsdk/react"; import { useUserStore } from "@/lib/zustand/user"; import { useSiteStore } from "@/lib/zustand/site"; import { getTxBuilder } from "@/utils/get-tx-builder"; @@ -14,7 +13,6 @@ import type { UTxO } from "@meshsdk/core"; import router from "next/router"; import useMultisigWallet from "@/hooks/useMultisigWallet"; import { MeshProxyContract } from "@/components/multisig/proxy/offchain"; -import { getProvider } from "@/utils/get-provider"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { api } from "@/utils/api"; @@ -33,8 +31,7 @@ interface RegisterDRepProps { export default function RegisterDRep({ onClose }: RegisterDRepProps = {}) { const { appWallet } = useAppWallet(); - const { connected, wallet } = useWallet(); - const { isAnyWalletConnected, isWalletReady } = useActiveWallet(); + const { activeWallet } = useActiveWallet(); const userAddress = useUserStore((state) => state.userAddress); const network = useSiteStore((state) => state.network); const loading = useSiteStore((state) => state.loading); @@ -95,16 +92,6 @@ export default function RegisterDRep({ onClose }: RegisterDRepProps = {}) { }); throw new Error(errorMessage); } - if (!multisigWallet) { - const errorMessage = "Multisig wallet not connected. Please ensure your multisig wallet is properly configured."; - toast({ - title: "Multisig Wallet Not Connected", - description: errorMessage, - variant: "destructive", - duration: 5000, - }); - throw new Error(errorMessage); - } // Get metadata with both compacted (for upload) and normalized (for hashing) forms const metadataResult = await getDRepMetadata( formState, @@ -133,8 +120,8 @@ export default function RegisterDRep({ onClose }: RegisterDRepProps = {}) { } async function registerDrep(): Promise { - if (!isWalletReady || !userAddress || !multisigWallet || !appWallet) { - const errorMessage = "Multisig wallet not connected. Please ensure your wallet is connected and try again."; + if (!activeWallet || !userAddress || !appWallet) { + const errorMessage = "Wallet not connected. Please ensure your wallet is connected and try again."; toast({ title: "Wallet Not Connected", description: errorMessage, @@ -263,8 +250,13 @@ export default function RegisterDRep({ onClose }: RegisterDRepProps = {}) { } async function registerProxyDrep(): Promise { - if (!isWalletReady || !userAddress || !multisigWallet || !appWallet) { - const errorMessage = "Multisig wallet not connected. Please ensure your wallet is connected and try again."; + if (!hasValidProxy) { + // Fall back to standard registration if no valid proxy + return registerDrep(); + } + + if (!activeWallet || !userAddress || !multisigWallet || !appWallet) { + const errorMessage = "Proxy registration requires both connected wallet signer and multisig wallet configuration."; toast({ title: "Wallet Not Connected", description: errorMessage, @@ -274,11 +266,6 @@ export default function RegisterDRep({ onClose }: RegisterDRepProps = {}) { throw new Error(errorMessage); } - if (!hasValidProxy) { - // Fall back to standard registration if no valid proxy - return registerDrep(); - } - setLoading(true); try { const { anchorUrl, anchorHash } = await createAnchor(); @@ -298,7 +285,7 @@ export default function RegisterDRep({ onClose }: RegisterDRepProps = {}) { const proxyContract = new MeshProxyContract( { mesh: txBuilder, - wallet: wallet, + wallet: activeWallet, networkId: network, }, { diff --git a/src/components/pages/wallet/governance/drep/retire.tsx b/src/components/pages/wallet/governance/drep/retire.tsx index afa9e726..1e454ee9 100644 --- a/src/components/pages/wallet/governance/drep/retire.tsx +++ b/src/components/pages/wallet/governance/drep/retire.tsx @@ -4,7 +4,6 @@ import { useSiteStore } from "@/lib/zustand/site"; import { getProvider } from "@/utils/get-provider"; import { getTxBuilder } from "@/utils/get-tx-builder"; import { keepRelevant, Quantity, Unit, UTxO } from "@meshsdk/core"; -import { useWallet } from "@meshsdk/react"; import { useUserStore } from "@/lib/zustand/user"; import { useWalletsStore } from "@/lib/zustand/wallets"; import useTransaction from "@/hooks/useTransaction"; @@ -15,10 +14,11 @@ import { api } from "@/utils/api"; import { useCallback } from "react"; import { useToast } from "@/hooks/use-toast"; import { DREP_DEPOSIT_STRING } from "@/utils/protocol-deposit-constants"; +import useActiveWallet from "@/hooks/useActiveWallet"; export default function Retire({ appWallet, manualUtxos }: { appWallet: Wallet; manualUtxos: UTxO[] }) { const network = useSiteStore((state) => state.network); - const { wallet, connected } = useWallet(); + const { activeWallet } = useActiveWallet(); const userAddress = useUserStore((state) => state.userAddress); const drepInfo = useWalletsStore((state) => state.drepInfo); const { newTransaction } = useTransaction(); @@ -53,18 +53,19 @@ export default function Retire({ appWallet, manualUtxos }: { appWallet: Wallet; }, [multisigWallet?.getScript().address, manualUtxos]); async function retireProxyDrep(): Promise { - if (!connected || !userAddress || !multisigWallet || !appWallet) { + if (!hasValidProxy) { + // Fall back to standard retire if no valid proxy + return retireDrep(); + } + + if (!activeWallet || !userAddress || !multisigWallet || !appWallet) { toast({ title: "Connection Error", - description: "Multisig wallet not connected", + description: "Proxy retirement requires both connected wallet signer and multisig wallet configuration.", variant: "destructive", }); return; } - if (!hasValidProxy) { - // Fall back to standard retire if no valid proxy - return retireDrep(); - } setLoading(true); @@ -96,7 +97,7 @@ export default function Retire({ appWallet, manualUtxos }: { appWallet: Wallet; const proxyContract = new MeshProxyContract( { mesh: txBuilder, - wallet: wallet, + wallet: activeWallet, networkId: network, }, { @@ -127,7 +128,7 @@ export default function Retire({ appWallet, manualUtxos }: { appWallet: Wallet; } async function retireDrep() { - if (!connected) { + if (!activeWallet) { toast({ title: "Connection Error", description: "Not connected to wallet", @@ -143,15 +144,6 @@ export default function Retire({ appWallet, manualUtxos }: { appWallet: Wallet; }); return; } - if (!multisigWallet) { - toast({ - title: "Wallet Error", - description: "Multisig Wallet could not be built", - variant: "destructive", - }); - return; - } - setLoading(true); try { diff --git a/src/lib/auth/botKey.ts b/src/lib/auth/botKey.ts index 9562c59c..66e4b310 100644 --- a/src/lib/auth/botKey.ts +++ b/src/lib/auth/botKey.ts @@ -1,4 +1,4 @@ -import { createHmac, randomBytes, timingSafeEqual } from "crypto"; +import { createHash, createHmac, randomBytes, timingSafeEqual } from "crypto"; const BOT_KEY_BYTES = 32; const HASH_ENCODING = "hex"; @@ -54,3 +54,13 @@ export function parseScope(scope: string): BotScope[] { export function scopeIncludes(parsed: BotScope[], required: BotScope): boolean { return parsed.includes(required); } + +/** Generate a random base64url claim code (32 bytes → ~43 chars). */ +export function generateClaimCode(): string { + return randomBytes(32).toString("base64url"); +} + +/** SHA-256 hash (hex). */ +export function sha256(input: string): string { + return createHash("sha256").update(input).digest("hex"); +} diff --git a/src/lib/auth/claimBot.ts b/src/lib/auth/claimBot.ts new file mode 100644 index 00000000..2a7f59c4 --- /dev/null +++ b/src/lib/auth/claimBot.ts @@ -0,0 +1,155 @@ +import { timingSafeEqual } from "crypto"; +import type { PrismaClient, Prisma } from "@prisma/client"; +import { + generateBotKeySecret, + hashBotKeySecret, + sha256, + BOT_SCOPES, + type BotScope, +} from "@/lib/auth/botKey"; + +const MAX_CLAIM_ATTEMPTS = 3; + +export class ClaimError extends Error { + constructor( + public status: number, + public code: string, + message: string, + ) { + super(message); + this.name = "ClaimError"; + } +} + +export interface ClaimBotInput { + pendingBotId: string; + claimCode: string; + approvedScopes?: BotScope[] | null; + ownerAddress: string; +} + +export interface ClaimBotResult { + botKeyId: string; + botId: string; + name: string; + scopes: BotScope[]; +} + +type TxClient = Omit; + +/** + * Core claim logic shared between the REST endpoint and tRPC procedure. + * Must be called within a Prisma transaction (or with a transaction client). + */ +export async function performClaim( + tx: TxClient, + input: ClaimBotInput, +): Promise { + const { pendingBotId, claimCode, approvedScopes, ownerAddress } = input; + const incomingHash = sha256(claimCode); + + // Load PendingBot + const pendingBot = await tx.pendingBot.findUnique({ + where: { id: pendingBotId }, + include: { claimToken: true }, + }); + + if (!pendingBot) { + throw new ClaimError(404, "bot_not_found", "Bot not found or registration expired"); + } + + if (pendingBot.status === "CLAIMED") { + throw new ClaimError(409, "bot_already_claimed", "This bot has already been claimed"); + } + + if (pendingBot.expiresAt < new Date()) { + throw new ClaimError(409, "invalid_or_expired_claim_code", "Registration has expired"); + } + + const claimToken = pendingBot.claimToken; + if (!claimToken || claimToken.consumedAt) { + throw new ClaimError(409, "invalid_or_expired_claim_code", "Claim token not found or already consumed"); + } + + if (claimToken.expiresAt < new Date()) { + throw new ClaimError(409, "invalid_or_expired_claim_code", "Claim code has expired"); + } + + if (claimToken.attempts >= MAX_CLAIM_ATTEMPTS) { + throw new ClaimError(409, "claim_locked_out", "Too many failed attempts. Ask the bot to re-register."); + } + + // Constant-time hash comparison + const storedBuf = Buffer.from(claimToken.tokenHash, "hex"); + const incomingBuf = Buffer.from(incomingHash, "hex"); + + let hashMatch = false; + if (storedBuf.length === incomingBuf.length) { + hashMatch = timingSafeEqual(storedBuf, incomingBuf); + } + + if (!hashMatch) { + await tx.botClaimToken.update({ + where: { id: claimToken.id }, + data: { attempts: claimToken.attempts + 1 }, + }); + throw new ClaimError(409, "invalid_or_expired_claim_code", "Invalid claim code"); + } + + // Parse requested scopes + const requestedScopes = JSON.parse(pendingBot.requestedScopes) as BotScope[]; + + // Determine final scopes — default to requested if not narrowed + const scopes = approvedScopes ?? requestedScopes; + + // Validate approvedScopes is a subset of requestedScopes + const invalidScopes = scopes.filter((s) => !requestedScopes.includes(s)); + if (invalidScopes.length > 0) { + throw new ClaimError(400, "invalid_claim_payload", "approvedScopes must be a subset of requestedScopes"); + } + + // Generate secret and create BotKey + BotUser + const secret = generateBotKeySecret(); + const keyHash = hashBotKeySecret(secret); + + const botKey = await tx.botKey.create({ + data: { + ownerAddress, + name: pendingBot.name, + keyHash, + scope: JSON.stringify(scopes), + }, + }); + + const botUser = await tx.botUser.create({ + data: { + botKeyId: botKey.id, + paymentAddress: pendingBot.paymentAddress, + stakeAddress: pendingBot.stakeAddress, + displayName: pendingBot.name, + }, + }); + + // Update PendingBot + await tx.pendingBot.update({ + where: { id: pendingBotId }, + data: { + status: "CLAIMED", + claimedBy: ownerAddress, + secretCipher: secret, // Store plain secret for one-time pickup + }, + }); + + // Mark claim token consumed + await tx.botClaimToken.update({ + where: { id: claimToken.id }, + data: { consumedAt: new Date() }, + }); + + return { + botKeyId: botKey.id, + botId: botUser.id, + name: pendingBot.name, + scopes, + }; +} diff --git a/src/pages/api-docs.tsx b/src/pages/api-docs.tsx index 435ee57d..fffc47b0 100644 --- a/src/pages/api-docs.tsx +++ b/src/pages/api-docs.tsx @@ -381,4 +381,4 @@ export default function ApiDocs() { ); -} +} \ No newline at end of file diff --git a/src/pages/api/auth/wallet-session.ts b/src/pages/api/auth/wallet-session.ts index 93e4fe50..f381ebe3 100644 --- a/src/pages/api/auth/wallet-session.ts +++ b/src/pages/api/auth/wallet-session.ts @@ -74,4 +74,3 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } } - diff --git a/src/pages/api/v1/README.md b/src/pages/api/v1/README.md index 89a2adfd..a1666ebc 100644 --- a/src/pages/api/v1/README.md +++ b/src/pages/api/v1/README.md @@ -118,7 +118,7 @@ A comprehensive REST API implementation for the multisig wallet application, pro - **Purpose**: Return the authenticated bot's own info, including its owner's address (bot JWT only) - **Authentication**: Required (bot JWT Bearer token) - **Features**: - - Bot can discover "my owner's address" (the human who created the bot key) for flows like creating a 2-of-2 with the owner + - Bot can discover "my owner's address" (the human who claimed the bot) for flows like creating a 2-of-2 with the owner - **Response**: `{ botId, paymentAddress, displayName, botName, ownerAddress }` (200) - **Error Handling**: 401 (auth), 403 (not a bot token), 404 (bot not found), 500 (server) @@ -132,17 +132,17 @@ A comprehensive REST API implementation for the multisig wallet application, pro - Supports `atLeast` / `all` / `any` script types and optional external stake credential - **Request Body**: - `name`: string (required, 1–256 chars) - - `description`: string (optional) + - `description`: string (optional, truncated to 2000 chars) - `signersAddresses`: string[] (required, Cardano payment addresses) - - `signersDescriptions`: string[] (optional, same length as signersAddresses) - - `signersStakeKeys`: (string | null)[] (optional) + - `signersDescriptions`: string[] (optional; missing entries default to `""`) + - `signersStakeKeys`: (string | null)[] (optional; used only when `stakeCredentialHash` is not provided) - `signersDRepKeys`: (string | null)[] (optional) - - `numRequiredSigners`: number (optional, default 1; ignored for `all`/`any`) + - `numRequiredSigners`: number (optional, minimum 1, clamped to signer count, default 1; stored as `null` for `all`/`any`) - `scriptType`: `"atLeast"` | `"all"` | `"any"` (optional, default `"atLeast"`) - `stakeCredentialHash`: string (optional, external stake) - `network`: 0 | 1 (optional, default 1 = mainnet) - **Response**: `{ walletId, address, name }` (201) -- **Error Handling**: 400 (validation), 401 (auth), 403 (not bot or insufficient scope), 429 (rate limit), 500 (server) +- **Error Handling**: 400 (validation/script build), 401 (missing/invalid token or bot not found), 403 (not bot token or insufficient scope), 405 (method), 429 (rate limit), 500 (server) #### `governanceActiveProposals.ts` - GET `/api/v1/governanceActiveProposals` @@ -264,10 +264,62 @@ A comprehensive REST API implementation for the multisig wallet application, pro - **Response**: JWT token object - **Error Handling**: 400 (validation), 401 (signature), 500 (server) +#### `botRegister.ts` - POST `/api/v1/botRegister` + +- **Purpose**: Self-register a bot and issue a short-lived claim code for human approval +- **Authentication**: Not required (public endpoint) +- **Features**: + - Creates a `PendingBot` record in `UNCLAIMED` state + - Generates one-time claim code and hashed claim token + - Validates requested scopes against allowed bot scopes + - Rejects already-registered bot payment addresses + - Strict rate limiting and 2 KB body size cap +- **Request Body**: + - `name`: string (required, 1-100 chars) + - `paymentAddress`: string (required) + - `stakeAddress`: string (optional) + - `requestedScopes`: string[] (required, non-empty, valid scope values) + - Allowed scope values: `multisig:create`, `multisig:read`, `multisig:sign`, `governance:read`, `ballot:write` +- **Response**: `{ pendingBotId, claimCode, claimExpiresAt }` (201) +- **Error Handling**: 400 (validation), 405 (method), 409 (address conflict), 429 (rate limit), 500 (server) + +#### `botClaim.ts` - POST `/api/v1/botClaim` + +- **Purpose**: Claim a pending bot as a human user and mint its bot key credentials +- **Authentication**: Required (human JWT Bearer token; bot tokens are rejected) +- **Features**: + - Verifies claim code using constant-time hash comparison + - Enforces claim attempt lockout and expiry + - Creates `BotKey` + `BotUser` and links ownership to claimer address + - Accepts optional `approvedScopes` to narrow requested scopes + - Stores one-time pickup secret on `PendingBot` for retrieval by the bot +- **Request Body**: + - `pendingBotId`: string (required) + - `claimCode`: string (required) + - `approvedScopes`: string[] (optional; must be subset of requested scopes) + - Allowed scope values: `multisig:create`, `multisig:read`, `multisig:sign`, `governance:read`, `ballot:write` +- **Response**: `{ botKeyId, botId, name, scopes }` (200) +- **Error Handling**: 400 (validation), 401 (auth), 404 (not found/expired), 405 (method), 409 (invalid claim/already claimed/locked out), 429 (rate limit), 500 (server) + +#### `botPickupSecret.ts` - GET `/api/v1/botPickupSecret` + +- **Purpose**: Allow a claimed bot to retrieve credentials exactly once +- **Authentication**: Not required (public endpoint; possession of `pendingBotId` is required) +- **Features**: + - Returns `botKeyId` + one-time `secret` once claim is complete + - Enforces state checks (`CLAIMED`, not already picked up) + - Marks secret as consumed (`pickedUp=true`, clears stored secret) + - Includes bot `paymentAddress` in response for convenience +- **Query Parameters**: + - `pendingBotId`: string (required) +- **Response**: `{ botKeyId, secret, paymentAddress }` (200) +- **Error Handling**: 400 (validation), 404 (not found/not yet claimed), 405 (method), 410 (already picked up), 429 (rate limit), 500 (server) + #### `botAuth.ts` - POST `/api/v1/botAuth` - **Purpose**: Authenticate a bot key and return a bot-scoped JWT bearer token - **Authentication**: Not required (public endpoint; credentials in request body) +- **Onboarding Note**: `botKeyId` and `secret` are obtained from the claim flow (`POST /api/v1/botRegister` -> human `POST /api/v1/botClaim` -> `GET /api/v1/botPickupSecret`), not from manual bot creation. - **Features**: - Bot key secret verification against stored hash - Minimum scope enforcement (`multisig:read`) @@ -314,6 +366,14 @@ A comprehensive REST API implementation for the multisig wallet application, pro 3. **Token Exchange**: Client exchanges signature for JWT token 4. **API Access**: Client uses JWT token for authenticated requests +### Bot Onboarding Flow + +1. **Bot Registers**: Bot calls `POST /api/v1/botRegister` with requested scopes +2. **Human Claims**: Owner calls `POST /api/v1/botClaim` with JWT + claim code +3. **Bot Picks Up Secret**: Bot calls `GET /api/v1/botPickupSecret` once +4. **Bot Authenticates**: Bot calls `POST /api/v1/botAuth` to receive bot JWT +5. **Bot API Access**: Bot uses JWT for bot endpoints (e.g. `botMe`, `createWallet`, governance APIs) + ### Error Handling - **HTTP Status Codes**: Proper status code usage diff --git a/src/pages/api/v1/botClaim.ts b/src/pages/api/v1/botClaim.ts new file mode 100644 index 00000000..49b9744c --- /dev/null +++ b/src/pages/api/v1/botClaim.ts @@ -0,0 +1,106 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { db } from "@/server/db"; +import { cors, addCorsCacheBustingHeaders } from "@/lib/cors"; +import { applyRateLimit, enforceBodySize } from "@/lib/security/requestGuards"; +import { verifyJwt, isBotJwt } from "@/lib/verifyJwt"; +import { BOT_SCOPES, type BotScope } from "@/lib/auth/botKey"; +import { ClaimError, performClaim } from "@/lib/auth/claimBot"; +import { getWalletSessionFromReq } from "@/lib/auth/walletSession"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + addCorsCacheBustingHeaders(res); + + if (!applyRateLimit(req, res, { keySuffix: "v1/botClaim" })) { + return; + } + + await cors(req, res); + if (req.method === "OPTIONS") { + return res.status(200).end(); + } + + if (req.method !== "POST") { + return res.status(405).json({ error: "Method Not Allowed" }); + } + + if (!enforceBodySize(req, res, 2 * 1024)) { + return; + } + + // --- Verify human JWT --- + + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return res.status(401).json({ error: "unauthorized", message: "Missing or invalid Authorization header" }); + } + + const jwt = verifyJwt(authHeader.slice(7)); + if (!jwt) { + return res.status(401).json({ error: "unauthorized", message: "Invalid or expired token" }); + } + + if (isBotJwt(jwt)) { + return res.status(401).json({ error: "unauthorized", message: "Bot tokens cannot claim bots" }); + } + + const walletSession = getWalletSessionFromReq(req); + const walletSessionAddress = + walletSession?.primaryWallet ?? (walletSession?.wallets?.[0] ?? null); + const ownerAddress = walletSessionAddress ?? jwt.address; + + if (walletSessionAddress && walletSessionAddress !== jwt.address) { + console.warn("[v1/botClaim] Address source mismatch", { + jwtAddress: jwt.address, + walletSessionAddress, + selectedAddress: ownerAddress, + }); + } + + // --- Validate input --- + + const { pendingBotId, claimCode, approvedScopes } = req.body; + + if (typeof pendingBotId !== "string" || pendingBotId.length < 1) { + return res.status(400).json({ error: "invalid_claim_payload", message: "pendingBotId is required" }); + } + + if (typeof claimCode !== "string" || claimCode.length < 24) { + return res.status(400).json({ error: "invalid_claim_payload", message: "claimCode must be at least 24 characters" }); + } + + // Validate approvedScopes if provided + let finalScopes: BotScope[] | null = null; + if (approvedScopes !== undefined && approvedScopes !== null) { + if (!Array.isArray(approvedScopes)) { + return res.status(400).json({ error: "invalid_claim_payload", message: "approvedScopes must be an array" }); + } + const valid = approvedScopes.filter( + (s): s is BotScope => typeof s === "string" && (BOT_SCOPES as readonly string[]).includes(s), + ); + if (valid.length !== approvedScopes.length) { + return res.status(400).json({ error: "invalid_claim_payload", message: "approvedScopes contains invalid scope values" }); + } + finalScopes = valid; + } + + // --- Perform claim --- + + try { + const result = await db.$transaction(async (tx) => { + return performClaim(tx, { + pendingBotId, + claimCode, + approvedScopes: finalScopes, + ownerAddress, + }); + }); + + return res.status(200).json(result); + } catch (err) { + if (err instanceof ClaimError) { + return res.status(err.status).json({ error: err.code, message: err.message }); + } + console.error("botClaim error:", err); + return res.status(500).json({ error: "internal_error", message: "An unexpected error occurred" }); + } +} diff --git a/src/pages/api/v1/botPickupSecret.ts b/src/pages/api/v1/botPickupSecret.ts new file mode 100644 index 00000000..f97abfb3 --- /dev/null +++ b/src/pages/api/v1/botPickupSecret.ts @@ -0,0 +1,99 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { db } from "@/server/db"; +import { cors, addCorsCacheBustingHeaders } from "@/lib/cors"; +import { applyStrictRateLimit } from "@/lib/security/requestGuards"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + addCorsCacheBustingHeaders(res); + + if (!applyStrictRateLimit(req, res, { keySuffix: "v1/botPickupSecret" })) { + return; + } + + await cors(req, res); + if (req.method === "OPTIONS") { + return res.status(200).end(); + } + + if (req.method !== "GET") { + return res.status(405).json({ error: "Method Not Allowed" }); + } + + // --- Validate query param --- + + const { pendingBotId } = req.query; + + if (typeof pendingBotId !== "string" || pendingBotId.length < 1) { + return res.status(400).json({ error: "invalid_pickup_payload", message: "pendingBotId query parameter is required" }); + } + + // --- Load PendingBot and return secret --- + + try { + const result = await db.$transaction(async (tx) => { + const pendingBot = await tx.pendingBot.findUnique({ + where: { id: pendingBotId }, + }); + + if (!pendingBot) { + throw new PickupError(404, "not_found", "No pending bot found with that ID"); + } + + if (pendingBot.status !== "CLAIMED") { + throw new PickupError(404, "not_yet_claimed", "Bot has not been claimed yet"); + } + + if (pendingBot.pickedUp) { + throw new PickupError(410, "already_picked_up", "Secret has already been collected"); + } + + const secret = pendingBot.secretCipher; + if (!secret) { + throw new PickupError(410, "already_picked_up", "Secret is no longer available"); + } + + // Find the BotUser by paymentAddress (unique) to get the botKeyId + const botUser = await tx.botUser.findUnique({ + where: { paymentAddress: pendingBot.paymentAddress }, + }); + + if (!botUser) { + throw new PickupError(500, "internal_error", "Bot user not found"); + } + + // Mark as picked up and clear the secret + await tx.pendingBot.update({ + where: { id: pendingBotId }, + data: { + pickedUp: true, + secretCipher: null, + }, + }); + + return { + botKeyId: botUser.botKeyId, + secret, + paymentAddress: pendingBot.paymentAddress, + }; + }); + + return res.status(200).json(result); + } catch (err) { + if (err instanceof PickupError) { + return res.status(err.status).json({ error: err.code, message: err.message }); + } + console.error("botPickupSecret error:", err); + return res.status(500).json({ error: "internal_error", message: "An unexpected error occurred" }); + } +} + +class PickupError extends Error { + constructor( + public status: number, + public code: string, + message: string, + ) { + super(message); + this.name = "PickupError"; + } +} diff --git a/src/pages/api/v1/botRegister.ts b/src/pages/api/v1/botRegister.ts new file mode 100644 index 00000000..b40faecd --- /dev/null +++ b/src/pages/api/v1/botRegister.ts @@ -0,0 +1,104 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { db } from "@/server/db"; +import { cors, addCorsCacheBustingHeaders } from "@/lib/cors"; +import { applyStrictRateLimit, enforceBodySize } from "@/lib/security/requestGuards"; +import { generateClaimCode, sha256, BOT_SCOPES, type BotScope } from "@/lib/auth/botKey"; + +const CLAIM_CODE_TTL_MS = 10 * 60 * 1000; // 10 minutes + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + addCorsCacheBustingHeaders(res); + + if (!applyStrictRateLimit(req, res, { keySuffix: "v1/botRegister" })) { + return; + } + + await cors(req, res); + if (req.method === "OPTIONS") { + return res.status(200).end(); + } + + if (req.method !== "POST") { + return res.status(405).json({ error: "Method Not Allowed" }); + } + + if (!enforceBodySize(req, res, 2 * 1024)) { + return; + } + + const { name, paymentAddress, stakeAddress, requestedScopes } = req.body; + + // --- Validate input --- + + if (typeof name !== "string" || name.length < 1 || name.length > 100) { + return res.status(400).json({ error: "invalid_registration_payload", message: "name must be a string between 1 and 100 characters" }); + } + + if (typeof paymentAddress !== "string" || paymentAddress.length < 20) { + return res.status(400).json({ error: "invalid_registration_payload", message: "Invalid paymentAddress" }); + } + + if (stakeAddress !== undefined && stakeAddress !== null && typeof stakeAddress !== "string") { + return res.status(400).json({ error: "invalid_registration_payload", message: "stakeAddress must be a string if provided" }); + } + + if (!Array.isArray(requestedScopes) || requestedScopes.length === 0) { + return res.status(400).json({ error: "invalid_registration_payload", message: "requestedScopes must be a non-empty array" }); + } + + const validScopes = requestedScopes.filter( + (s): s is BotScope => typeof s === "string" && (BOT_SCOPES as readonly string[]).includes(s), + ); + if (validScopes.length !== requestedScopes.length) { + return res.status(400).json({ error: "invalid_registration_payload", message: "requestedScopes contains invalid scope values" }); + } + + // --- Check address not already registered to a claimed bot or existing BotUser --- + + const existingBotUser = await db.botUser.findUnique({ where: { paymentAddress } }); + if (existingBotUser) { + return res.status(409).json({ error: "address_already_registered", message: "This address is already registered to a bot" }); + } + + const existingClaimed = await db.pendingBot.findFirst({ + where: { paymentAddress, status: "CLAIMED", pickedUp: false }, + }); + if (existingClaimed) { + return res.status(409).json({ error: "address_already_registered", message: "This address has a pending claimed bot" }); + } + + // --- Generate claim code and create records --- + + const claimCode = generateClaimCode(); + const tokenHash = sha256(claimCode); + const expiresAt = new Date(Date.now() + CLAIM_CODE_TTL_MS); + + const pendingBot = await db.$transaction(async (tx) => { + const bot = await tx.pendingBot.create({ + data: { + name, + paymentAddress, + stakeAddress: typeof stakeAddress === "string" ? stakeAddress : null, + requestedScopes: JSON.stringify(validScopes), + status: "UNCLAIMED", + expiresAt, + }, + }); + + await tx.botClaimToken.create({ + data: { + pendingBotId: bot.id, + tokenHash, + expiresAt, + }, + }); + + return bot; + }); + + return res.status(201).json({ + pendingBotId: pendingBot.id, + claimCode, + claimExpiresAt: expiresAt.toISOString(), + }); +} diff --git a/src/server/api/routers/auth.ts b/src/server/api/routers/auth.ts index fd96254c..a7ba0b04 100644 --- a/src/server/api/routers/auth.ts +++ b/src/server/api/routers/auth.ts @@ -17,4 +17,3 @@ export const authRouter = createTRPCRouter({ }), }); - diff --git a/src/server/api/routers/bot.ts b/src/server/api/routers/bot.ts index a48727fe..62629839 100644 --- a/src/server/api/routers/bot.ts +++ b/src/server/api/routers/bot.ts @@ -1,64 +1,126 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; -import { hashBotKeySecret, generateBotKeySecret, BOT_SCOPES, parseScope } from "@/lib/auth/botKey"; +import { BOT_SCOPES, parseScope, type BotScope } from "@/lib/auth/botKey"; +import { ClaimError, performClaim } from "@/lib/auth/claimBot"; import { BotWalletRole } from "@prisma/client"; -function requireSessionAddress(ctx: unknown): string { - const c = ctx as { session?: { user?: { id?: string } } | null; sessionAddress?: string | null }; - const address = c.session?.user?.id ?? c.sessionAddress; - if (!address) throw new TRPCError({ code: "UNAUTHORIZED" }); - return address; +type SessionAddressContext = { + primaryWallet?: string | null; + sessionWallets?: string[]; +}; + +function requireSessionAddress( + ctx: unknown, + options?: { + requesterAddress?: string; + requireWalletSession?: boolean; + }, +): string { + const c = ctx as SessionAddressContext; + const requestedAddress = options?.requesterAddress?.trim() || null; + const sessionWallets = Array.isArray(c.sessionWallets) ? c.sessionWallets : []; + const hasWalletSession = Boolean(c.primaryWallet) || sessionWallets.length > 0; + const walletSessionMatchesRequested = + requestedAddress !== null && + (c.primaryWallet === requestedAddress || sessionWallets.includes(requestedAddress)); + + if (options?.requireWalletSession && !hasWalletSession) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Please authorize your active wallet before claiming a bot", + }); + } + + if (!requestedAddress) { + throw new TRPCError({ code: "UNAUTHORIZED", message: "Missing requester address" }); + } + + if (!walletSessionMatchesRequested) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Address mismatch. Please authorize your currently connected wallet.", + }); + } + + return requestedAddress; } export const botRouter = createTRPCRouter({ - createBotKey: protectedProcedure - .input( - z.object({ - name: z.string().min(1).max(256), - scope: z.array(z.enum(BOT_SCOPES as unknown as [string, ...string[]])).min(1), - }), - ) - .mutation(async ({ ctx, input }) => { - const ownerAddress = requireSessionAddress(ctx); - const secret = generateBotKeySecret(); - const keyHash = hashBotKeySecret(secret); - const scopeJson = JSON.stringify(input.scope); - - const botKey = await ctx.db.botKey.create({ - data: { - ownerAddress, - name: input.name, - keyHash, - scope: scopeJson, + listBotKeys: protectedProcedure + .input(z.object({ requesterAddress: z.string().min(1) })) + .query(async ({ ctx, input }) => { + const ownerAddress = requireSessionAddress(ctx, { + requesterAddress: input.requesterAddress, + requireWalletSession: true, + }); + + const botKeys = await ctx.db.botKey.findMany({ + where: { ownerAddress }, + include: { botUser: true }, + orderBy: { createdAt: "desc" }, + }); + + const botIds = botKeys.map((botKey) => botKey.botUser?.id).filter((id): id is string => Boolean(id)); + + if (botIds.length === 0) { + return botKeys.map((botKey) => ({ + ...botKey, + scopes: parseScope(botKey.scope), + botWalletAccesses: [], + })); + } + + const ownedWallets = await ctx.db.wallet.findMany({ + where: { + ownerAddress: { + in: [ownerAddress, "all"], + }, }, + select: { id: true }, }); - return { botKeyId: botKey.id, secret, name: botKey.name }; - }), + const walletIds = ownedWallets.map((wallet) => wallet.id); + const walletAccesses = walletIds.length + ? await ctx.db.walletBotAccess.findMany({ + where: { + walletId: { in: walletIds }, + botId: { in: botIds }, + }, + select: { + walletId: true, + botId: true, + role: true, + }, + }) + : []; - listBotKeys: protectedProcedure.input(z.object({})).query(async ({ ctx }) => { - const ownerAddress = requireSessionAddress(ctx); - const botKeys = await ctx.db.botKey.findMany({ - where: { ownerAddress }, - include: { botUser: true }, - orderBy: { createdAt: "desc" }, - }); - return botKeys.map((botKey) => ({ - ...botKey, - scopes: parseScope(botKey.scope), - })); - }), + const accessByBotId = walletAccesses.reduce>((acc, access) => { + const existing = acc[access.botId] ?? []; + acc[access.botId] = [...existing, access]; + return acc; + }, {}); + + return botKeys.map((botKey) => ({ + ...botKey, + scopes: parseScope(botKey.scope), + botWalletAccesses: botKey.botUser ? (accessByBotId[botKey.botUser.id] ?? []) : [], + })); + }), updateBotKeyScopes: protectedProcedure .input( z.object({ botKeyId: z.string(), + requesterAddress: z.string().min(1), scope: z.array(z.enum(BOT_SCOPES as unknown as [string, ...string[]])).min(1), }), ) .mutation(async ({ ctx, input }) => { - const ownerAddress = requireSessionAddress(ctx); + const ownerAddress = requireSessionAddress(ctx, { + requesterAddress: input.requesterAddress, + requireWalletSession: true, + }); const botKey = await ctx.db.botKey.findUnique({ where: { id: input.botKeyId } }); if (!botKey) throw new TRPCError({ code: "NOT_FOUND", message: "Bot key not found" }); if (botKey.ownerAddress !== ownerAddress) { @@ -72,9 +134,12 @@ export const botRouter = createTRPCRouter({ }), revokeBotKey: protectedProcedure - .input(z.object({ botKeyId: z.string() })) + .input(z.object({ botKeyId: z.string(), requesterAddress: z.string().min(1) })) .mutation(async ({ ctx, input }) => { - const ownerAddress = requireSessionAddress(ctx); + const ownerAddress = requireSessionAddress(ctx, { + requesterAddress: input.requesterAddress, + requireWalletSession: true, + }); const botKey = await ctx.db.botKey.findUnique({ where: { id: input.botKeyId } }); if (!botKey) throw new TRPCError({ code: "NOT_FOUND", message: "Bot key not found" }); if (botKey.ownerAddress !== ownerAddress) { @@ -87,22 +152,24 @@ export const botRouter = createTRPCRouter({ grantBotAccess: protectedProcedure .input( z.object({ + requesterAddress: z.string().min(1), walletId: z.string(), botId: z.string(), role: z.nativeEnum(BotWalletRole), }), ) .mutation(async ({ ctx, input }) => { - const requester = requireSessionAddress(ctx); - const sessionWallets: string[] = (ctx as any).sessionWallets ?? []; - const allRequesters = [requester, ...sessionWallets]; + const requester = requireSessionAddress(ctx, { + requesterAddress: input.requesterAddress, + requireWalletSession: true, + }); const wallet = await ctx.db.wallet.findUnique({ where: { id: input.walletId } }); if (!wallet) throw new TRPCError({ code: "NOT_FOUND", message: "Wallet not found" }); const ownerAddress = wallet.ownerAddress ?? null; const isOwner = ownerAddress !== null && - (ownerAddress === "all" || allRequesters.includes(ownerAddress)); + (ownerAddress === "all" || ownerAddress === requester); if (!isOwner) { throw new TRPCError({ code: "FORBIDDEN", message: "Only the wallet owner can grant bot access" }); } @@ -110,6 +177,16 @@ export const botRouter = createTRPCRouter({ const botUser = await ctx.db.botUser.findUnique({ where: { id: input.botId } }); if (!botUser) throw new TRPCError({ code: "NOT_FOUND", message: "Bot not found" }); + if ( + input.role === BotWalletRole.cosigner && + !wallet.signersAddresses.includes(botUser.paymentAddress) + ) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Bot payment address must be in wallet signer list to grant cosigner role", + }); + } + await ctx.db.walletBotAccess.upsert({ where: { walletId_botId: { walletId: input.walletId, botId: input.botId }, @@ -125,16 +202,18 @@ export const botRouter = createTRPCRouter({ }), revokeBotAccess: protectedProcedure - .input(z.object({ walletId: z.string(), botId: z.string() })) + .input(z.object({ requesterAddress: z.string().min(1), walletId: z.string(), botId: z.string() })) .mutation(async ({ ctx, input }) => { - const requester = requireSessionAddress(ctx); - const sessionWallets: string[] = (ctx as any).sessionWallets ?? []; + const requester = requireSessionAddress(ctx, { + requesterAddress: input.requesterAddress, + requireWalletSession: true, + }); const wallet = await ctx.db.wallet.findUnique({ where: { id: input.walletId } }); if (!wallet) throw new TRPCError({ code: "NOT_FOUND", message: "Wallet not found" }); const ownerAddress = wallet.ownerAddress ?? null; const isOwner = ownerAddress !== null && - (ownerAddress === "all" || ownerAddress === requester || sessionWallets.includes(ownerAddress)); + (ownerAddress === "all" || ownerAddress === requester); if (!isOwner) { throw new TRPCError({ code: "FORBIDDEN", message: "Only the wallet owner can revoke bot access" }); } @@ -145,16 +224,18 @@ export const botRouter = createTRPCRouter({ }), listWalletBotAccess: protectedProcedure - .input(z.object({ walletId: z.string() })) + .input(z.object({ requesterAddress: z.string().min(1), walletId: z.string() })) .query(async ({ ctx, input }) => { - const requester = requireSessionAddress(ctx); - const sessionWallets: string[] = (ctx as any).sessionWallets ?? []; + const requester = requireSessionAddress(ctx, { + requesterAddress: input.requesterAddress, + requireWalletSession: true, + }); const wallet = await ctx.db.wallet.findUnique({ where: { id: input.walletId } }); if (!wallet) throw new TRPCError({ code: "NOT_FOUND", message: "Wallet not found" }); const ownerAddress = wallet.ownerAddress ?? null; const isOwner = ownerAddress !== null && - (ownerAddress === "all" || ownerAddress === requester || sessionWallets.includes(ownerAddress)); + (ownerAddress === "all" || ownerAddress === requester); if (!isOwner) { throw new TRPCError({ code: "FORBIDDEN", message: "Only the wallet owner can list bot access" }); } @@ -162,4 +243,69 @@ export const botRouter = createTRPCRouter({ where: { walletId: input.walletId }, }); }), + + lookupPendingBot: protectedProcedure + .input(z.object({ pendingBotId: z.string().min(1) })) + .query(async ({ ctx, input }) => { + const pendingBot = await ctx.db.pendingBot.findUnique({ + where: { id: input.pendingBotId }, + }); + + if (!pendingBot) { + throw new TRPCError({ code: "NOT_FOUND", message: "Bot not found or registration expired" }); + } + + if (pendingBot.expiresAt < new Date()) { + throw new TRPCError({ code: "NOT_FOUND", message: "Bot registration has expired" }); + } + + return { + name: pendingBot.name, + paymentAddress: pendingBot.paymentAddress, + requestedScopes: JSON.parse(pendingBot.requestedScopes) as string[], + status: pendingBot.status, + }; + }), + + claimBot: protectedProcedure + .input( + z.object({ + requesterAddress: z.string().min(1), + pendingBotId: z.string().min(1), + claimCode: z.string().min(24), + approvedScopes: z.array(z.enum(BOT_SCOPES as unknown as [string, ...string[]])), + }), + ) + .mutation(async ({ ctx, input }) => { + const ownerAddress = requireSessionAddress(ctx, { + requesterAddress: input.requesterAddress, + requireWalletSession: true, + }); + + try { + return await ctx.db.$transaction(async (tx) => { + return performClaim(tx, { + pendingBotId: input.pendingBotId, + claimCode: input.claimCode, + approvedScopes: input.approvedScopes as BotScope[], + ownerAddress, + }); + }); + } catch (err) { + if (err instanceof ClaimError) { + const codeMap: Record = { + bot_not_found: "NOT_FOUND", + bot_already_claimed: "CONFLICT", + invalid_or_expired_claim_code: "CONFLICT", + claim_locked_out: "CONFLICT", + invalid_claim_payload: "BAD_REQUEST", + }; + throw new TRPCError({ + code: codeMap[err.code] ?? "INTERNAL_SERVER_ERROR", + message: err.code, + }); + } + throw err; + } + }), }); diff --git a/src/utils/swagger.ts b/src/utils/swagger.ts index f635945d..c07db6ce 100644 --- a/src/utils/swagger.ts +++ b/src/utils/swagger.ts @@ -701,12 +701,175 @@ This API uses **Bearer Token** authentication (JWT). }, }, }, + "/api/v1/botRegister": { + post: { + tags: ["Auth", "Bot"], + summary: "Self-register a bot for human claim approval", + description: + "Creates a pending bot registration and returns a short-lived claim code for a human owner to approve.", + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + name: { type: "string", minLength: 1, maxLength: 100 }, + paymentAddress: { type: "string", minLength: 20 }, + stakeAddress: { type: "string" }, + requestedScopes: { + type: "array", + items: { + type: "string", + enum: [ + "multisig:read", + "multisig:create", + "multisig:sign", + "governance:read", + "ballot:write", + ], + }, + minItems: 1, + }, + }, + required: ["name", "paymentAddress", "requestedScopes"], + }, + }, + }, + }, + responses: { + 201: { + description: "Pending bot created; claim code issued", + content: { + "application/json": { + schema: { + type: "object", + properties: { + pendingBotId: { type: "string" }, + claimCode: { type: "string" }, + claimExpiresAt: { type: "string", format: "date-time" }, + }, + }, + }, + }, + }, + 400: { description: "Invalid registration payload" }, + 405: { description: "Method not allowed" }, + 409: { description: "Address already registered" }, + 429: { description: "Too many requests" }, + 500: { description: "Internal server error" }, + }, + }, + }, + "/api/v1/botClaim": { + post: { + tags: ["Auth", "Bot"], + summary: "Claim a pending bot as a human user", + description: + "Requires a human JWT. Verifies claim code, creates bot credentials, and links ownership to the claimer.", + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + pendingBotId: { type: "string" }, + claimCode: { type: "string", minLength: 24 }, + approvedScopes: { + type: "array", + items: { + type: "string", + enum: [ + "multisig:read", + "multisig:create", + "multisig:sign", + "governance:read", + "ballot:write", + ], + }, + }, + }, + required: ["pendingBotId", "claimCode"], + }, + }, + }, + }, + responses: { + 200: { + description: "Bot claimed and credentials minted", + content: { + "application/json": { + schema: { + type: "object", + properties: { + botKeyId: { type: "string" }, + botId: { type: "string" }, + name: { type: "string" }, + scopes: { + type: "array", + items: { type: "string" }, + }, + }, + }, + }, + }, + }, + 400: { description: "Invalid claim payload" }, + 401: { description: "Unauthorized (human JWT required)" }, + 404: { description: "Pending bot not found or expired" }, + 405: { description: "Method not allowed" }, + 409: { description: "Invalid claim code, already claimed, or claim locked" }, + 429: { description: "Too many requests" }, + 500: { description: "Internal server error" }, + }, + }, + }, + "/api/v1/botPickupSecret": { + get: { + tags: ["Auth", "Bot"], + summary: "Retrieve one-time bot secret after claim", + description: + "Returns bot credentials exactly once after a successful claim. Requires pendingBotId query parameter.", + parameters: [ + { + in: "query", + name: "pendingBotId", + required: true, + schema: { type: "string" }, + }, + ], + responses: { + 200: { + description: "One-time bot secret", + content: { + "application/json": { + schema: { + type: "object", + properties: { + botKeyId: { type: "string" }, + secret: { type: "string" }, + paymentAddress: { type: "string" }, + }, + }, + }, + }, + }, + 400: { description: "Missing or invalid pendingBotId" }, + 404: { description: "Pending bot not found or not yet claimed" }, + 405: { description: "Method not allowed" }, + 410: { description: "Secret already picked up" }, + 429: { description: "Too many requests" }, + 500: { description: "Internal server error" }, + }, + }, + }, "/api/v1/botAuth": { post: { tags: ["Auth", "Bot"], summary: "Bot authentication", description: - "Authenticate a bot using bot key credentials. Register or link the bot's Cardano payment address; returns a JWT for use as Bearer token on v1 endpoints. Bot keys are created in the app (User → Create bot). One bot key maps to one paymentAddress.", + "Authenticate a bot key and return a bot JWT. botKeyId and secret are issued by the claim flow: POST /api/v1/botRegister -> human POST /api/v1/botClaim -> GET /api/v1/botPickupSecret.", requestBody: { required: true, content: { @@ -714,8 +877,8 @@ This API uses **Bearer Token** authentication (JWT). schema: { type: "object", properties: { - botKeyId: { type: "string", description: "Bot key ID from Create bot" }, - secret: { type: "string", description: "Secret from Create bot (shown once)" }, + botKeyId: { type: "string", description: "Bot key ID from bot claim flow" }, + secret: { type: "string", description: "One-time secret from botPickupSecret" }, paymentAddress: { type: "string", description: "Cardano payment address for this bot" }, stakeAddress: { type: "string", description: "Optional stake address" }, }, @@ -744,10 +907,135 @@ This API uses **Bearer Token** authentication (JWT). 403: { description: "Insufficient scope" }, 409: { description: "paymentAddress already registered to another bot" }, 405: { description: "Method not allowed" }, + 429: { description: "Too many requests" }, + 500: { description: "Internal server error" }, + }, + }, + }, + "/api/v1/botMe": { + get: { + tags: ["V1", "Bot"], + summary: "Get authenticated bot profile", + description: + "Returns the authenticated bot's own identity and owner address. Requires bot JWT.", + responses: { + 200: { + description: "Bot profile", + content: { + "application/json": { + schema: { + type: "object", + properties: { + botId: { type: "string" }, + paymentAddress: { type: "string" }, + displayName: { type: "string", nullable: true }, + botName: { type: "string" }, + ownerAddress: { type: "string" }, + }, + }, + }, + }, + }, + 401: { description: "Missing/invalid token" }, + 403: { description: "Not a bot token" }, + 404: { description: "Bot not found" }, + 405: { description: "Method not allowed" }, + 429: { description: "Too many requests" }, 500: { description: "Internal server error" }, }, }, }, + "/api/v1/createWallet": { + post: { + tags: ["V1", "Bot"], + summary: "Create multisig wallet with bot JWT", + description: + "Creates a multisig wallet from signer payment/stake/DRep inputs. Requires bot JWT and multisig:create scope.", + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + name: { type: "string", minLength: 1, maxLength: 256 }, + description: { + type: "string", + description: "Optional free text. Server stores at most 2000 chars.", + maxLength: 2000, + }, + signersAddresses: { + type: "array", + items: { type: "string" }, + minItems: 1, + description: "Cardano payment addresses used to derive payment key hashes.", + }, + signersDescriptions: { + type: "array", + items: { type: "string" }, + description: "Optional per-signer labels. Missing entries default to an empty string.", + }, + signersStakeKeys: { + type: "array", + items: { + oneOf: [{ type: "string" }, { type: "null" }], + }, + description: + "Optional stake addresses. Ignored when stakeCredentialHash is provided.", + }, + signersDRepKeys: { + type: "array", + items: { + oneOf: [{ type: "string" }, { type: "null" }], + }, + description: "Optional DRep key hashes (non-empty values are used as provided).", + }, + numRequiredSigners: { + type: "integer", + minimum: 1, + default: 1, + description: + "Used for atLeast scripts. Values above signer count are clamped to signer count.", + }, + scriptType: { + type: "string", + enum: ["atLeast", "all", "any"], + default: "atLeast", + description: "Unknown values are treated as atLeast.", + }, + stakeCredentialHash: { type: "string" }, + network: { type: "integer", enum: [0, 1], default: 1 }, + }, + required: ["name", "signersAddresses"], + }, + }, + }, + }, + responses: { + 201: { + description: "Wallet created", + content: { + "application/json": { + schema: { + type: "object", + properties: { + walletId: { type: "string" }, + address: { type: "string" }, + name: { type: "string" }, + }, + }, + }, + }, + }, + 400: { description: "Invalid payload or signer data" }, + 401: { description: "Missing/invalid token or bot not found" }, + 403: { description: "Not a bot token or insufficient scope" }, + 405: { description: "Method not allowed" }, + 429: { description: "Too many requests" }, + 500: { description: "Failed to create wallet" }, + }, + }, + }, "/api/v1/governanceActiveProposals": { get: { tags: ["V1", "Bot", "Governance"],