diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d124761a..8fe1da14 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -159,7 +159,7 @@ A turn the user didn't start (a heartbeat, a schedule, a sub-agent's result, car ## Bank: cards and wallets -A nibblet can be given money to spend, as virtual cards or as crypto wallets that work like them. Where the money comes from is a bank plugin (`packages/bank-*`) implementing `BankPlugin` from `@nibblet/bank`: it issues a card with a lifetime limit, gives its details again (`reveal`), reports what's been spent on it (`inspect`), and closes it (`close`). A plugin turns itself on from the server's environment (`apps/server/src/banks.ts` lists them), and holds its provider's credentials, which never leave the server. Settings shows each plugin, its balance, and any whose settings are wrong. +A nibblet can be given money to spend, as virtual cards or as crypto wallets that work like them. Where the money comes from is a bank plugin (`packages/bank-*`) implementing `BankPlugin` from `@nibblet/bank`: it issues a card with a lifetime limit, gives its details again (`reveal`), reports what's been spent on it (`inspect`), and closes it (`close`). Each plugin's factory describes its own settings form (`fields`: labels, hints, secrets, choices, keys the form can make, and fields that can't change while cards are open), so Settings → Bank sets any of them up without the web app knowing one bank from another. Saving checks the settings with the provider (`check`), keeps them in the `settings` table (`banks`, secrets never sent back), and loads the plugin without a restart (`apps/server/src/banks.ts`, which lists the plugins). A bank with no saved settings falls back to the server's environment, whose variables are named like its fields. A plugin holds its provider's credentials, which never leave the server. Settings shows each bank, its balance, where to top a treasury up (`fundAt`), and any whose settings are wrong. A bank can't be removed, or have its locked fields changed, while it has cards that aren't closed. - **Limits:** the person sets each nibblet's `spendingLimits`, one per asset (`USD`, `BTC`, `ETH`), per day, week or month (UTC) or in total. A nibblet has none to begin with, so it can't have a card until it's given one, and it can't change its own. Every card counts against its period for its whole limit while it's open, since it could all still be spent, and for what was spent once it's closed. One request or kill at a time per nibblet, so two at once can't both fit the same budget. - **The card file:** `card_request` asks the plugin for a card, stores its record (`cards`: never the number or key), and sends its details over the runner socket (`card_put`). The runner writes them to `$TMPDIR/nibblet-cards/.json`, `0600` in a `0700` folder, as the agent's user, and answers `card_done` with the path, which is all the tool returns. So a card's number reaches the model only if it reads the file, and never the thread, the database or a snapshot. A card that can't be written is closed again. The folder goes with the container; when a runner reconnects, the server asks the plugins for its open cards again and rewrites them. diff --git a/apps/server/src/bank.test.ts b/apps/server/src/bank.test.ts index c3d3cbbe..884e35e2 100644 --- a/apps/server/src/bank.test.ts +++ b/apps/server/src/bank.test.ts @@ -13,7 +13,7 @@ import { type Nibblet, type SpendingLimit, } from "@nibblet/protocol"; -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { Bank, type BankStore } from "./bank.ts"; import type { CardRow } from "./store.ts"; @@ -118,6 +118,9 @@ class FakeIssuer { id: "fake", label: "Fake issuer", asset: { code: "USD", decimals: 2 }, + instrument: "card", + fundAt: null, + check: async () => {}, balance: async () => 100_000n, issue: async (request) => { if (this.refuseNext) throw new BankError("the account is frozen"); @@ -444,7 +447,7 @@ describe("Bank", () => { const broken = new Bank({ store, plugins: new Map([["fake", issuer.plugin()]]), - errors: new Map([["slash", "SLASH_API_KEY isn't set"]]), + errors: new Map([["slash", "API key is needed"]]), files: { put: async () => "", remove: async () => {} }, publish: () => {}, log: () => {}, @@ -455,12 +458,50 @@ describe("Bank", () => { id: "fake", label: "Fake issuer", asset: { code: "USD", decimals: 2 }, + instrument: "card", state: "ready", detail: null, balance: "100000", + fundAt: null, }, ], - problems: [{ id: "slash", detail: "SLASH_API_KEY isn't set" }], + problems: [{ id: "slash", detail: "API key is needed" }], }); }); + + it("takes banks set up while it runs, and says so", async () => { + const published: LiveMessage[] = []; + const later = new Bank({ + store, + plugins: new Map(), + files: { put: async () => "", remove: async () => {} }, + publish: (m) => published.push(m), + log: () => {}, + }); + expect(later.enabled).toBe(false); + later.use(new Map([["fake", issuer.plugin()]]), new Map()); + expect(later.enabled).toBe(true); + expect(later.providers().map((p) => p.id)).toEqual(["fake"]); + expect(published).toEqual([{ type: "bank_changed" }]); + }); + + it("starts checking cards once a bank's set up, if there was none when it started", async () => { + const single = (await request("5.00", "single_use")).card; + const c = issuer.cards.get("tok_1"); + if (c) c.spent = 100n; + const later = new Bank({ + store, + plugins: new Map(), + files: { put: async () => "", remove: async () => {} }, + publish: () => {}, + log: () => {}, + now: () => clock, + }); + later.start(); + later.use(new Map([["fake", issuer.plugin()]]), new Map()); + await vi.waitFor(async () => + expect(await store.cardRow(single.id)).toMatchObject({ state: "closed" }), + ); + later.stop(); + }); }); diff --git a/apps/server/src/bank.ts b/apps/server/src/bank.ts index e6f019b0..e498020b 100644 --- a/apps/server/src/bank.ts +++ b/apps/server/src/bank.ts @@ -43,6 +43,7 @@ export type BankStore = Pick< export interface BankDeps { store: BankStore; + /** The banks set up when it starts; `use` swaps in others. */ plugins: ReadonlyMap; /** Plugins whose settings are there but wrong, with why. */ errors?: ReadonlyMap; @@ -74,30 +75,56 @@ export class Bank { /** Where each open card's file is in its container, as its runner last said. */ private readonly paths = new Map(); private timer: ReturnType | null = null; + /** Whether the server has asked it to keep cards in step, so a bank set up later starts it. */ + private running = false; private syncing: Promise | null = null; + private plugins: ReadonlyMap; + private errors: ReadonlyMap; constructor(deps: BankDeps) { this.deps = deps; + this.plugins = deps.plugins; + this.errors = deps.errors ?? new Map(); + } + + /** + * Swaps in the banks as they're set up now. Cards at a bank that's gone + * wait, as they are, for it to come back. + */ + use( + plugins: ReadonlyMap, + errors: ReadonlyMap, + ) { + this.plugins = plugins; + this.errors = errors; + if (this.running) this.startTimer(); + this.deps.publish({ type: "bank_changed" }); } /** Whether any bank is set up, so the tools are worth offering. */ get enabled(): boolean { - return this.deps.plugins.size > 0; + return this.plugins.size > 0; } /** The banks nibblets can ask for cards from. */ providers(): { id: string; label: string; asset: AssetInfo }[] { - return [...this.deps.plugins.values()].map((p) => ({ + return [...this.plugins.values()].map((p) => ({ id: p.id, label: p.label, asset: p.asset, })); } - async status(): Promise { + async status(): Promise> { const providers = await Promise.all( - [...this.deps.plugins.values()].map(async (plugin): Promise => { - const base = { id: plugin.id, label: plugin.label, asset: plugin.asset }; + [...this.plugins.values()].map(async (plugin): Promise => { + const base = { + id: plugin.id, + label: plugin.label, + asset: plugin.asset, + instrument: plugin.instrument, + fundAt: plugin.fundAt, + }; try { const balance = await plugin.balance(); return { ...base, state: "ready", detail: null, balance: balance?.toString() ?? null }; @@ -109,7 +136,7 @@ export class Bank { } }), ); - const problems = [...(this.deps.errors ?? [])].map(([id, detail]) => ({ id, detail })); + const problems = [...this.errors].map(([id, detail]) => ({ id, detail })); return { providers, problems }; } @@ -261,7 +288,7 @@ export class Bank { "That card is still being made, or checked on. It'll be closed if it was made; try again in a few minutes.", ); } - const plugin = this.deps.plugins.get(row.provider); + const plugin = this.plugins.get(row.provider); if (!plugin) { throw new RpcError( "unavailable", @@ -300,7 +327,7 @@ export class Bank { // Checked in turn with kills, so a card killed meanwhile isn't written back. const row = await this.deps.store.cardRow(id); if (row.state !== "open") return; - const plugin = this.deps.plugins.get(row.provider); + const plugin = this.plugins.get(row.provider); if (!plugin) return; const details = await plugin.reveal(cardRef(row)); const path = await this.deps.files.put(nibbletId, { @@ -333,20 +360,27 @@ export class Bank { } start(): void { - if (this.timer || !this.enabled) return; - this.timer = setInterval(() => void this.sync(), SYNC_EVERY_MS); - this.timer.unref?.(); - void this.sync(); + this.running = true; + this.startTimer(); } stop(): void { + this.running = false; if (this.timer) clearInterval(this.timer); this.timer = null; } + /** Checks cards every minute from the first bank on; with none, there's nothing to check. */ + private startTimer(): void { + if (this.timer || !this.enabled) return; + this.timer = setInterval(() => void this.sync(), SYNC_EVERY_MS); + this.timer.unref?.(); + void this.sync(); + } + private async syncOnce(): Promise { for (const row of await this.deps.store.liveCards()) { - const plugin = this.deps.plugins.get(row.provider); + const plugin = this.plugins.get(row.provider); if (!plugin) continue; try { await this.serially(row.nibbletId, async () => { @@ -476,9 +510,9 @@ export class Bank { } private plugin(id: string): BankPlugin { - const plugin = this.deps.plugins.get(id); + const plugin = this.plugins.get(id); if (plugin) return plugin; - const known = [...this.deps.plugins.keys()]; + const known = [...this.plugins.keys()]; throw new RpcError( "bad_request", known.length diff --git a/apps/server/src/banks.test.ts b/apps/server/src/banks.test.ts new file mode 100644 index 00000000..7c921fa1 --- /dev/null +++ b/apps/server/src/banks.test.ts @@ -0,0 +1,169 @@ +import { BankError, defineBankFactory, defineBankPlugin, defineField } from "@nibblet/bank"; +import { MASKED, type BankProviderId, type CardSecret } from "@nibblet/protocol"; +import { describe, expect, it } from "vitest"; +import { Banks } from "./banks.ts"; + +const secret: CardSecret = { + type: "card", + pan: "4111111111111111", + cvv: "123", + expMonth: "01", + expYear: "2030", + billingAddress: null, +}; + +const KEY = defineField("FAKE_KEY", "API key", { required: true, secret: true }); +const ACCOUNT = defineField("FAKE_ACCOUNT", "Account", { lockedByCards: true }); + +/** A bank that turns on with a key, refuses the key `wrong`, and can't be reached with `down`. */ +const fake = defineBankFactory({ + id: "fake", + name: "Fake", + about: "Cards for tests.", + instrument: "card", + fields: [KEY, ACCOUNT], + fromSettings(settings) { + const key = settings.FAKE_KEY; + if (!key) return null; + if (key === "short") throw new Error("API key is too short"); + return defineBankPlugin({ + id: "fake", + label: `Fake (${settings.FAKE_ACCOUNT ?? "default"})`, + asset: { code: "USD", decimals: 2 }, + instrument: "card", + fundAt: null, + check: async () => { + if (key === "wrong") throw new BankError("Fake: Invalid API key"); + if (key === "down") throw new Error("connect to https://fake.example?key=down failed"); + }, + balance: async () => 500n, + issue: async () => ({ ref: "r", display: "•••• 1111", secret }), + find: async () => null, + reveal: async () => secret, + inspect: async () => ({ open: true, spent: 0n, used: false }), + close: async () => ({ open: false, spent: 0n, used: false, settled: true }), + }); + }, +}); + +function setUp(env: Record = {}) { + const settings = new Map(); + const live: { provider: string }[] = []; + const loaded: string[][] = []; + const logs: string[] = []; + let plugins = new Map(); + const banks = new Banks({ + store: { + async getSetting(key: string, parse: (v: unknown) => T, fallback: T) { + return settings.has(key) ? parse(settings.get(key)) : fallback; + }, + async setSetting(key: string, value: unknown) { + settings.set(key, structuredClone(value)); + }, + liveCards: async () => live, + }, + bank: { + status: async () => ({ + providers: [...plugins.entries()].map(([id, p]) => ({ + id, + label: p.label, + asset: { code: "USD", decimals: 2 }, + instrument: "card" as const, + state: "ready" as const, + detail: null, + balance: "500", + fundAt: null, + })), + problems: [], + }), + use: (next) => { + plugins = new Map([...next].map(([id, p]) => [id, { label: p.label }])); + loaded.push([...next.keys()]); + }, + }, + env, + factories: [fake], + plugin: { fetch: globalThis.fetch, log: () => {} }, + log: (m) => logs.push(m), + }); + return { banks, settings, live, loaded, logs }; +} + +describe("setting banks up", () => { + it("saves settings the provider takes, and loads the bank with them", async () => { + const { banks, settings, loaded } = setUp(); + await banks.load(); + expect(loaded).toEqual([[]]); + const status = await banks.configure("fake", { FAKE_KEY: "k1", FAKE_ACCOUNT: "acc" }); + expect(settings.get("banks")).toEqual({ fake: { FAKE_KEY: "k1", FAKE_ACCOUNT: "acc" } }); + expect(loaded.at(-1)).toEqual(["fake"]); + expect(status.providers.map((p) => p.label)).toEqual(["Fake (acc)"]); + expect(status.kinds).toEqual([ + expect.objectContaining({ + id: "fake", + name: "Fake", + source: "saved", + settings: { FAKE_KEY: MASKED, FAKE_ACCOUNT: "acc" }, + liveCards: 0, + }), + ]); + }); + + it("keeps a secret sent back masked", async () => { + const { banks, settings } = setUp(); + await banks.configure("fake", { FAKE_KEY: "k1" }); + await banks.configure("fake", { FAKE_KEY: MASKED, FAKE_ACCOUNT: "acc" }); + expect(settings.get("banks")).toEqual({ fake: { FAKE_KEY: "k1", FAKE_ACCOUNT: "acc" } }); + }); + + it("saves nothing the provider refuses, saying why", async () => { + const { banks, settings, logs } = setUp(); + await expect(banks.configure("fake", { FAKE_KEY: "" })).rejects.toThrow("API key is needed"); + await expect(banks.configure("fake", { FAKE_KEY: "short" })).rejects.toThrow( + "API key is too short", + ); + await expect(banks.configure("fake", { FAKE_KEY: "wrong" })).rejects.toThrow( + "Fake: Invalid API key", + ); + // Anything but a BankError could carry a credential, so it's only logged. + const down = banks.configure("fake", { FAKE_KEY: "down" }); + await expect(down).rejects.toThrow("Fake didn't answer; the server's log has why"); + expect(logs.join("\n")).toContain("key=down"); + await expect(banks.configure("nope", {})).rejects.toThrow("No bank called nope"); + expect(settings.has("banks")).toBe(false); + }); + + it("won't change what open cards depend on, or remove a bank they need", async () => { + const { banks, live, settings } = setUp(); + await banks.configure("fake", { FAKE_KEY: "k1", FAKE_ACCOUNT: "acc" }); + live.push({ provider: "fake" }, { provider: "other" }); + await expect( + banks.configure("fake", { FAKE_KEY: MASKED, FAKE_ACCOUNT: "acc2" }), + ).rejects.toThrow( + "Account can't change while Fake has a card that isn't closed: kill them first.", + ); + // Fields that aren't locked still can. + await banks.configure("fake", { FAKE_KEY: "k2", FAKE_ACCOUNT: "acc" }); + await expect(banks.remove("fake")).rejects.toThrow( + "Fake has a card that isn't closed, and closing them needs it", + ); + live.length = 0; + await banks.remove("fake"); + expect(settings.get("banks")).toEqual({}); + }); + + it("falls back to the environment, and lets Settings take over from it", async () => { + const { banks, loaded } = setUp({ FAKE_KEY: "from-env" }); + await banks.load(); + expect(loaded).toEqual([["fake"]]); + expect((await banks.status()).kinds[0]).toMatchObject({ + source: "env", + settings: { FAKE_KEY: MASKED }, + }); + // Saving keeps the environment's key when it's sent back masked. + await banks.configure("fake", { FAKE_KEY: MASKED, FAKE_ACCOUNT: "acc" }); + expect((await banks.status()).kinds[0]).toMatchObject({ source: "saved" }); + await banks.remove("fake"); + expect((await banks.status()).kinds[0]).toMatchObject({ source: "env" }); + }); +}); diff --git a/apps/server/src/banks.ts b/apps/server/src/banks.ts index 68978213..850b14a3 100644 --- a/apps/server/src/banks.ts +++ b/apps/server/src/banks.ts @@ -1,20 +1,192 @@ -import { loadBankPlugins, type BankPluginFactory, type LoadedBanks } from "@nibblet/bank"; +import { + BankError, + changedLockedFields, + loadBankPlugins, + maskSettings, + mergeSettings, + resolveSettings, + type BankDeps, + type BankPlugin, + type BankPluginFactory, + type BankSettingValues, +} from "@nibblet/bank"; import { bitcoin } from "@nibblet/bank-bitcoin"; import { ethereum } from "@nibblet/bank-ethereum"; import { slash } from "@nibblet/bank-slash"; +import { + BankProviderId, + BankSettings, + RpcError, + type BankKind, + type BankStatus, +} from "@nibblet/protocol"; +import { z } from "zod"; +import type { Bank } from "./bank.ts"; +import type { CardRow, Store } from "./store.ts"; /** - * Every bank plugin the server knows. Each turns itself on when its settings - * are in the environment (see the server's environment in `docker/compose.yaml`), and stays off otherwise. + * Every bank plugin the server knows. Each is set up in Settings, which + * keeps its settings in the database, or else from the server's environment + * (variables named like its fields; see `docker/compose.yaml`), and stays + * off otherwise. */ export const BANK_PLUGINS: readonly BankPluginFactory[] = [slash, bitcoin, ethereum]; -export function loadBanks(env: NodeJS.ProcessEnv = process.env): LoadedBanks { - const loaded = loadBankPlugins(BANK_PLUGINS, env, { - fetch: globalThis.fetch, - log: (message) => console.warn(`bank: ${message}`), - }); - for (const plugin of loaded.plugins.values()) console.log(`bank: ${plugin.label} is set up`); - for (const [id, problem] of loaded.errors) console.warn(`bank: ${id} isn't set up: ${problem}`); - return loaded; +const SETTING = "banks"; +const Saved = z.record(BankProviderId, BankSettings); +type Saved = z.infer; + +export interface BanksDeps { + store: Pick & { + liveCards(): Promise[]>; + }; + bank: Pick; + env: BankSettingValues; + factories?: readonly BankPluginFactory[]; + plugin?: BankDeps; + log?(message: string): void; +} + +/** + * Which banks are set up, and setting them up: their settings, checked with + * their providers before they're saved, then loaded into the `Bank` without + * a restart. Each plugin describes its own form (`fields`), so nothing here + * or in the web app knows one bank from another. + */ +export class Banks { + private readonly deps: BanksDeps; + private readonly factories: readonly BankPluginFactory[]; + private readonly plugin: BankDeps; + private readonly log: (message: string) => void; + /** One change at a time, so two saves can't each write over the other's. */ + private queue: Promise = Promise.resolve(); + + constructor(deps: BanksDeps) { + this.deps = deps; + this.factories = deps.factories ?? BANK_PLUGINS; + this.log = deps.log ?? ((message) => console.warn(`bank: ${message}`)); + this.plugin = deps.plugin ?? { fetch: globalThis.fetch, log: this.log }; + } + + /** Loads every bank as it's set up now into the `Bank`. */ + async load(): Promise { + const saved = await this.saved(); + const settings = new Map( + this.factories.map((f) => [f.id, resolveSettings(f, saved[f.id], this.deps.env).settings]), + ); + const { plugins, errors } = loadBankPlugins(this.factories, settings, this.plugin); + for (const plugin of plugins.values()) console.log(`bank: ${plugin.label} is set up`); + for (const [id, problem] of errors) this.log(`${id} isn't set up: ${problem}`); + this.deps.bank.use(plugins, errors); + } + + async status(): Promise { + const [health, kinds] = await Promise.all([this.deps.bank.status(), this.kinds()]); + return { ...health, kinds }; + } + + /** Checks the settings with the provider, saves them, and loads the bank with them. */ + configure(id: string, input: BankSettings): Promise { + return this.serially(async () => { + const factory = this.factory(id); + const saved = await this.saved(); + const current = resolveSettings(factory, saved[id], this.deps.env).settings; + const next = mergeSettings(factory.fields, input, current); + const missing = factory.fields.find((f) => f.required && !next[f.key]); + if (missing) throw new RpcError("bad_request", `${missing.label} is needed`); + const live = await this.liveCards(id); + const locked = changedLockedFields(factory.fields, current, next); + if (live > 0 && locked.length > 0) { + throw new RpcError( + "conflict", + `${locked.map((f) => f.label).join(" and ")} can't change while ${factory.name} has ${openCards(live)}: kill them first.`, + ); + } + const plugin = this.build(factory, next); + try { + await plugin.check(); + } catch (err) { + if (err instanceof BankError) throw new RpcError("bad_request", err.message); + this.log(`checking ${id}: ${err instanceof Error ? err.message : String(err)}`); + throw new RpcError( + "unavailable", + `${factory.name} didn't answer; the server's log has why`, + ); + } + await this.deps.store.setSetting(SETTING, { ...saved, [id]: next }); + await this.load(); + return this.status(); + }); + } + + /** Forgets a bank's saved settings, unless it has cards that still need it to close. */ + remove(id: string): Promise { + return this.serially(async () => { + const factory = this.factory(id); + const live = await this.liveCards(id); + if (live > 0) { + throw new RpcError( + "conflict", + `${factory.name} has ${openCards(live)}, and closing them needs it: kill them first.`, + ); + } + const saved = await this.saved(); + delete saved[id]; + await this.deps.store.setSetting(SETTING, saved); + await this.load(); + return this.status(); + }); + } + + private async kinds(): Promise { + const [saved, live] = await Promise.all([this.saved(), this.deps.store.liveCards()]); + return this.factories.map((f) => { + const { source, settings } = resolveSettings(f, saved[f.id], this.deps.env); + return { + id: f.id, + name: f.name, + about: f.about, + instrument: f.instrument, + fields: [...f.fields], + source, + settings: maskSettings(f.fields, settings), + liveCards: live.filter((c) => c.provider === f.id).length, + }; + }); + } + + private build(factory: BankPluginFactory, settings: BankSettings): BankPlugin { + let plugin: BankPlugin | null; + try { + plugin = factory.fromSettings(settings, this.plugin); + } catch (err) { + throw new RpcError("bad_request", err instanceof Error ? err.message : String(err)); + } + if (!plugin) throw new RpcError("bad_request", `${factory.name} needs more settings`); + return plugin; + } + + private factory(id: string): BankPluginFactory { + const factory = this.factories.find((f) => f.id === id); + if (!factory) throw new RpcError("not_found", `No bank called ${id}`); + return factory; + } + + private saved(): Promise { + return this.deps.store.getSetting(SETTING, (v) => Saved.parse(v), {}); + } + + private async liveCards(id: string): Promise { + return (await this.deps.store.liveCards()).filter((c) => c.provider === id).length; + } + + private serially(work: () => Promise): Promise { + const next = this.queue.then(work, work); + this.queue = next.catch(() => undefined); + return next; + } +} + +function openCards(n: number): string { + return n === 1 ? "a card that isn't closed" : `${n} cards that aren't closed`; } diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts index d5016b01..354218dd 100644 --- a/apps/server/src/handlers.ts +++ b/apps/server/src/handlers.ts @@ -15,6 +15,7 @@ import { cliStatuses } from "./clilogins.ts"; import { readTailnetOAuth, setTailnetKeys, tailnetKeys } from "./tailnet-keys.ts"; import { forgetGrants, onePasswordStatus, readGrants, setGrants } from "./onepassword.ts"; import type { Bank } from "./bank.ts"; +import type { Banks } from "./banks.ts"; import type { Conductor } from "./conductor.ts"; import type { HarnessModelLists } from "./opencode.ts"; import { @@ -50,6 +51,7 @@ export interface Services { harnessUpdates: HarnessUpdater; appUpdates: AppUpdater; bank: Bank; + banks: Banks; } const ok = { ok: true } as const; @@ -270,10 +272,12 @@ export function createHandlers(s: Services): Handlers s.bank.status(), + status: () => s.banks.status(), cards: ({ nibbletId }) => s.bank.cards(nibbletId), allowances: ({ nibbletId }) => s.bank.allowances(nibbletId), kill: ({ id }) => s.bank.kill(id, "user"), + configure: ({ id, settings }) => s.banks.configure(id, settings), + remove: ({ id }) => s.banks.remove(id), }, snapshots: { list: ({ nibbletId }) => s.snapshots.list(nibbletId), diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 3cbb1ff5..31a3fc65 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -24,7 +24,7 @@ import { AppUpdater, HarnessUpdater } from "./updates.ts"; import { Notifier } from "./notifier.ts"; import { tailscaleStatus } from "./hostmcp.ts"; import { Bank } from "./bank.ts"; -import { loadBanks } from "./banks.ts"; +import { Banks } from "./banks.ts"; // The repository's version, which releases are tagged with. const VERSION = z @@ -57,15 +57,15 @@ async function main(): Promise { conductor.terminals.onTasks = (nibbletId, tasks) => bus.publish({ type: "tasks", nibbletId, tasks }); conductor.terminals.onApps = (nibbletId, apps) => bus.publish({ type: "apps", nibbletId, apps }); - const banks = loadBanks(); const bank = new Bank({ store, - plugins: banks.plugins, - errors: banks.errors, + plugins: new Map(), files: conductor.cardFiles, publish: (message) => bus.publish(message), log: (message) => console.warn(`bank: ${message}`), }); + const banks = new Banks({ store, bank, env: process.env }); + await banks.load(); conductor.bank = bank; const scheduler = new Scheduler(store, conductor); const snapshots = new Snapshots({ @@ -115,6 +115,7 @@ async function main(): Promise { harnessUpdates, appUpdates, bank, + banks, }, bus, ); diff --git a/apps/web/src/components/bank/BankDialog.tsx b/apps/web/src/components/bank/BankDialog.tsx new file mode 100644 index 00000000..ab303ec4 --- /dev/null +++ b/apps/web/src/components/bank/BankDialog.tsx @@ -0,0 +1,276 @@ +import { useEffect, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { ArrowLeft, ChevronRight, Copy, CreditCard, KeyRound, Wallet } from "lucide-react"; +import type { BankField, BankInstrument, BankKind, BankStatus } from "@nibblet/protocol"; +import type { ServerConnection } from "../../lib/connection.ts"; +import { + bankDraftChanged, + bankDraftOf, + bankSettingsOf, + missingField, + newPrivateKey, + secretSaved, + type BankDraft, +} from "../../lib/bank.ts"; +import { keys } from "../../lib/servers.tsx"; +import { Button } from "../ui/Button.tsx"; +import { FieldRow, Input, Select } from "../ui/Field.tsx"; +import { Modal } from "../ui/Modal.tsx"; + +export const instrumentIcons: Record = { + card: CreditCard, + wallet: Wallet, +}; + +/** + * Setting a bank up, or changing one: a choice of the banks the server + * knows, then the form its plugin describes. Nothing here knows one bank + * from another; each plugin says what it needs (`BankKind.fields`). + */ +export function BankDialog({ + connection, + kinds, + chosen, + onClose, +}: { + connection: ServerConnection; + kinds: readonly BankKind[]; + /** A bank's id to open its form, null to choose one to add, undefined when closed. */ + chosen: string | null | undefined; + onClose: () => void; +}) { + const [picked, setPicked] = useState(null); + useEffect(() => setPicked(null), [chosen]); + const id = chosen ?? picked; + const kind = kinds.find((k) => k.id === id) ?? null; + const title = kind ? (kind.source ? kind.name : `Set up ${kind.name}`) : "Add a bank"; + return ( + (open ? undefined : onClose())} + title={title} + className="w-[min(94vw,560px)]" + > + {kind ? ( + setPicked(null) : null} + onDone={onClose} + /> + ) : ( + k.source === null)} onChoose={setPicked} /> + )} + + ); +} + +function BankChoice({ + kinds, + onChoose, +}: { + kinds: readonly BankKind[]; + onChoose: (id: string) => void; +}) { + if (kinds.length === 0) { + return

