diff --git a/apps/web/src/components/SettingsPage.tsx b/apps/web/src/components/SettingsPage.tsx
index 97807a02..4ec40dc5 100644
--- a/apps/web/src/components/SettingsPage.tsx
+++ b/apps/web/src/components/SettingsPage.tsx
@@ -5,6 +5,7 @@ import type { CredentialStatus } from "@nibblet/protocol";
import type { ServerConnection } from "../lib/connection.ts";
import { AccountSection, RemoteAccessSection } from "./RemoteAccess.tsx";
import { TailnetKeysSection } from "./TailnetKeysSection.tsx";
+import { BankSection } from "./bank/BankSection.tsx";
import { keys, useServers } from "../lib/servers.tsx";
import { clockTime, relativeTime } from "../lib/time.ts";
import { Button } from "./ui/Button.tsx";
@@ -53,6 +54,7 @@ export function SettingsPage() {
+
diff --git a/apps/web/src/components/bank/BankSection.tsx b/apps/web/src/components/bank/BankSection.tsx
new file mode 100644
index 00000000..c34dc389
--- /dev/null
+++ b/apps/web/src/components/bank/BankSection.tsx
@@ -0,0 +1,67 @@
+import { useQuery } from "@tanstack/react-query";
+import { CheckCircle2, XCircle } from "lucide-react";
+import type { ServerConnection } from "../../lib/connection.ts";
+import { money } from "../../lib/bank.ts";
+import { keys } from "../../lib/servers.tsx";
+import { SectionHeading } from "../ui/Field.tsx";
+
+/**
+ * The banks nibblets' cards come from: card issuers and crypto treasuries,
+ * set up in the server's settings, and what each has left to fund cards with.
+ */
+export function BankSection({ connection }: { connection: ServerConnection }) {
+ const status = useQuery({
+ queryKey: keys.bank(connection.id),
+ queryFn: () => connection.client.bank.status({}),
+ refetchInterval: 60_000,
+ });
+ const providers = status.data?.providers ?? [];
+ const problems = status.data?.problems ?? [];
+ return (
+ <>
+ Bank
+
+ {providers.length === 0 && problems.length === 0
+ ? "No bank is set up, so nibblets can't have cards. Add your Slash account (SLASH_API_KEY) or a crypto treasury (BITCOIN_TREASURY_KEY, ETHEREUM_TREASURY_KEY) to the server's .env and restart it."
+ : "Nibblets get cards from these, within the spending limit you give each one in its settings."}
+
+ {providers.map((p) => (
+
+ {p.state === "ready" ? (
+
+ ) : (
+
+ )}
+
+
+ {p.label}
+ {p.asset.code}
+
+
+ {p.state === "error"
+ ? `Can't be reached: ${p.detail ?? "no reason given"}`
+ : p.balance !== null
+ ? `${money(p.balance, p.asset)} available`
+ : "Ready"}
+
+
+
+ ))}
+ {problems.map((p) => (
+
+
+
+ {p.id}
+ Not set up: {p.detail}
+
+
+ ))}
+ >
+ );
+}
diff --git a/apps/web/src/components/bank/NibbletSpending.tsx b/apps/web/src/components/bank/NibbletSpending.tsx
new file mode 100644
index 00000000..e7d900fc
--- /dev/null
+++ b/apps/web/src/components/bank/NibbletSpending.tsx
@@ -0,0 +1,217 @@
+import { useEffect, useMemo, useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { CreditCard, Wallet } from "lucide-react";
+import { SpendingPeriod, type Card, type Nibblet } from "@nibblet/protocol";
+import type { ServerConnection } from "../../lib/connection.ts";
+import {
+ bankAssets,
+ describeCard,
+ draftOf,
+ limitsOf,
+ money,
+ periodLabels,
+ type LimitsDraft,
+} from "../../lib/bank.ts";
+import { keys } from "../../lib/servers.tsx";
+import { relativeTime } from "../../lib/time.ts";
+import { Button } from "../ui/Button.tsx";
+import { FieldRow, Input, SectionHeading, Select } from "../ui/Field.tsx";
+
+const periodOptions = SpendingPeriod.options.map((p) => ({ value: p, label: periodLabels[p] }));
+
+/**
+ * What a nibblet may spend, in each asset the server's banks deal in, and
+ * the cards it has. Only the person sets the limits; the nibblet asks for
+ * cards within them. Hidden when no bank is set up.
+ */
+export function NibbletSpending({
+ connection,
+ nibblet,
+}: {
+ connection: ServerConnection;
+ nibblet: Nibblet;
+}) {
+ const status = useQuery({
+ queryKey: keys.bank(connection.id),
+ queryFn: () => connection.client.bank.status({}),
+ });
+ const cards = useQuery({
+ queryKey: keys.cards(connection.id, nibblet.id),
+ queryFn: () => connection.client.bank.cards({ nibbletId: nibblet.id }),
+ });
+ const allowances = useQuery({
+ queryKey: keys.allowances(connection.id, nibblet.id),
+ queryFn: () => connection.client.bank.allowances({ nibbletId: nibblet.id }),
+ });
+ const assets = useMemo(() => bankAssets(status.data?.providers ?? []), [status.data]);
+ const saved = useMemo(
+ () => draftOf(nibblet.spendingLimits, assets),
+ [nibblet.spendingLimits, assets],
+ );
+ const [draft, setDraft] = useState(saved);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ // By content: every live update brings a new nibblet, which mustn't wipe what's half typed.
+ useEffect(() => setDraft(saved), [JSON.stringify(saved)]);
+
+ if (assets.length === 0 && !cards.data?.length) return null;
+
+ const dirty = JSON.stringify(draft) !== JSON.stringify(saved);
+ const save = async () => {
+ const result = limitsOf(draft, assets, nibblet.spendingLimits);
+ if ("error" in result) {
+ setError(result.error);
+ return;
+ }
+ setBusy(true);
+ setError(null);
+ try {
+ await connection.client.nibblets.update({ id: nibblet.id, spendingLimits: result.limits });
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+ <>
+ Spending
+
+ The most it may put on cards. Open cards count in full until they're killed. Leave a limit
+ empty for no cards in that currency.
+
+ {assets.map((asset) => {
+ const entry = draft[asset.code] ?? { amount: "", period: "month" };
+ const left = allowances.data?.find((a) => a.limit.asset === asset.code);
+ return (
+
+
+
+ setDraft({ ...draft, [asset.code]: { ...entry, amount: e.target.value } })
+ }
+ />
+ {
+ const period = SpendingPeriod.safeParse(v);
+ if (period.success)
+ setDraft({ ...draft, [asset.code]: { ...entry, period: period.data } });
+ }}
+ />
+
+
+ );
+ })}
+ {dirty || error ? (
+
+ void save()}>
+ Save limits
+
+ {dirty ? (
+ setDraft(saved)}>
+ Cancel
+
+ ) : null}
+ {error ? {error} : null}
+
+ ) : null}
+
+ >
+ );
+}
+
+function CardList({ connection, cards }: { connection: ServerConnection; cards: Card[] }) {
+ const [showClosed, setShowClosed] = useState(false);
+ const open = cards.filter((c) => c.state !== "closed");
+ const closed = cards.filter((c) => c.state === "closed");
+ if (cards.length === 0) return No cards yet.
;
+ return (
+
+ {open.map((card) => (
+
+ ))}
+ {open.length === 0 ?
No open cards.
: null}
+ {closed.length > 0 ? (
+
setShowClosed(!showClosed)}
+ >
+ {showClosed ? "Hide" : "Show"} {closed.length} closed
+
+ ) : null}
+ {showClosed
+ ? closed.map((card) =>
)
+ : null}
+
+ );
+}
+
+const closedByLabel: Record, string> = {
+ nibblet: "killed by the nibblet",
+ user: "killed by you",
+ provider: "closed after use",
+ server: "closed: it couldn't reach the container",
+};
+
+/** What a card that isn't closed is doing, and since when. */
+function liveLabel(card: Card): string {
+ const made = relativeTime(card.createdAt);
+ if (card.state === "pending") return `being made (asked ${made})`;
+ if (card.state === "closing") return `closing, waiting on ${card.provider} · made ${made}`;
+ return `made ${made}`;
+}
+
+function CardRow({ connection, card }: { connection: ServerConnection; card: Card }) {
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const kill = async () => {
+ setBusy(true);
+ setError(null);
+ try {
+ await connection.client.bank.kill({ id: card.id });
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setBusy(false);
+ }
+ };
+ const Icon = card.display.startsWith("•") ? CreditCard : Wallet;
+ return (
+
+
+
+
+ {card.purpose}
+
+ {card.display}
+
+ {describeCard(card)} · {card.provider} ·{" "}
+ {card.state === "closed"
+ ? `${card.closedBy ? closedByLabel[card.closedBy] : "closed"} ${card.closedAt ? relativeTime(card.closedAt) : ""}`
+ : liveLabel(card)}
+
+ {error ? {error} : null}
+
+ {card.state === "open" || card.state === "closing" ? (
+
void kill()}>
+ Kill
+
+ ) : null}
+
+ );
+}
diff --git a/apps/web/src/components/panels/NibbletSettings.tsx b/apps/web/src/components/panels/NibbletSettings.tsx
index fe061d59..e5cb5a45 100644
--- a/apps/web/src/components/panels/NibbletSettings.tsx
+++ b/apps/web/src/components/panels/NibbletSettings.tsx
@@ -17,6 +17,7 @@ import { PromptNameField } from "../PromptNameField.tsx";
import { NibbletVaults } from "../onepassword/OnePasswordSection.tsx";
import { BudgetSection } from "./BudgetSection.tsx";
import { ResourcesSection } from "./ResourcesSection.tsx";
+import { NibbletSpending } from "../bank/NibbletSpending.tsx";
import { TailscaleSection } from "./TailscaleSection.tsx";
export function NibbletSettings({
@@ -216,6 +217,8 @@ export function NibbletSettings({
+
+
diff --git a/apps/web/src/lib/bank.test.ts b/apps/web/src/lib/bank.test.ts
new file mode 100644
index 00000000..afd937af
--- /dev/null
+++ b/apps/web/src/lib/bank.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from "vitest";
+import { bankAssets, draftOf, limitsOf, money } from "./bank.ts";
+
+const usd = { code: "USD", decimals: 2 };
+const btc = { code: "BTC", decimals: 8 };
+
+describe("bank limits", () => {
+ it("lists each asset once", () => {
+ expect(bankAssets([{ asset: usd }, { asset: btc }, { asset: usd }])).toEqual([usd, btc]);
+ });
+
+ it("shows limits in each asset's own units", () => {
+ expect(draftOf([{ asset: "BTC", amount: "100000", period: "total" }], [usd, btc])).toEqual({
+ USD: { amount: "", period: "month" },
+ BTC: { amount: "0.001", period: "total" },
+ });
+ });
+
+ it("turns a draft back into limits, dropping empty ones", () => {
+ const draft = {
+ USD: { amount: "50", period: "week" as const },
+ BTC: { amount: "", period: "month" as const },
+ };
+ expect(limitsOf(draft, [usd, btc], [])).toEqual({
+ limits: [{ asset: "USD", amount: "5000", period: "week" }],
+ });
+ expect(limitsOf({ USD: { amount: "0", period: "day" } }, [usd], [])).toEqual({ limits: [] });
+ });
+
+ it("keeps limits in assets no bank here deals in", () => {
+ const eth = { asset: "ETH", amount: "1", period: "day" as const };
+ expect(limitsOf({ USD: { amount: "1", period: "day" } }, [usd], [eth])).toEqual({
+ limits: [eth, { asset: "USD", amount: "100", period: "day" }],
+ });
+ });
+
+ it("says what's wrong with an amount", () => {
+ expect(limitsOf({ USD: { amount: "1.234", period: "day" } }, [usd], [])).toEqual({
+ error: 'USD: "1.234" has more than 2 decimal places',
+ });
+ });
+
+ it("formats money", () => {
+ expect(money("1250", usd)).toBe("12.50 USD");
+ expect(money(100000n, btc)).toBe("0.001 BTC");
+ });
+});
diff --git a/apps/web/src/lib/bank.ts b/apps/web/src/lib/bank.ts
new file mode 100644
index 00000000..83ee5529
--- /dev/null
+++ b/apps/web/src/lib/bank.ts
@@ -0,0 +1,86 @@
+import {
+ formatAmount,
+ parseAmount,
+ SpendingPeriod,
+ type AssetInfo,
+ type Card,
+ type SpendingLimit,
+} from "@nibblet/protocol";
+
+/** One asset's limit as typed: an amount in its own units (empty for none), and a period. */
+export interface LimitDraft {
+ amount: string;
+ period: SpendingPeriod;
+}
+
+export type LimitsDraft = Record;
+
+export const periodLabels: Record = {
+ day: "a day",
+ week: "a week",
+ month: "a month",
+ total: "in total",
+};
+
+/** The assets the server's banks deal in, once each. */
+export function bankAssets(providers: readonly { asset: AssetInfo }[]): AssetInfo[] {
+ const seen = new Map();
+ for (const p of providers) if (!seen.has(p.asset.code)) seen.set(p.asset.code, p.asset);
+ return [...seen.values()];
+}
+
+export function draftOf(
+ limits: readonly SpendingLimit[],
+ assets: readonly AssetInfo[],
+): LimitsDraft {
+ return Object.fromEntries(
+ assets.map((asset) => {
+ const limit = limits.find((l) => l.asset === asset.code);
+ return [
+ asset.code,
+ {
+ amount: limit ? formatAmount(limit.amount, asset.decimals) : "",
+ period: limit?.period ?? "month",
+ },
+ ];
+ }),
+ );
+}
+
+/**
+ * The limits a draft sets, or what's wrong with it. Limits in assets no bank
+ * here deals in are kept as they were, since they can't be shown to change.
+ */
+export function limitsOf(
+ draft: LimitsDraft,
+ assets: readonly AssetInfo[],
+ previous: readonly SpendingLimit[],
+): { limits: SpendingLimit[] } | { error: string } {
+ const shown = new Set(assets.map((a) => a.code));
+ const limits = previous.filter((l) => !shown.has(l.asset));
+ for (const asset of assets) {
+ const entry = draft[asset.code];
+ const text = entry?.amount.trim() ?? "";
+ if (!entry || text === "") continue;
+ let amount: bigint;
+ try {
+ amount = parseAmount(text, asset.decimals);
+ } catch (err) {
+ return { error: `${asset.code}: ${err instanceof Error ? err.message : String(err)}` };
+ }
+ if (amount === 0n) continue;
+ limits.push({ asset: asset.code, amount: amount.toString(), period: entry.period });
+ }
+ return { limits };
+}
+
+/** `12.50 USD`. */
+export function money(minor: string | bigint, asset: AssetInfo): string {
+ return `${formatAmount(minor, asset.decimals)} ${asset.code}`;
+}
+
+/** A card's line in a list: what it is and what's gone on it. */
+export function describeCard(card: Card): string {
+ const kind = card.kind === "single_use" ? "Single use" : "Multi use";
+ return `${kind} · ${money(card.spent, card.asset)} of ${money(card.limit, card.asset)}`;
+}
diff --git a/apps/web/src/lib/mock.ts b/apps/web/src/lib/mock.ts
index 20ef075a..e592bd82 100644
--- a/apps/web/src/lib/mock.ts
+++ b/apps/web/src/lib/mock.ts
@@ -11,6 +11,7 @@ import {
mergeMcpServers,
tailnetHostname,
randomHamsterLook,
+ type Card,
type Contract,
type OnePasswordGrants,
type HostTool,
@@ -189,6 +190,10 @@ export function createMockServer(): ServerConnection {
status: "working",
statusText: "Hunting for a cheap flight to Lisbon",
statusEmoji: "✈️",
+ spendingLimits: [
+ { asset: "USD", amount: "15000", period: "month" },
+ { asset: "BTC", amount: "200000", period: "total" },
+ ],
statusUpdatedAt: ago(3),
instructions:
"Look after my inbox and calendar. Summarise anything important each morning.",
@@ -265,6 +270,71 @@ export function createMockServer(): ServerConnection {
].map((n) => [n.id, n]),
);
+ const usd = { code: "USD", decimals: 2 };
+ const btc = { code: "BTC", decimals: 8 };
+ const cards: Card[] = [
+ {
+ id: "card_flights",
+ nibbletId: "pip",
+ provider: "slash",
+ asset: usd,
+ kind: "multi_use",
+ limit: "12000",
+ spent: "0",
+ purpose: "Flight to Lisbon, if the fare drops under £80",
+ display: "•••• 4821",
+ state: "open",
+ closedBy: null,
+ createdAt: ago(40),
+ closedAt: null,
+ },
+ {
+ id: "card_domain",
+ nibbletId: "pip",
+ provider: "slash",
+ asset: usd,
+ kind: "single_use",
+ limit: "1500",
+ spent: "1288",
+ purpose: "Renew nibblets.dev at the registrar",
+ display: "•••• 0193",
+ state: "closed",
+ closedBy: "provider",
+ createdAt: ago(60 * 26),
+ closedAt: ago(60 * 26 - 4),
+ },
+ {
+ id: "card_relay",
+ nibbletId: "pip",
+ provider: "bitcoin",
+ asset: btc,
+ kind: "single_use",
+ limit: "50000",
+ spent: "0",
+ purpose: "Pay for a month of the VPN relay",
+ display: "tb1q9h7garjr0e3y0ls6dx5l2s5k6l3e0zq0d6v5wl",
+ state: "open",
+ closedBy: null,
+ createdAt: ago(12),
+ closedAt: null,
+ },
+ {
+ id: "card_tip",
+ nibbletId: "pip",
+ provider: "bitcoin",
+ asset: btc,
+ kind: "multi_use",
+ limit: "20000",
+ spent: "0",
+ purpose: "Tip the podcast for the Lisbon recommendations",
+ display: "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx",
+ state: "closing",
+ closedBy: "nibblet",
+ createdAt: ago(9),
+ closedAt: null,
+ },
+ ];
+
const items = new Map();
const store = (nibbletId: string, item: ThreadItem) => {
const list = items.get(nibbletId) ?? [];
@@ -953,11 +1023,51 @@ export function createMockServer(): ServerConnection {
},
},
bank: {
- status: async () => ({ providers: [], problems: [] }),
- cards: async () => [],
- allowances: async () => [],
+ status: async () => ({
+ providers: [
+ {
+ id: "slash",
+ label: "Slash",
+ asset: usd,
+ state: "ready",
+ detail: null,
+ balance: "248650",
+ },
+ {
+ id: "bitcoin",
+ label: "Bitcoin (signet)",
+ asset: btc,
+ state: "ready",
+ detail: null,
+ balance: "4180000",
+ },
+ ],
+ problems: [],
+ }),
+ cards: async ({ nibbletId }) => cards.filter((c) => c.nibbletId === nibbletId),
+ allowances: async ({ nibbletId }) => {
+ const n = nibblets.get(nibbletId);
+ return (n?.spendingLimits ?? []).map((limit) => {
+ const used = cards
+ .filter((c) => c.nibbletId === nibbletId && c.asset.code === limit.asset)
+ .reduce((sum, c) => sum + BigInt(c.state === "open" ? c.limit : c.spent), 0n);
+ const cap = BigInt(limit.amount);
+ return {
+ limit,
+ used: used.toString(),
+ remaining: (used >= cap ? 0n : cap - used).toString(),
+ since: ago(60 * 24 * 18),
+ };
+ });
+ },
kill: async ({ id }) => {
- throw new RpcError("not_found", `No card ${id}`);
+ const i = cards.findIndex((c) => c.id === id);
+ const card = cards[i];
+ if (!card) throw new RpcError("not_found", `No card ${id}`);
+ const killed: Card = { ...card, state: "closed", closedBy: "user", closedAt: now() };
+ cards[i] = killed;
+ push({ type: "cards_changed", nibbletId: card.nibbletId });
+ return killed;
},
},
snapshots: {
diff --git a/apps/web/src/lib/servers.tsx b/apps/web/src/lib/servers.tsx
index 47322579..8f53a22a 100644
--- a/apps/web/src/lib/servers.tsx
+++ b/apps/web/src/lib/servers.tsx
@@ -29,6 +29,9 @@ export const keys = {
tasks: (server: string, id: string) => ["tasks", server, id] as const,
apps: (server: string, id: string) => ["apps", server, id] as const,
spend: (server: string, id: string) => ["spend", server, id] as const,
+ bank: (server: string) => ["bank", server] as const,
+ cards: (server: string, id: string) => ["cards", server, id] as const,
+ allowances: (server: string, id: string) => ["allowances", server, id] as const,
memories: (server: string, id: string | null, query: string) =>
["memories", server, id, query] as const,
hostTools: (server: string) => ["hostTools", server] as const,
@@ -321,6 +324,10 @@ export function applyLive(qc: QueryClient, server: string, m: LiveMessage): void
case "snapshots_changed":
void qc.invalidateQueries({ queryKey: keys.snapshots(server, m.nibbletId) });
return;
+ case "cards_changed":
+ void qc.invalidateQueries({ queryKey: keys.cards(server, m.nibbletId) });
+ void qc.invalidateQueries({ queryKey: keys.allowances(server, m.nibbletId) });
+ return;
}
}