From 01156869919ed0371686fc1bacd8a8a94c0ad272 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 15:20:57 +0000 Subject: [PATCH] Add Slash as the bank for nibblets' virtual cards `@nibblet/bank-slash` issues USD virtual cards on the person's Slash account: `isSingleUse` cards, which Slash closes after one authorisation, and ones for several purchases, each with a collective (never resetting) utilisation limit of the card's limit, so Slash itself declines anything over it. It reports spending from the card's utilisation, finds a card an interrupted issue made by the id it tags it with, and shows the account's balance in Settings. It turns on with `SLASH_API_KEY` (optionally `SLASH_ACCOUNT_ID`), passed to the server by compose. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KGXXQcj3hZN2jiB6As6kk5 --- ARCHITECTURE.md | 1 + apps/server/package.json | 1 + apps/server/src/banks.ts | 5 +- docker/compose.yaml | 4 + packages/bank-slash/package.json | 17 ++ packages/bank-slash/src/index.ts | 264 ++++++++++++++++++++++++++ packages/bank-slash/src/slash.test.ts | 214 +++++++++++++++++++++ packages/bank-slash/tsconfig.json | 4 + pnpm-lock.yaml | 15 ++ 9 files changed, 523 insertions(+), 2 deletions(-) create mode 100644 packages/bank-slash/package.json create mode 100644 packages/bank-slash/src/index.ts create mode 100644 packages/bank-slash/src/slash.test.ts create mode 100644 packages/bank-slash/tsconfig.json diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e7410741..a42fd785 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -165,6 +165,7 @@ A nibblet can be given money to spend, as virtual cards or as crypto wallets tha - **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. - **Card states:** a card's row goes in as `pending` before the plugin is asked for it, so one the provider made before something failed (a timeout after it was sent) is never lost: every minute the server asks the plugin to `find` pending cards it isn't still making, closes any that were made, and forgets ones that haven't shown up in an hour. A plugin throws a `BankError` only for a refusal, where nothing was made; its message is the only kind passed on, since other errors can carry credentials (an RPC URL with its key), so they're logged on the server instead. - **Closing:** `card_kill` (the nibblet, its own cards), Kill in the UI (the person, any), and deleting a nibblet close cards at the provider and delete their files. A card the provider can't settle yet (a wallet whose funding isn't seen, so there's nothing to sweep) stays `closing`, counting in full, and the server tries again every minute; after a day it's recorded as closed with a warning in the log. A nibblet with a card that can't be closed isn't deleted, so the card's record (and a wallet's id, from which its key comes) isn't lost. Every minute the server also checks open cards with their providers: it records what's been spent, notices cards the provider closed, and closes single-use cards that have been used where the provider doesn't do that itself. +- **Slash** (`packages/bank-slash`, `USD`): virtual cards on the person's Slash account, set up with `SLASH_API_KEY` (with the cards.view and cards.manage permissions), plus `SLASH_ACCOUNT_ID` for the account cards are made under and whose balance Settings shows (Slash picks the first commercial account otherwise). Single-use cards are Slash's own `isSingleUse`, which Slash closes after the first authorisation; both kinds carry a `collective` (never resetting) utilisation limit of the card's limit, so Slash declines anything over it. Each card is tagged with its id in `userData`, which is how `find` recognises it. - **Wallets:** a crypto plugin funds a fresh wallet per card from its treasury with exactly the card's limit, so the limit holds on chain. The wallet's key is derived from the treasury's secret and the card's id (`deriveWalletKey`), so none is stored, and killing the card sweeps what's left back to the treasury. ## API diff --git a/apps/server/package.json b/apps/server/package.json index 1d853cad..5079481a 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -14,6 +14,7 @@ "@huggingface/transformers": "^4.3.1", "@modelcontextprotocol/sdk": "^1.32.1", "@nibblet/bank": "workspace:*", + "@nibblet/bank-slash": "workspace:*", "@nibblet/db": "workspace:*", "@nibblet/protocol": "workspace:*", "croner": "^10.0.1", diff --git a/apps/server/src/banks.ts b/apps/server/src/banks.ts index 12766608..e674c84c 100644 --- a/apps/server/src/banks.ts +++ b/apps/server/src/banks.ts @@ -1,10 +1,11 @@ import { loadBankPlugins, type BankPluginFactory, type LoadedBanks } from "@nibblet/bank"; +import { slash } from "@nibblet/bank-slash"; /** * Every bank plugin the server knows. Each turns itself on when its settings - * are in the environment (see `docker/.env.example`), and stays off otherwise. + * are in the environment (see the server's environment in `docker/compose.yaml`), and stays off otherwise. */ -export const BANK_PLUGINS: readonly BankPluginFactory[] = []; +export const BANK_PLUGINS: readonly BankPluginFactory[] = [slash]; export function loadBanks(env: NodeJS.ProcessEnv = process.env): LoadedBanks { const loaded = loadBankPlugins(BANK_PLUGINS, env, { diff --git a/docker/compose.yaml b/docker/compose.yaml index 77e4077e..ec1a046f 100644 --- a/docker/compose.yaml +++ b/docker/compose.yaml @@ -90,6 +90,10 @@ services: NIBBLET_HOST_NAME: ${NIBBLET_HOST_NAME:-} NIBBLET_HOST_PLATFORM: ${NIBBLET_HOST_PLATFORM:-other} NIBBLET_LAN_URLS: ${NIBBLET_LAN_URLS:-} + # Banks for nibblets' cards (packages/bank-*); each is off until its settings are set. + SLASH_API_KEY: ${SLASH_API_KEY:-} + SLASH_ACCOUNT_ID: ${SLASH_ACCOUNT_ID:-} + SLASH_BASE_URL: ${SLASH_BASE_URL:-} # Reaches the host MCP bridge; Docker Desktop provides this name, Linux needs the mapping. extra_hosts: - host.docker.internal:host-gateway diff --git a/packages/bank-slash/package.json b/packages/bank-slash/package.json new file mode 100644 index 00000000..cfb958a6 --- /dev/null +++ b/packages/bank-slash/package.json @@ -0,0 +1,17 @@ +{ + "name": "@nibblet/bank-slash", + "version": "0.3.1", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc -p ." + }, + "dependencies": { + "@nibblet/bank": "workspace:*", + "@nibblet/protocol": "workspace:*", + "zod": "^4.6.7" + } +} diff --git a/packages/bank-slash/src/index.ts b/packages/bank-slash/src/index.ts new file mode 100644 index 00000000..7fcbc8fc --- /dev/null +++ b/packages/bank-slash/src/index.ts @@ -0,0 +1,264 @@ +import { + BankError, + defineBankFactory, + defineBankPlugin, + maskedCard, + requireEnv, + type BankDeps, + type BankEnv, + type BankPlugin, + type CardRef, + type CardUsage, +} from "@nibblet/bank"; +import type { CardSecret } from "@nibblet/protocol"; +import { z } from "zod"; + +/** + * Virtual cards from Slash (slash.com), in US dollars, on the person's own + * Slash account. A single-use card is Slash's own `isSingleUse`, which Slash + * closes after the first authorisation; a multi-use one stays open until + * it's killed. Both carry a `collective` utilisation limit (one that never + * resets) of the card's limit, so Slash declines anything over it whatever + * the server thinks. + * + * Settings: `SLASH_API_KEY` (with the cards.view and cards.manage + * permissions), and optionally `SLASH_ACCOUNT_ID` (the account cards are + * made under, and whose balance Settings shows; Slash picks the first + * commercial account otherwise) and `SLASH_BASE_URL`. + */ + +export const SLASH_API = "https://api.joinslash.com"; + +/** How many pages of recent cards `find` looks through. */ +const FIND_PAGES = 3; + +const Money = z.object({ amountCents: z.number() }); + +const SlashCard = z.object({ + id: z.string(), + last4: z.string(), + status: z.enum(["active", "paused", "inactive", "closed"]), + expiryMonth: z.string(), + expiryYear: z.string(), + pan: z.string().optional(), + cvv: z.string().optional(), + userData: z.unknown().optional(), +}); +type SlashCard = z.infer; + +const CardList = z.object({ + items: z.array(SlashCard), + metadata: z.object({ nextCursor: z.string().optional() }).optional(), +}); + +const Utilization = z.object({ spend: Money }); + +const Balances = z.object({ balances: z.array(z.object({ available: Money })) }); + +const ErrorBody = z.union([z.object({ message: z.string() }), z.object({ error: z.string() })]); + +/** The card id the server tagged a Slash card with, if it has one. */ +const Tagged = z.object({ userData: z.object({ nibbletCard: z.string() }) }); + +/** + * Slash said no. Its own messages are safe to show, and a refused request + * made nothing. + */ +export class SlashError extends BankError { + readonly status: number; + constructor(status: number, message: string) { + super(`Slash: ${message}`); + this.status = status; + } +} + +export interface SlashOptions { + apiKey: string; + accountId?: string; + baseUrl?: string; +} + +/** A whole number of cents for Slash, which takes them as a JSON number. */ +function cents(amount: bigint): number { + if (amount > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new BankError("That's more than a card can hold"); + } + return Number(amount); +} + +export function slashPlugin(options: SlashOptions, deps: Pick): BankPlugin { + const base = (options.baseUrl ?? SLASH_API).replace(/\/+$/, ""); + + async function call(method: string, path: string, body?: object): Promise { + const res = await deps.fetch(`${base}${path}`, { + method, + headers: { + "x-api-key": options.apiKey, + accept: "application/json", + ...(body ? { "content-type": "application/json" } : {}), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + signal: AbortSignal.timeout(30_000), + }); + const text = await res.text(); + let parsed: unknown = null; + try { + parsed = text ? JSON.parse(text) : null; + } catch { + // Not JSON; the status says enough. + } + if (res.status >= 400 && res.status < 500) { + const error = ErrorBody.safeParse(parsed); + const said = error.success + ? "message" in error.data + ? error.data.message + : error.data.error + : `HTTP ${res.status}`; + throw new SlashError(res.status, said); + } + // A server error leaves it unclear whether anything happened. + if (!res.ok) throw new Error(`Slash: HTTP ${res.status} for ${method} ${path}`); + return parsed; + } + + const getCard = async (id: string, withNumber = false) => + SlashCard.parse( + await call( + "GET", + `/card/${encodeURIComponent(id)}${withNumber ? "?include_pan=true&include_cvv=true" : ""}`, + ), + ); + + function secret(card: SlashCard): CardSecret { + if (!card.pan || !card.cvv) { + throw new BankError( + "Slash didn't send the card's number. The API key needs the cards.view permission to hand it over.", + ); + } + return { + type: "card", + pan: card.pan, + cvv: card.cvv, + expMonth: card.expiryMonth, + expYear: card.expiryYear, + billingAddress: null, + }; + } + + async function usage(card: CardRef): Promise { + const id = encodeURIComponent(card.ref); + const [state, used] = await Promise.all([ + getCard(card.ref), + call("GET", `/card/${id}/utilization`).then((v) => Utilization.parse(v)), + ]); + const spent = BigInt(Math.max(0, Math.round(used.spend.amountCents))); + return { open: state.status !== "closed", spent, used: spent > 0n }; + } + + const closeAt = (id: string) => + call("PATCH", `/card/${encodeURIComponent(id)}`, { status: "closed" }); + + return defineBankPlugin({ + id: "slash", + label: "Slash", + asset: { code: "USD", decimals: 2 }, + + async balance() { + if (!options.accountId) return null; + const { balances } = Balances.parse( + await call("GET", `/account/${encodeURIComponent(options.accountId)}/balance`), + ); + return balances.reduce((sum, b) => sum + BigInt(Math.round(b.available.amountCents)), 0n); + }, + + async issue(request) { + const made = SlashCard.parse( + await call("POST", "/card", { + type: "virtual", + name: request.label, + isSingleUse: request.kind === "single_use", + ...(options.accountId ? { accountId: options.accountId } : {}), + // So `find` can tell which card is which. + userData: { nibbletCard: request.cardId, nibblet: request.nibbletId }, + spendingConstraint: { + spendingRule: { + utilizationLimit: { + preset: "collective", + limitAmount: { amountCents: cents(request.limit) }, + }, + }, + }, + }), + ); + try { + return { + ref: made.id, + display: maskedCard(made.last4), + secret: secret(await getCard(made.id, true)), + }; + } catch (err) { + // A card nobody can use shouldn't stay open. If it can't be closed, + // the error mustn't read as "nothing was made": the server then + // finds the card and closes it later. + const closed = await closeAt(made.id).then( + () => true, + () => false, + ); + if (closed) throw err; + throw new Error(`Slash card ${made.id} couldn't be used or closed: ${String(err)}`); + } + }, + + async find(cardId) { + let cursor: string | undefined; + for (let page = 0; page < FIND_PAGES; page++) { + const query = new URLSearchParams({ sort: "createdAt", sortDirection: "DESC" }); + if (options.accountId) query.set("filter:accountId", options.accountId); + if (cursor) query.set("cursor", cursor); + const list = CardList.parse(await call("GET", `/card?${query}`)); + for (const card of list.items) { + const tagged = Tagged.safeParse(card); + if (tagged.success && tagged.data.userData.nibbletCard === cardId) { + return { ref: card.id, display: maskedCard(card.last4) }; + } + } + cursor = list.metadata?.nextCursor; + if (!cursor) break; + } + return null; + }, + + async reveal(card) { + return secret(await getCard(card.ref, true)); + }, + + inspect: usage, + + async close(card) { + const current = await getCard(card.ref); + if (current.status !== "closed") await closeAt(card.ref); + return { ...(await usage(card)), open: false, settled: true }; + }, + }); +} + +export const slash = defineBankFactory({ + id: "slash", + env: ["SLASH_API_KEY", "SLASH_ACCOUNT_ID", "SLASH_BASE_URL"], + fromEnv(env: BankEnv, deps: BankDeps) { + if (!env.SLASH_API_KEY?.trim()) return null; + const baseUrl = env.SLASH_BASE_URL?.trim(); + if (baseUrl && !/^https:\/\//.test(baseUrl)) { + throw new Error("SLASH_BASE_URL should be an https URL"); + } + const accountId = env.SLASH_ACCOUNT_ID?.trim(); + return slashPlugin( + { + apiKey: requireEnv(env, "SLASH_API_KEY"), + ...(accountId ? { accountId } : {}), + ...(baseUrl ? { baseUrl } : {}), + }, + deps, + ); + }, +}); diff --git a/packages/bank-slash/src/slash.test.ts b/packages/bank-slash/src/slash.test.ts new file mode 100644 index 00000000..e62f1844 --- /dev/null +++ b/packages/bank-slash/src/slash.test.ts @@ -0,0 +1,214 @@ +import { BankError } from "@nibblet/bank"; +import { describe, expect, it } from "vitest"; +import { slash, slashPlugin, SLASH_API } from "./index.ts"; + +interface Call { + method: string; + url: URL; + key: string | null; + body: unknown; +} + +interface StoredCard { + id: string; + status: string; + spent: number; + body: Record; +} + +/** A Slash that keeps cards in memory, enough for the plugin's calls. */ +function fakeSlash(opts: { hidePan?: boolean; failCreate?: number } = {}) { + const calls: Call[] = []; + const cards = new Map(); + const reply = (status: number, body: object) => new Response(JSON.stringify(body), { status }); + const view = (c: StoredCard, withNumber: boolean) => ({ + id: c.id, + accountId: "acc_1", + last4: "4242", + status: c.status, + expiryMonth: "08", + expiryYear: "2031", + isPhysical: false, + name: c.body.name, + userData: c.body.userData, + ...(withNumber && !opts.hidePan ? { pan: "4111111111114242", cvv: "321" } : {}), + }); + const fetch: typeof globalThis.fetch = async (input, init) => { + const url = new URL(String(input)); + const method = init?.method ?? "GET"; + const body: unknown = typeof init?.body === "string" ? JSON.parse(init.body) : null; + calls.push({ method, url, key: new Headers(init?.headers).get("x-api-key"), body }); + const path = url.pathname; + if (method === "POST" && path === "/card") { + if (opts.failCreate) return reply(opts.failCreate, { message: "Account is frozen" }); + const id = `card_s${cards.size + 1}`; + const record = + typeof body === "object" && body !== null ? Object.fromEntries(Object.entries(body)) : {}; + cards.set(id, { id, status: "active", spent: 0, body: record }); + return reply(200, view(cards.get(id) ?? { id, status: "", spent: 0, body: {} }, false)); + } + if (method === "GET" && path === "/card") { + const items = [...cards.values()].reverse().map((c) => view(c, false)); + return reply(200, { items, metadata: {} }); + } + if (path === "/account/acc_1/balance") { + return reply(200, { + balances: [ + { accountId: "acc_1", available: { amountCents: 123456 }, posted: { amountCents: 0 } }, + ], + }); + } + const used = /^\/card\/([^/]+)\/utilization$/.exec(path); + if (used?.[1]) { + const c = cards.get(used[1]); + return c + ? reply(200, { spend: { amountCents: c.spent } }) + : reply(404, { message: "Not found" }); + } + const one = /^\/card\/([^/]+)$/.exec(path); + if (one?.[1]) { + const c = cards.get(one[1]); + if (!c) return reply(404, { message: "Card not found" }); + if (method === "PATCH" && typeof body === "object" && body && "status" in body) { + c.status = String(body.status); + } + return reply(200, view(c, url.searchParams.get("include_pan") === "true")); + } + return reply(404, { message: `No route ${method} ${path}` }); + }; + return { fetch, calls, cards }; +} + +const request = { + cardId: "card_1", + nibbletId: "nib_1", + label: "Pip: domain", + kind: "single_use" as const, + limit: 2500n, +}; + +describe("slash", () => { + it("issues a single-use card with a lifetime limit in cents", async () => { + const api = fakeSlash(); + const plugin = slashPlugin({ apiKey: "key_1", accountId: "acc_1" }, api); + const card = await plugin.issue(request); + expect(card).toEqual({ + ref: "card_s1", + display: "•••• 4242", + secret: { + type: "card", + pan: "4111111111114242", + cvv: "321", + expMonth: "08", + expYear: "2031", + billingAddress: null, + }, + }); + const [create, reveal] = api.calls; + expect(create).toMatchObject({ method: "POST", key: "key_1" }); + expect(create?.url.href).toBe(`${SLASH_API}/card`); + expect(create?.body).toEqual({ + type: "virtual", + name: "Pip: domain", + isSingleUse: true, + accountId: "acc_1", + userData: { nibbletCard: "card_1", nibblet: "nib_1" }, + spendingConstraint: { + spendingRule: { + utilizationLimit: { preset: "collective", limitAmount: { amountCents: 2500 } }, + }, + }, + }); + expect(reveal?.url.search).toBe("?include_pan=true&include_cvv=true"); + }); + + it("makes a multi-use card that isn't single use", async () => { + const api = fakeSlash(); + await slashPlugin({ apiKey: "k" }, api).issue({ ...request, kind: "multi_use" }); + expect(api.calls[0]?.body).toMatchObject({ isSingleUse: false }); + expect(api.calls[0]?.body).not.toHaveProperty("accountId"); + }); + + it("reads a refusal as nothing made, and a server error as unclear", async () => { + const refused = slashPlugin({ apiKey: "k" }, fakeSlash({ failCreate: 403 })); + const err = await refused.issue(request).catch((e: unknown) => e); + expect(err).toBeInstanceOf(BankError); + expect(String(err)).toContain("Slash: Account is frozen"); + const down = slashPlugin({ apiKey: "k" }, fakeSlash({ failCreate: 502 })); + const unclear = await down.issue(request).catch((e: unknown) => e); + expect(unclear).toBeInstanceOf(Error); + expect(unclear).not.toBeInstanceOf(BankError); + }); + + it("closes a card again when Slash won't hand over its number", async () => { + const api = fakeSlash({ hidePan: true }); + const plugin = slashPlugin({ apiKey: "k" }, api); + await expect(plugin.issue(request)).rejects.toThrow(/cards.view/); + expect(api.cards.get("card_s1")?.status).toBe("closed"); + }); + + it("finds a card it made by the id it was tagged with", async () => { + const api = fakeSlash(); + const plugin = slashPlugin({ apiKey: "k", accountId: "acc_1" }, api); + await plugin.issue(request); + await plugin.issue({ ...request, cardId: "card_2" }); + expect(await plugin.find("card_1")).toEqual({ ref: "card_s1", display: "•••• 4242" }); + expect(await plugin.find("card_9")).toBeNull(); + const list = api.calls.find((c) => c.method === "GET" && c.url.pathname === "/card"); + expect(list?.url.searchParams.get("filter:accountId")).toBe("acc_1"); + }); + + it("reports what's spent, and closes", async () => { + const api = fakeSlash(); + const plugin = slashPlugin({ apiKey: "k" }, api); + const { ref } = await plugin.issue({ ...request, kind: "multi_use" }); + const card = { id: "card_1", ref, kind: "multi_use" as const, limit: 2500n }; + expect(await plugin.inspect(card)).toEqual({ open: true, spent: 0n, used: false }); + const stored = api.cards.get(ref); + if (stored) stored.spent = 1999; + expect(await plugin.inspect(card)).toEqual({ open: true, spent: 1999n, used: true }); + expect(await plugin.close(card)).toEqual({ + open: false, + spent: 1999n, + used: true, + settled: true, + }); + expect(api.cards.get(ref)?.status).toBe("closed"); + const patches = () => api.calls.filter((c) => c.method === "PATCH").length; + const before = patches(); + await plugin.close(card); + expect(patches()).toBe(before); + }); + + it("notices a single-use card Slash closed itself", async () => { + const api = fakeSlash(); + const plugin = slashPlugin({ apiKey: "k" }, api); + const { ref } = await plugin.issue(request); + const stored = api.cards.get(ref); + if (stored) { + stored.spent = 2500; + stored.status = "closed"; + } + expect(await plugin.inspect({ id: "card_1", ref, kind: "single_use", limit: 2500n })).toEqual({ + open: false, + spent: 2500n, + used: true, + }); + }); + + it("reads the account's balance when it knows the account", async () => { + expect(await slashPlugin({ apiKey: "k", accountId: "acc_1" }, fakeSlash()).balance()).toBe( + 123456n, + ); + expect(await slashPlugin({ apiKey: "k" }, fakeSlash()).balance()).toBeNull(); + }); + + it("turns on from its settings, and only then", () => { + const deps = { fetch: globalThis.fetch, log: () => {} }; + expect(slash.fromEnv({}, deps)).toBeNull(); + expect(slash.fromEnv({ SLASH_API_KEY: "k" }, deps)?.label).toBe("Slash"); + expect(() => + slash.fromEnv({ SLASH_API_KEY: "k", SLASH_BASE_URL: "http://plain.example" }, deps), + ).toThrow(/https/); + }); +}); diff --git a/packages/bank-slash/tsconfig.json b/packages/bank-slash/tsconfig.json new file mode 100644 index 00000000..564a5990 --- /dev/null +++ b/packages/bank-slash/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c837cd0..1a5ce0d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -321,6 +321,9 @@ importers: '@nibblet/bank': specifier: workspace:* version: link:../../packages/bank + '@nibblet/bank-slash': + specifier: workspace:* + version: link:../../packages/bank-slash '@nibblet/db': specifier: workspace:* version: link:../../packages/db @@ -438,6 +441,18 @@ importers: specifier: workspace:* version: link:../protocol + packages/bank-slash: + dependencies: + '@nibblet/bank': + specifier: workspace:* + version: link:../bank + '@nibblet/protocol': + specifier: workspace:* + version: link:../protocol + zod: + specifier: ^4.6.7 + version: 4.6.7 + packages/db: dependencies: '@nibblet/protocol':