Every bank this server knows is set up.

; + } + return ( +
+ {kinds.map((kind) => { + const Icon = instrumentIcons[kind.instrument]; + return ( + + ); + })} +
+ ); +} + +function BankForm({ + connection, + kind, + onBack, + onDone, +}: { + connection: ServerConnection; + kind: BankKind; + onBack: (() => void) | null; + onDone: () => void; +}) { + const qc = useQueryClient(); + const [draft, setDraft] = useState(() => bankDraftOf(kind)); + /** Keys the form made, shown in the clear so they can be copied before they're saved. */ + const [generated, setGenerated] = useState>(new Set()); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const missing = missingField(kind, draft); + const changed = kind.source === null || bankDraftChanged(kind, draft); + const locked = (field: BankField) => + field.lockedByCards && kind.source !== null && kind.liveCards > 0; + + const run = async (call: () => Promise) => { + setBusy(true); + setError(null); + try { + qc.setQueryData(keys.bank(connection.id), await call()); + onDone(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + const save = () => + run(() => + connection.client.bank.configure({ id: kind.id, settings: bankSettingsOf(kind, draft) }), + ); + const remove = () => run(() => connection.client.bank.remove({ id: kind.id })); + + return ( + <> +
+

+ {kind.about} + {kind.source === "env" + ? " It's set up from the server's environment now; saving here takes over from that." + : null} +

+ {kind.fields.map((field) => ( + + setDraft({ ...draft, [field.key]: value })} + onGenerate={() => { + setDraft({ ...draft, [field.key]: newPrivateKey() }); + setGenerated(new Set([...generated, field.key])); + }} + /> + + ))} +
+
+ {onBack ? ( + + ) : null} + {kind.source === "saved" ? ( + + ) : null} + + {error + ? error + : missing + ? `${missing.label} is needed.` + : `It's checked with ${kind.name} when you save.`} + + +
+ + ); +} + +function FieldInput({ + field, + value, + saved, + shown, + disabled, + onChange, + onGenerate, +}: { + field: BankField; + value: string; + /** A secret's saved, so empty keeps it. */ + saved: boolean; + /** A secret made here, shown so it can be copied. */ + shown: boolean; + disabled: boolean; + onChange: (value: string) => void; + onGenerate: () => void; +}) { + if (field.options) { + const options = + value === "" ? [{ value: "", label: "Choose…" }, ...field.options] : field.options; + return onChange(e.target.value)} + /> + ); + if (!field.generate) return input; + return ( + + {input} + {shown ? ( + + ) : ( + + )} + + ); +} diff --git a/apps/web/src/components/bank/BankSection.tsx b/apps/web/src/components/bank/BankSection.tsx index c34dc389..e4567614 100644 --- a/apps/web/src/components/bank/BankSection.tsx +++ b/apps/web/src/components/bank/BankSection.tsx @@ -1,13 +1,17 @@ +import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { CheckCircle2, XCircle } from "lucide-react"; +import { CheckCircle2, Copy, Plus, XCircle } from "lucide-react"; +import type { BankKind, BankProvider } from "@nibblet/protocol"; import type { ServerConnection } from "../../lib/connection.ts"; import { money } from "../../lib/bank.ts"; import { keys } from "../../lib/servers.tsx"; +import { Button } from "../ui/Button.tsx"; import { SectionHeading } from "../ui/Field.tsx"; +import { BankDialog } from "./BankDialog.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. + * set up here, and what each has left to fund cards with. */ export function BankSection({ connection }: { connection: ServerConnection }) { const status = useQuery({ @@ -15,53 +19,122 @@ export function BankSection({ connection }: { connection: ServerConnection }) { queryFn: () => connection.client.bank.status({}), refetchInterval: 60_000, }); - const providers = status.data?.providers ?? []; - const problems = status.data?.problems ?? []; + /** A bank's id to edit it, null to add one, undefined when the dialog's closed. */ + const [dialog, setDialog] = useState(undefined); + // Card events don't refetch this (it asks every provider for its balance), + // so the open cards that lock a bank's fields are fetched fresh for the form. + const open = (id: string | null) => { + setDialog(id); + void status.refetch(); + }; + const kinds = status.data?.kinds ?? []; + const setUp = kinds.filter((k) => k.source !== null); + const addable = kinds.some((k) => k.source === null); return ( <> - Bank + open(null)}> + + Add a bank + + ) : null + } + > + 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." + {setUp.length === 0 + ? "No bank is set up, so nibblets can't have cards. Add a card issuer or a crypto treasury, then give each nibblet a spending limit in its settings." : "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} -
-
+ {setUp.map((kind) => ( + p.id === kind.id) ?? null} + problem={status.data?.problems.find((p) => p.id === kind.id)?.detail ?? null} + onEdit={() => open(kind.id)} + /> ))} + setDialog(undefined)} + /> ); } + +function BankRow({ + kind, + provider, + problem, + onEdit, +}: { + kind: BankKind; + provider: BankProvider | null; + /** Why its settings don't load, if they don't. */ + problem: string | null; + onEdit: () => void; +}) { + const [copied, setCopied] = useState(false); + const ready = provider?.state === "ready"; + const detail = !provider + ? `Not set up: ${problem ?? "its settings aren't finished"}` + : provider.state === "error" + ? `Can't be reached: ${provider.detail ?? "no reason given"}` + : provider.balance !== null + ? `${money(provider.balance, provider.asset)} available` + : "Ready"; + return ( +
+ {ready ? ( + + ) : ( + + )} +
+ + {provider?.label ?? kind.name} + {provider ? ( + {provider.asset.code} + ) : null} + + {detail} + {provider?.fundAt ? ( + + Top up at + + {provider.fundAt} + + + {copied ? Copied : null} + + ) : null} + {kind.source === "env" ? ( + From the server's environment + ) : null} +
+ +
+ ); +} diff --git a/apps/web/src/components/bank/NibbletSpending.tsx b/apps/web/src/components/bank/NibbletSpending.tsx index e7d900fc..06d9151c 100644 --- a/apps/web/src/components/bank/NibbletSpending.tsx +++ b/apps/web/src/components/bank/NibbletSpending.tsx @@ -1,7 +1,6 @@ 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 { SpendingPeriod, type BankKind, type Card, type Nibblet } from "@nibblet/protocol"; import type { ServerConnection } from "../../lib/connection.ts"; import { bankAssets, @@ -16,6 +15,7 @@ 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"; +import { instrumentIcons } from "./BankDialog.tsx"; const periodOptions = SpendingPeriod.options.map((p) => ({ value: p, label: periodLabels[p] })); @@ -127,12 +127,20 @@ export function NibbletSpending({ {error ? {error} : null} ) : null} - + ); } -function CardList({ connection, cards }: { connection: ServerConnection; cards: Card[] }) { +function CardList({ + connection, + cards, + kinds, +}: { + connection: ServerConnection; + cards: Card[]; + kinds: readonly BankKind[]; +}) { const [showClosed, setShowClosed] = useState(false); const open = cards.filter((c) => c.state !== "closed"); const closed = cards.filter((c) => c.state === "closed"); @@ -140,7 +148,7 @@ function CardList({ connection, cards }: { connection: ServerConnection; cards: return (
{open.map((card) => ( - + ))} {open.length === 0 ?

No open cards.

: null} {closed.length > 0 ? ( @@ -153,7 +161,9 @@ function CardList({ connection, cards }: { connection: ServerConnection; cards: ) : null} {showClosed - ? closed.map((card) => ) + ? closed.map((card) => ( + + )) : null}
); @@ -166,15 +176,29 @@ const closedByLabel: Record, string> = { server: "closed: it couldn't reach the container", }; +function kindOf(kinds: readonly BankKind[], card: Card): BankKind | null { + return kinds.find((k) => k.id === card.provider) ?? null; +} + /** What a card that isn't closed is doing, and since when. */ -function liveLabel(card: Card): string { +function liveLabel(card: Card, bank: string): 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}`; + if (card.state === "closing") return `closing, waiting on ${bank} · made ${made}`; return `made ${made}`; } -function CardRow({ connection, card }: { connection: ServerConnection; card: Card }) { +function CardRow({ + connection, + card, + kind, +}: { + connection: ServerConnection; + card: Card; + /** Its bank's plugin, which says what it is; null if the server no longer knows it. */ + kind: BankKind | null; +}) { + const bank = kind?.name ?? card.provider; const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const kill = async () => { @@ -188,7 +212,7 @@ function CardRow({ connection, card }: { connection: ServerConnection; card: Car setBusy(false); } }; - const Icon = card.display.startsWith("•") ? CreditCard : Wallet; + const Icon = instrumentIcons[kind?.instrument ?? "card"]; return (
{card.display} - {describeCard(card)} · {card.provider} ·{" "} + {describeCard(card)} · {bank} ·{" "} {card.state === "closed" ? `${card.closedBy ? closedByLabel[card.closedBy] : "closed"} ${card.closedAt ? relativeTime(card.closedAt) : ""}` - : liveLabel(card)} + : liveLabel(card, bank)} {error ? {error} : null}
diff --git a/apps/web/src/components/ui/Field.tsx b/apps/web/src/components/ui/Field.tsx index 08e048dd..5cd3d08d 100644 --- a/apps/web/src/components/ui/Field.tsx +++ b/apps/web/src/components/ui/Field.tsx @@ -2,7 +2,7 @@ import type { InputHTMLAttributes, ReactNode, TextareaHTMLAttributes } from "rea import { cn } from "../../lib/cn.ts"; const fieldClass = - "w-full rounded-md border border-edge bg-field px-2 text-sm text-text placeholder:text-faint focus:border-accent focus:outline-none"; + "w-full rounded-md border border-edge bg-field px-2 text-sm text-text placeholder:text-faint focus:border-accent focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"; export function Input({ className, ...props }: InputHTMLAttributes) { return ; @@ -22,16 +22,19 @@ export function Select({ options, value, onChange, + disabled, }: { className?: string; options: readonly { value: string; label: string }[]; value: string; onChange: (value: string) => void; + disabled?: boolean; }) { return (