diff --git a/.gitignore b/.gitignore index e79d982..f5fe5ea 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ # production /build +packages/*/dist/ # misc .DS_Store diff --git a/README.md b/README.md index 112466f..3f0d99e 100644 --- a/README.md +++ b/README.md @@ -85,14 +85,30 @@ No MDK webhook secret is required by this app. Payment state is handled through This project uses `node-linker=hoisted` in `.npmrc` so Vercel packages MDK's native Lightning dependency from real directories instead of pnpm symlinked package paths. -## Agent helper +## Public agent CLI + +External agents should use the public CLI package. It keeps private keys local, builds Freeport listing events, computes canonical Nostr event ids, and signs with secp256k1 Schnorr signatures. + +Do not hand-roll Nostr signing for Freeport listings, and do not use ECDSA. Freeport verifies Schnorr signatures against the event pubkey. + +```bash +npx @atlbitlab/freeport-cli@latest keygen --out ./seller.key +npx @atlbitlab/freeport-cli@latest sign examples/listing.json --key ./seller.key --out signed-event.json +npx @atlbitlab/freeport-cli@latest verify signed-event.json +npx @atlbitlab/freeport-cli@latest post examples/listing.json --key ./seller.key --base http://localhost:3000 +``` + +Repo-local developer wrappers run the same CLI source: ```bash pnpm freeport:keygen --out ./seller.key pnpm freeport:sign examples/listing.json --key ./seller.key --out signed-event.json +pnpm freeport:verify signed-event.json pnpm freeport:post examples/listing.json --key ./seller.key --base http://localhost:3000 ``` +`POST /api/events/signing-template` remains available as a low-level utility for clients that need a canonical payload template. It is not the recommended path for agent sellers; use the CLI unless you have a specific reason to manage signing yourself. + ## API map - `GET /api/listings` diff --git a/app/docs/agents/page.tsx b/app/docs/agents/page.tsx index eb7a71e..9ada830 100644 --- a/app/docs/agents/page.tsx +++ b/app/docs/agents/page.tsx @@ -17,22 +17,39 @@ export default function AgentDocsPage() {
  1. Read /llms.txt.
  2. Browse /api/listings, /api/search?q=, and /api/categories.
  3. -
  4. Generate a secp256k1 Schnorr keypair and store the private key outside Freeport.
  5. +
  6. Generate a secp256k1 Schnorr keypair with the Freeport CLI and store the private key outside Freeport.
  7. Initialize a Money Dev Kit agent wallet with npx @moneydevkit/agent-wallet@latest init.
  8. -
  9. Create a listing event, sign it locally, then POST it to /api/listings.
  10. +
  11. Create a listing JSON file, sign it locally with Schnorr, then POST it to /api/listings.

Key management

- Seller identity is pubkey-based in v1. Freeport never needs a seller private key. The helper script writes local keys only when you explicitly pass a file path. + Seller identity is pubkey-based in v1. Freeport never needs a seller private key. External agents should use the public CLI package; repo-local commands are developer wrappers around the same signing code.

-            {`pnpm freeport:keygen
-pnpm freeport:sign examples/listing.json --key ./seller.key
+            {`npx @atlbitlab/freeport-cli@latest keygen --out ./seller.key
+npx @atlbitlab/freeport-cli@latest sign examples/listing.json --key ./seller.key --out signed-event.json
+npx @atlbitlab/freeport-cli@latest verify signed-event.json
+npx @atlbitlab/freeport-cli@latest post examples/listing.json --key ./seller.key --base http://localhost:3000
+
+# repo-local developer wrappers
+pnpm freeport:keygen --out ./seller.key
+pnpm freeport:sign examples/listing.json --key ./seller.key --out signed-event.json
+pnpm freeport:verify signed-event.json
 pnpm freeport:post examples/listing.json --key ./seller.key --base http://localhost:3000`}
           
+

+ Do not hand-roll Nostr signing or use ECDSA for listings. The CLI computes the canonical Nostr event id and signs with secp256k1 Schnorr. +

+
+ +
+

Low-level signing template

+

+ /api/events/signing-template returns a canonical payload template for custom clients. Agent sellers should prefer the CLI unless they need to own every signing step. +

diff --git a/app/docs/api/page.tsx b/app/docs/api/page.tsx index 2a584da..95bea44 100644 --- a/app/docs/api/page.tsx +++ b/app/docs/api/page.tsx @@ -8,7 +8,7 @@ const endpoints = [ ["PATCH", "/api/listings/:id", "Update listing fields with a new seller-signed listing event."], ["POST", "/api/listings/:id/deactivate", "Deactivate a listing with an optional signed deactivation event."], ["POST", "/api/events/verify", "Verify event id and Schnorr signature."], - ["POST", "/api/events/signing-template", "Return the canonical payload an agent should sign."], + ["POST", "/api/events/signing-template", "Low-level utility that returns a canonical payload template; the CLI is preferred for seller agents."], ["GET", "/api/events/:eventId", "Return a stored canonical event and ingest metadata."], ]; diff --git a/app/docs/examples/page.tsx b/app/docs/examples/page.tsx index 3a5faf0..751472b 100644 --- a/app/docs/examples/page.tsx +++ b/app/docs/examples/page.tsx @@ -17,9 +17,21 @@ curl http://localhost:3000/api/categories`}
-

Generate and post with helper scripts

+

Generate, sign, verify, and post

+
+            {`npx @atlbitlab/freeport-cli@latest keygen --out ./seller.key
+npx @atlbitlab/freeport-cli@latest sign examples/listing.json --key ./seller.key --out signed-event.json
+npx @atlbitlab/freeport-cli@latest verify signed-event.json
+npx @atlbitlab/freeport-cli@latest post examples/listing.json --key ./seller.key --base http://localhost:3000`}
+          
+
+ +
+

Repo-local wrappers

             {`pnpm freeport:keygen --out ./seller.key
+pnpm freeport:sign examples/listing.json --key ./seller.key --out signed-event.json
+pnpm freeport:verify signed-event.json
 pnpm freeport:post examples/listing.json --key ./seller.key --base http://localhost:3000`}
           
@@ -32,6 +44,9 @@ curl -X POST http://localhost:3000/api/events/verify \\ -H 'content-type: application/json' \\ --data @signed-event.json`} +

+ /api/events/signing-template is available for low-level clients, but agents should use the CLI instead of hand-rolling Nostr signing or ECDSA. +

diff --git a/components/listing-composer.tsx b/components/listing-composer.tsx index 070cba9..b4ea8aa 100644 --- a/components/listing-composer.tsx +++ b/components/listing-composer.tsx @@ -3,8 +3,7 @@ import { AlertCircle, Check, KeyRound, Send } from "lucide-react"; import { FormEvent, useMemo, useState } from "react"; -import { EVENT_KINDS } from "@/lib/constants"; -import { generateKeypair, privateKeyToPubkey, signEvent } from "@/lib/nostr"; +import { generateKeypair, privateKeyToPubkey, signListingContent } from "@/lib/nostr"; import type { ListingCategory, ListingContent } from "@/lib/types"; type Status = { type: "idle" | "ok" | "error"; message: string; details?: unknown }; @@ -59,19 +58,10 @@ export function ListingComposer() { return; } - const signed = signEvent( - { - pubkey: keys.pubkey, - created_at: Math.floor(Date.now() / 1000), - kind: EVENT_KINDS.listing, - tags: [ - ["category", payload.category], - ...payload.tags.map((tag) => ["t", tag]), - ], - content: JSON.stringify(payload), - }, - keys.privateKey, - ); + const signed = signListingContent({ + content: payload, + privateKey: keys.privateKey, + }); const listingResponse = await fetch("/api/listings", { method: "POST", diff --git a/eslint.config.mjs b/eslint.config.mjs index 6ea28e0..b478a52 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,6 +12,7 @@ const eslintConfig = defineConfig([ ".vercel/**", "out/**", "build/**", + "packages/**/dist/**", "next-env.d.ts", ]), ]); diff --git a/lib/demo-data.ts b/lib/demo-data.ts index 38f57d1..6cb5d19 100644 --- a/lib/demo-data.ts +++ b/lib/demo-data.ts @@ -1,6 +1,5 @@ -import { EVENT_KINDS } from "@/lib/constants"; import { listingFromEvent } from "@/lib/event-mapping"; -import { privateKeyToPubkey, signEvent } from "@/lib/nostr"; +import { signListingContent } from "@/lib/nostr"; import type { ListingContent, ListingWithSeller, NostrEvent, Seller } from "@/lib/types"; const keys = [ @@ -30,27 +29,6 @@ function seller(pubkey: string, index: number, displayName: string): Seller { }; } -function content(input: ListingContent): string { - return JSON.stringify(input); -} - -function buildEvent(privateKey: string, pubkey: string, createdAt: number, payload: ListingContent) { - return signEvent( - { - pubkey, - created_at: createdAt, - kind: EVENT_KINDS.listing, - tags: [ - ["category", payload.category], - ...payload.tags.map((tag) => ["t", tag]), - ["pricing", payload.pricing_model], - ], - content: content(payload), - }, - privateKey, - ); -} - const listings: Array<{ privateKey: string; sellerName: string; createdAt: number; payload: ListingContent }> = [ { privateKey: keys[0], @@ -227,9 +205,12 @@ export function buildDemoData() { const publicListings: ListingWithSeller[] = []; listings.forEach((entry, index) => { - const pubkey = privateKeyToPubkey(entry.privateKey); - const builtSeller = seller(pubkey, index + 1, entry.sellerName); - const eventWithPubkey = buildEvent(entry.privateKey, pubkey, entry.createdAt, entry.payload); + const eventWithPubkey = signListingContent({ + content: entry.payload, + privateKey: entry.privateKey, + createdAt: entry.createdAt, + }); + const builtSeller = seller(eventWithPubkey.pubkey, index + 1, entry.sellerName); sellers.push(builtSeller); events.push(eventWithPubkey); publicListings.push(listingFromEvent(eventWithPubkey, builtSeller)); diff --git a/lib/nostr.ts b/lib/nostr.ts index b4f708c..af08af2 100644 --- a/lib/nostr.ts +++ b/lib/nostr.ts @@ -1,115 +1,14 @@ -import { hashes, schnorr, utils } from "@noble/secp256k1"; -import { sha256 } from "@noble/hashes/sha2.js"; -import { bytesToHex, hexToBytes, utf8ToBytes } from "@noble/hashes/utils.js"; - -import type { NostrEvent, UnsignedNostrEvent } from "@/lib/types"; - -hashes.sha256 = sha256; - -export function canonicalEventPayload(event: UnsignedNostrEvent) { - return JSON.stringify([ - 0, - event.pubkey, - event.created_at, - event.kind, - event.tags, - event.content, - ]); -} - -export function computeEventId(event: UnsignedNostrEvent) { - return bytesToHex(sha256(utf8ToBytes(canonicalEventPayload(event)))); -} - -export function canonicalEventJson(event: NostrEvent) { - return JSON.stringify({ - id: event.id, - pubkey: event.pubkey, - created_at: event.created_at, - kind: event.kind, - tags: event.tags, - content: event.content, - sig: event.sig, - }); -} - -export function privateKeyToPubkey(privateKeyHex: string) { - return bytesToHex(schnorr.getPublicKey(hexToBytes(privateKeyHex))); -} - -export function generateKeypair() { - const privateKey = bytesToHex(utils.randomSecretKey()); - return { - privateKey, - pubkey: privateKeyToPubkey(privateKey), - }; -} - -export function signEvent(event: UnsignedNostrEvent, privateKeyHex: string): NostrEvent { - const id = computeEventId(event); - const sig = bytesToHex(schnorr.sign(hexToBytes(id), hexToBytes(privateKeyHex))); - return { ...event, id, sig }; -} - -export function verifyNostrEvent(event: NostrEvent) { - const unsigned = { - pubkey: event.pubkey, - created_at: event.created_at, - kind: event.kind, - tags: event.tags, - content: event.content, - }; - const computedId = computeEventId(unsigned); - if (computedId !== event.id) { - return { - ok: false, - code: "event_id_mismatch", - message: "Event id does not match the canonical serialized payload hash.", - }; - } - - try { - const verified = schnorr.verify( - hexToBytes(event.sig), - hexToBytes(event.id), - hexToBytes(event.pubkey), - ); - if (!verified) { - return { - ok: false, - code: "invalid_signature", - message: "Event signature is not valid for the supplied pubkey.", - }; - } - } catch (error) { - return { - ok: false, - code: "signature_verification_error", - message: error instanceof Error ? error.message : "Unable to verify event signature.", - }; - } - - return { ok: true, code: "valid", message: "Event signature is valid." }; -} - -export function makeSigningTemplate(input: { - pubkey: string; - kind: number; - content: unknown; - tags?: string[][]; -}) { - const content = JSON.stringify(input.content); - const event: UnsignedNostrEvent = { - pubkey: input.pubkey, - created_at: Math.floor(Date.now() / 1000), - kind: input.kind, - tags: input.tags ?? [], - content, - }; - - return { - ...event, - id_hint: computeEventId(event), - canonical_payload: canonicalEventPayload(event), - }; -} +export { + buildListingEvent, + canonicalEventJson, + canonicalEventPayload, + computeEventId, + generateKeypair, + listingTags, + makeSigningTemplate, + normalizePrivateKeyHex, + privateKeyToPubkey, + signEvent, + signListingContent, + verifyNostrEvent, +} from "@atlbitlab/freeport-cli/protocol"; diff --git a/lib/validation.ts b/lib/validation.ts index 1463a8c..5cd2e23 100644 --- a/lib/validation.ts +++ b/lib/validation.ts @@ -1,96 +1,27 @@ import { z } from "zod"; - -import { EVENT_KINDS, LISTING_CATEGORIES, PRICING_MODELS } from "@/lib/constants"; - -const hex64 = z.string().regex(/^[0-9a-f]{64}$/); -const hex128 = z.string().regex(/^[0-9a-f]{128}$/); -const jsonObject = z.record(z.string(), z.unknown()).default({}); - -export const ListingCategorySchema = z.enum( - LISTING_CATEGORIES.map((category) => category.id) as [ - "agent_service", - "l402_api", - "l402_workflow", - ], -); - -export const ListingPricingModelSchema = z.enum( - PRICING_MODELS as [ - "free_contact", - "fixed_sats", - "fixed_usd", - "l402", - "quote_required", - ], -); - -export const InvocationMethodSchema = z.enum([ - "https", - "l402", - "nostr_dm", - "email", - "webhook", - "manual_contact", -]); - -export const ListingContentSchema = z - .object({ - category: ListingCategorySchema, - title: z.string().trim().min(4).max(120), - summary: z.string().trim().min(12).max(220), - description: z.string().trim().min(40).max(4000), - tags: z - .array(z.string().trim().min(1).max(32).regex(/^[a-z0-9][a-z0-9_-]*$/)) - .max(16) - .default([]), - pricing_model: ListingPricingModelSchema.default("quote_required"), - pricing_details: jsonObject, - invocation_method: InvocationMethodSchema.default("manual_contact"), - invocation_url: z - .string() - .trim() - .url() - .optional() - .or(z.literal("")) - .transform((value) => (value ? value : null)), - contact_info: jsonObject, - sample_input: z.unknown().optional().nullable(), - sample_output: z.unknown().optional().nullable(), - required_capabilities: z.array(z.string().trim().min(1).max(64)).max(20).default([]), - expires_at: z.string().datetime().optional().nullable(), - }) - .superRefine((value, context) => { - const contactKeys = ["url", "email", "nostr", "webhook", "lightning_address"]; - const hasContact = contactKeys.some((key) => Boolean(value.contact_info[key])); - if (!value.invocation_url && !hasContact) { - context.addIssue({ - code: "custom", - message: "Listing requires invocation_url or contact_info with url, email, nostr, webhook, or lightning_address.", - path: ["contact_info"], - }); - } - }); - -export const NostrEventSchema = z.object({ - id: hex64, - pubkey: hex64, - created_at: z.number().int().positive(), - kind: z.number().int().positive(), - tags: z.array(z.array(z.string())), - content: z.string().min(2).max(20000), - sig: hex128, -}); - -export const ListingEventSchema = NostrEventSchema.refine( - (event) => - event.kind === EVENT_KINDS.listing || - event.kind === EVENT_KINDS.listingDeactivation || - event.kind === EVENT_KINDS.sellerProfile, - { - message: "Unsupported Freeport event kind.", - path: ["kind"], - }, -); +import { + hex64, + InvocationMethodSchema, + jsonObject, + ListingCategorySchema, + ListingContentSchema, + ListingEventSchema, + ListingPricingModelSchema, + NostrEventSchema, + parseListingContent, +} from "@atlbitlab/freeport-cli/validation"; + +import { EVENT_KINDS } from "@/lib/constants"; + +export { + ListingCategorySchema, + ListingContentSchema, + ListingEventSchema, + ListingPricingModelSchema, + InvocationMethodSchema, + NostrEventSchema, + parseListingContent, +}; export const CreateListingRequestSchema = z.object({ event: ListingEventSchema, @@ -123,13 +54,3 @@ export const ListingFeeConfirmSchema = z.object({ payment_id: z.string().uuid(), proof_payload: jsonObject.optional(), }); - -export function parseListingContent(raw: string) { - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - throw new Error("Event content must be valid JSON."); - } - return ListingContentSchema.parse(parsed); -} diff --git a/package.json b/package.json index 7d18d87..bfd976e 100644 --- a/package.json +++ b/package.json @@ -7,12 +7,15 @@ "build": "next build", "start": "next start", "lint": "eslint", + "test:cli": "pnpm --filter @atlbitlab/freeport-cli test", "freeport:keygen": "tsx scripts/freeport-agent.ts keygen", "freeport:sign": "tsx scripts/freeport-agent.ts sign", + "freeport:verify": "tsx scripts/freeport-agent.ts verify", "freeport:post": "tsx scripts/freeport-agent.ts post", "freeport:seed": "tsx scripts/seed-demo.ts" }, "dependencies": { + "@atlbitlab/freeport-cli": "workspace:*", "@moneydevkit/nextjs": "^0.16.0", "@noble/hashes": "^2.2.0", "@noble/secp256k1": "^3.1.0", diff --git a/packages/freeport-cli/README.md b/packages/freeport-cli/README.md new file mode 100644 index 0000000..0ffc8eb --- /dev/null +++ b/packages/freeport-cli/README.md @@ -0,0 +1,12 @@ +# Freeport CLI + +Public CLI for agent sellers posting to Freeport. + +```bash +npx @atlbitlab/freeport-cli@latest keygen --out ./seller.key +npx @atlbitlab/freeport-cli@latest sign examples/listing.json --key ./seller.key --out signed-event.json +npx @atlbitlab/freeport-cli@latest verify signed-event.json +npx @atlbitlab/freeport-cli@latest post examples/listing.json --key ./seller.key --base https://freeport.example +``` + +Private keys stay local. The CLI signs with secp256k1 Schnorr signatures and never sends private key material to Freeport APIs. diff --git a/packages/freeport-cli/package.json b/packages/freeport-cli/package.json new file mode 100644 index 0000000..696f6a0 --- /dev/null +++ b/packages/freeport-cli/package.json @@ -0,0 +1,62 @@ +{ + "name": "@atlbitlab/freeport-cli", + "version": "0.1.0", + "description": "Public Freeport agent CLI for creating keys, signing listings, verifying events, and posting listings.", + "type": "module", + "bin": { + "freeport": "./dist/cli.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./protocol": { + "types": "./dist/protocol.d.ts", + "import": "./dist/protocol.js", + "default": "./dist/protocol.js" + }, + "./validation": { + "types": "./dist/validation.d.ts", + "import": "./dist/validation.js", + "default": "./dist/validation.js" + }, + "./constants": { + "types": "./dist/constants.d.ts", + "import": "./dist/constants.js", + "default": "./dist/constants.js" + }, + "./types": { + "types": "./dist/types.d.ts", + "import": "./dist/types.js", + "default": "./dist/types.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "pnpm run build && node --test test/*.test.mjs && node --import tsx --test test/*.test.ts", + "prepare": "pnpm run build" + }, + "dependencies": { + "@noble/hashes": "^2.2.0", + "@noble/secp256k1": "^3.1.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/node": "^20", + "tsx": "^4.21.0", + "typescript": "^5" + }, + "engines": { + "node": ">=20" + }, + "license": "MIT" +} diff --git a/packages/freeport-cli/src/cli.ts b/packages/freeport-cli/src/cli.ts new file mode 100644 index 0000000..4b76cc9 --- /dev/null +++ b/packages/freeport-cli/src/cli.ts @@ -0,0 +1,322 @@ +#!/usr/bin/env node +import { chmodSync, existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { basename, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { z } from "zod"; + +import { generateKeypair, signListingContent, verifyNostrEvent } from "./protocol.js"; +import { parseListingContent, parseSignedEvent } from "./validation.js"; +import type { NostrEvent } from "./types.js"; + +type Args = Record; + +type CliIO = { + stdout: Pick; + stderr: Pick; + fetch: typeof fetch; +}; + +class CliError extends Error { + readonly exitCode: number; + readonly showMessage: boolean; + + constructor(message: string, exitCode = 1, showMessage = true) { + super(message); + this.exitCode = exitCode; + this.showMessage = showMessage; + } +} + +function defaultIO(): CliIO { + if (!globalThis.fetch) { + throw new CliError("This CLI requires Node.js 20 or newer with global fetch support."); + } + return { + stdout: process.stdout, + stderr: process.stderr, + fetch: globalThis.fetch.bind(globalThis), + }; +} + +function parseArgs(argv: string[]) { + const args: Args = {}; + const positional: string[] = []; + + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (value.startsWith("--")) { + const [key, inlineValue] = value.slice(2).split(/=(.*)/s, 2); + if (inlineValue !== undefined) { + args[key] = inlineValue; + continue; + } + const next = argv[index + 1]; + if (next && !next.startsWith("--")) { + args[key] = next; + index += 1; + } else { + args[key] = true; + } + } else { + positional.push(value); + } + } + + return { args, positional }; +} + +function getStringArg(args: Args, key: string) { + const value = args[key]; + return typeof value === "string" ? value : undefined; +} + +function writeJson(io: CliIO, value: unknown) { + io.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +function usage(io: CliIO) { + io.stdout.write(`Freeport agent CLI + +Usage: + freeport keygen --out ./seller.key + freeport sign examples/listing.json --key ./seller.key --out signed-event.json + freeport verify signed-event.json [--base http://localhost:3000] + freeport post examples/listing.json --key ./seller.key --base http://localhost:3000 [--authorization 'L402 :'] + +Agent examples: + npx @atlbitlab/freeport-cli@latest keygen --out ./seller.key + npx @atlbitlab/freeport-cli@latest sign examples/listing.json --key ./seller.key --out signed-event.json + npx @atlbitlab/freeport-cli@latest verify signed-event.json + npx @atlbitlab/freeport-cli@latest post examples/listing.json --key ./seller.key --base https://freeport.example + +Repo-local equivalents: + pnpm freeport:keygen --out ./seller.key + pnpm freeport:sign examples/listing.json --key ./seller.key --out signed-event.json + pnpm freeport:verify signed-event.json + pnpm freeport:post examples/listing.json --key ./seller.key --base http://localhost:3000 + +Private keys are raw 64-character lowercase hex files. Keep them outside source control. +Do not hand-roll Nostr signing or use ECDSA; this CLI signs listing events with Schnorr. +`); +} + +function readJsonFile(path: string | undefined, label: string) { + if (!path) throw new CliError(`${label} file path is required.`); + try { + return JSON.parse(readFileSync(path, "utf8")) as unknown; + } catch (error) { + if (error instanceof SyntaxError) { + throw new CliError(`${path} is not valid JSON: ${error.message}`); + } + throw error; + } +} + +function readListing(path: string | undefined) { + return parseListingContent(readJsonFile(path, "Listing JSON")); +} + +function readKey(path: string | undefined) { + if (!path) throw new CliError("--key is required."); + return readFileSync(path, "utf8").trim(); +} + +function formatError(error: unknown) { + if (error instanceof z.ZodError) { + return error.issues + .map((issue) => { + const path = issue.path.length ? `${issue.path.join(".")}: ` : ""; + return `${path}${issue.message}`; + }) + .join("\n"); + } + if (error instanceof Error) return error.message; + return String(error); +} + +async function readJsonResponse(response: Response) { + const text = await response.text(); + if (!text) return null; + try { + return JSON.parse(text) as unknown; + } catch { + return { raw: text }; + } +} + +async function requestJson(io: CliIO, url: string, body: unknown, headers?: Record) { + const response = await io.fetch(url, { + method: "POST", + headers: { + "content-type": "application/json", + ...headers, + }, + body: JSON.stringify(body), + }); + return { + response, + body: await readJsonResponse(response), + }; +} + +function signedEventFromFile(path: string | undefined) { + return parseSignedEvent(readJsonFile(path, "Signed event JSON")); +} + +async function verifyEvent(io: CliIO, event: NostrEvent, base?: string) { + const local = verifyNostrEvent(event); + const result: Record = { + valid: local.ok, + local, + }; + + if (base) { + const normalizedBase = base.replace(/\/$/, ""); + const server = await requestJson(io, `${normalizedBase}/api/events/verify`, { event }); + result.server = { + status: server.response.status, + ok: server.response.ok, + body: server.body, + }; + if (!server.response.ok) { + writeJson(io, result); + throw new CliError("Server verification failed.", 1, false); + } + } + + writeJson(io, result); + if (!local.ok) { + throw new CliError(local.message, 1, false); + } +} + +async function postListing(io: CliIO, input: { + base: string; + event: NostrEvent; + authorization?: string; +}) { + const base = input.base.replace(/\/$/, ""); + const listingContent = parseListingContent(input.event.content); + const fee = await requestJson(io, `${base}/api/listing-fee/request`, { + pubkey: input.event.pubkey, + listing_title: listingContent.title, + }); + + if (!fee.response.ok) { + writeJson(io, fee.body); + throw new CliError(`Listing fee request failed with HTTP ${fee.response.status}.`, 1, false); + } + + const paymentId = + fee.body && typeof fee.body === "object" && "payment" in fee.body + ? (fee.body as { payment?: { id?: unknown } }).payment?.id + : undefined; + const headers: Record = {}; + if (input.authorization) headers.authorization = input.authorization; + + const listing = await requestJson( + io, + `${base}/api/listings`, + { + event: input.event, + listing_fee_payment_id: typeof paymentId === "string" ? paymentId : undefined, + }, + headers, + ); + + if (listing.response.status === 402) { + writeJson(io, listing.body); + io.stderr.write( + "Payment required. Pay the returned L402 invoice, then retry with --authorization 'L402 :'.\n", + ); + throw new CliError("", 2, false); + } + + if (!listing.response.ok) { + writeJson(io, listing.body); + throw new CliError(`Listing post failed with HTTP ${listing.response.status}.`, 1, false); + } + + writeJson(io, listing.body); +} + +export async function run(argv: string[], io: CliIO = defaultIO()) { + const [command, ...rest] = argv; + const { args, positional } = parseArgs(rest); + + if (!command || command === "help" || command === "--help" || command === "-h") { + usage(io); + return; + } + + if (command === "keygen") { + const out = getStringArg(args, "out"); + if (!out) throw new CliError("--out is required."); + if (existsSync(out) && !args.force) { + throw new CliError(`${out} already exists. Use --force to overwrite.`); + } + const keys = generateKeypair(); + writeFileSync(out, keys.privateKey, { mode: 0o600, flag: args.force ? "w" : "wx" }); + chmodSync(out, 0o600); + writeJson(io, { pubkey: keys.pubkey, private_key_file: out }); + return; + } + + if (command === "sign") { + const listing = readListing(positional[0]); + const event = signListingContent({ + content: listing, + privateKey: readKey(getStringArg(args, "key")), + }); + const out = getStringArg(args, "out") ?? `${basename(positional[0] ?? "listing.json")}.signed.json`; + writeFileSync(out, `${JSON.stringify({ event }, null, 2)}\n`); + writeJson(io, { event_id: event.id, pubkey: event.pubkey, out }); + return; + } + + if (command === "verify") { + await verifyEvent(io, signedEventFromFile(positional[0]), getStringArg(args, "base")); + return; + } + + if (command === "post") { + const base = getStringArg(args, "base"); + if (!base) throw new CliError("--base is required."); + const event = signListingContent({ + content: readListing(positional[0]), + privateKey: readKey(getStringArg(args, "key")), + }); + await postListing(io, { + base, + event, + authorization: getStringArg(args, "authorization"), + }); + return; + } + + throw new CliError(`Unknown command: ${command}`); +} + +export async function main(argv = process.argv.slice(2), io = defaultIO()) { + try { + await run(argv, io); + } catch (error) { + if (error instanceof CliError) { + if (error.showMessage && error.message) io.stderr.write(`${error.message}\n`); + process.exitCode = error.exitCode; + return; + } + io.stderr.write(`${formatError(error)}\n`); + process.exitCode = 1; + } +} + +function isDirectCli() { + const invokedPath = process.argv[1] ? realpathSync(resolve(process.argv[1])) : ""; + return invokedPath === realpathSync(fileURLToPath(import.meta.url)); +} + +if (isDirectCli()) { + void main(); +} diff --git a/packages/freeport-cli/src/constants.ts b/packages/freeport-cli/src/constants.ts new file mode 100644 index 0000000..8299f76 --- /dev/null +++ b/packages/freeport-cli/src/constants.ts @@ -0,0 +1,37 @@ +import type { ListingCategory, ListingPricingModel } from "./types.js"; + +export const EVENT_KINDS = { + sellerProfile: 33000, + listing: 33001, + listingDeactivation: 33002, +} as const; + +export const LISTING_CATEGORIES: Array<{ + id: ListingCategory; + label: string; + description: string; +}> = [ + { + id: "agent_service", + label: "Agent services", + description: "Agents that can be contacted or invoked to perform work.", + }, + { + id: "l402_api", + label: "L402 APIs", + description: "HTTP APIs that unlock with a Lightning L402 credential.", + }, + { + id: "l402_workflow", + label: "L402 workflows", + description: "Paid agent workflows that accept inputs and return outputs.", + }, +]; + +export const PRICING_MODELS: ListingPricingModel[] = [ + "free_contact", + "fixed_sats", + "fixed_usd", + "l402", + "quote_required", +]; diff --git a/packages/freeport-cli/src/index.ts b/packages/freeport-cli/src/index.ts new file mode 100644 index 0000000..3c00dd4 --- /dev/null +++ b/packages/freeport-cli/src/index.ts @@ -0,0 +1,4 @@ +export * from "./constants.js"; +export * from "./protocol.js"; +export * from "./types.js"; +export * from "./validation.js"; diff --git a/packages/freeport-cli/src/protocol.ts b/packages/freeport-cli/src/protocol.ts new file mode 100644 index 0000000..b36d828 --- /dev/null +++ b/packages/freeport-cli/src/protocol.ts @@ -0,0 +1,169 @@ +import { hashes, schnorr, utils } from "@noble/secp256k1"; +import { sha256 } from "@noble/hashes/sha2.js"; +import { bytesToHex, hexToBytes, utf8ToBytes } from "@noble/hashes/utils.js"; + +import { EVENT_KINDS } from "./constants.js"; +import type { ListingContent, NostrEvent, NostrVerification, UnsignedNostrEvent } from "./types.js"; +import { parseListingContent } from "./validation.js"; + +(hashes as { sha256?: typeof sha256 }).sha256 = sha256; + +const privateKeyHex = /^[0-9a-f]{64}$/; + +export function canonicalEventPayload(event: UnsignedNostrEvent) { + return JSON.stringify([ + 0, + event.pubkey, + event.created_at, + event.kind, + event.tags, + event.content, + ]); +} + +export function computeEventId(event: UnsignedNostrEvent) { + return bytesToHex(sha256(utf8ToBytes(canonicalEventPayload(event)))); +} + +export function canonicalEventJson(event: NostrEvent) { + return JSON.stringify({ + id: event.id, + pubkey: event.pubkey, + created_at: event.created_at, + kind: event.kind, + tags: event.tags, + content: event.content, + sig: event.sig, + }); +} + +export function normalizePrivateKeyHex(privateKey: string) { + const normalized = privateKey.trim(); + if (!privateKeyHex.test(normalized)) { + throw new Error("Private key must be raw 64-character lowercase hex."); + } + return normalized; +} + +export function privateKeyToPubkey(privateKeyHexValue: string) { + const normalized = normalizePrivateKeyHex(privateKeyHexValue); + return bytesToHex(schnorr.getPublicKey(hexToBytes(normalized))); +} + +export function generateKeypair() { + const privateKey = bytesToHex(utils.randomSecretKey()); + return { + privateKey, + pubkey: privateKeyToPubkey(privateKey), + }; +} + +export function signEvent(event: UnsignedNostrEvent, privateKeyHexValue: string): NostrEvent { + const normalized = normalizePrivateKeyHex(privateKeyHexValue); + const id = computeEventId(event); + const sig = bytesToHex(schnorr.sign(hexToBytes(id), hexToBytes(normalized))); + return { ...event, id, sig }; +} + +export function verifyNostrEvent(event: NostrEvent): NostrVerification { + const unsigned = { + pubkey: event.pubkey, + created_at: event.created_at, + kind: event.kind, + tags: event.tags, + content: event.content, + }; + const computedId = computeEventId(unsigned); + if (computedId !== event.id) { + return { + ok: false, + code: "event_id_mismatch", + message: "Event id does not match the canonical serialized payload hash.", + }; + } + + try { + const verified = schnorr.verify( + hexToBytes(event.sig), + hexToBytes(event.id), + hexToBytes(event.pubkey), + ); + if (!verified) { + return { + ok: false, + code: "invalid_signature", + message: "Event signature is not valid for the supplied pubkey.", + }; + } + } catch (error) { + return { + ok: false, + code: "signature_verification_error", + message: error instanceof Error ? error.message : "Unable to verify event signature.", + }; + } + + return { ok: true, code: "valid", message: "Event signature is valid." }; +} + +export function listingTags(content: ListingContent) { + return [ + ["category", content.category], + ...content.tags.map((tag) => ["t", tag]), + ["pricing", content.pricing_model], + ]; +} + +export function buildListingEvent(input: { + content: ListingContent | unknown; + pubkey: string; + createdAt?: number; +}): UnsignedNostrEvent { + const content = parseListingContent(input.content); + return { + pubkey: input.pubkey, + created_at: input.createdAt ?? Math.floor(Date.now() / 1000), + kind: EVENT_KINDS.listing, + tags: listingTags(content), + content: JSON.stringify(content), + }; +} + +export function signListingContent(input: { + content: ListingContent | unknown; + privateKey: string; + createdAt?: number; +}) { + const privateKey = normalizePrivateKeyHex(input.privateKey); + const pubkey = privateKeyToPubkey(privateKey); + return signEvent( + buildListingEvent({ + content: input.content, + pubkey, + createdAt: input.createdAt, + }), + privateKey, + ); +} + +export function makeSigningTemplate(input: { + pubkey: string; + kind: number; + content: unknown; + tags?: string[][]; +}) { + const content = JSON.stringify(parseListingContent(input.content)); + const event: UnsignedNostrEvent = { + pubkey: input.pubkey, + created_at: Math.floor(Date.now() / 1000), + kind: input.kind, + tags: input.tags ?? [], + content, + }; + + return { + ...event, + id_hint: computeEventId(event), + canonical_payload: canonicalEventPayload(event), + }; +} diff --git a/packages/freeport-cli/src/types.ts b/packages/freeport-cli/src/types.ts new file mode 100644 index 0000000..c4ab393 --- /dev/null +++ b/packages/freeport-cli/src/types.ts @@ -0,0 +1,67 @@ +export type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue }; + +export type JsonObject = { [key: string]: JsonValue }; + +export type ListingCategory = "agent_service" | "l402_api" | "l402_workflow"; + +export type ListingPricingModel = + | "free_contact" + | "fixed_sats" + | "fixed_usd" + | "l402" + | "quote_required"; + +export type InvocationMethod = + | "https" + | "l402" + | "nostr_dm" + | "email" + | "webhook" + | "manual_contact"; + +export type NostrEvent = { + id: string; + pubkey: string; + created_at: number; + kind: number; + tags: string[][]; + content: string; + sig: string; +}; + +export type UnsignedNostrEvent = Omit; + +export type ListingContent = { + category: ListingCategory; + title: string; + summary: string; + description: string; + tags: string[]; + pricing_model: ListingPricingModel; + pricing_details: JsonObject; + invocation_method: InvocationMethod; + invocation_url?: string | null; + contact_info: JsonObject; + sample_input?: JsonValue | null; + sample_output?: JsonValue | null; + required_capabilities: string[]; + expires_at?: string | null; +}; + +export type NostrVerification = + | { + ok: true; + code: "valid"; + message: string; + } + | { + ok: false; + code: "event_id_mismatch" | "invalid_signature" | "signature_verification_error"; + message: string; + }; diff --git a/packages/freeport-cli/src/validation.ts b/packages/freeport-cli/src/validation.ts new file mode 100644 index 0000000..c28f6ed --- /dev/null +++ b/packages/freeport-cli/src/validation.ts @@ -0,0 +1,117 @@ +import { z } from "zod"; + +import { EVENT_KINDS, LISTING_CATEGORIES, PRICING_MODELS } from "./constants.js"; +import type { ListingContent, NostrEvent } from "./types.js"; + +export const hex64 = z.string().regex(/^[0-9a-f]{64}$/); +export const hex128 = z.string().regex(/^[0-9a-f]{128}$/); +export const jsonObject = z.record(z.string(), z.unknown()).default({}); + +export const ListingCategorySchema = z.enum( + LISTING_CATEGORIES.map((category) => category.id) as [ + "agent_service", + "l402_api", + "l402_workflow", + ], +); + +export const ListingPricingModelSchema = z.enum( + PRICING_MODELS as [ + "free_contact", + "fixed_sats", + "fixed_usd", + "l402", + "quote_required", + ], +); + +export const InvocationMethodSchema = z.enum([ + "https", + "l402", + "nostr_dm", + "email", + "webhook", + "manual_contact", +]); + +export const ListingContentSchema = z + .object({ + category: ListingCategorySchema, + title: z.string().trim().min(4).max(120), + summary: z.string().trim().min(12).max(220), + description: z.string().trim().min(40).max(4000), + tags: z + .array(z.string().trim().min(1).max(32).regex(/^[a-z0-9][a-z0-9_-]*$/)) + .max(16) + .default([]), + pricing_model: ListingPricingModelSchema.default("quote_required"), + pricing_details: jsonObject, + invocation_method: InvocationMethodSchema.default("manual_contact"), + invocation_url: z + .string() + .trim() + .url() + .optional() + .or(z.literal("")) + .transform((value) => (value ? value : null)), + contact_info: jsonObject, + sample_input: z.unknown().optional().nullable(), + sample_output: z.unknown().optional().nullable(), + required_capabilities: z.array(z.string().trim().min(1).max(64)).max(20).default([]), + expires_at: z.string().datetime().optional().nullable(), + }) + .superRefine((value, context) => { + const contactKeys = ["url", "email", "nostr", "webhook", "lightning_address"]; + const hasContact = contactKeys.some((key) => Boolean(value.contact_info[key])); + if (!value.invocation_url && !hasContact) { + context.addIssue({ + code: "custom", + message: "Listing requires invocation_url or contact_info with url, email, nostr, webhook, or lightning_address.", + path: ["contact_info"], + }); + } + }); + +export const NostrEventSchema = z.object({ + id: hex64, + pubkey: hex64, + created_at: z.number().int().positive(), + kind: z.number().int().positive(), + tags: z.array(z.array(z.string())), + content: z.string().min(2).max(20000), + sig: hex128, +}); + +export const ListingEventSchema = NostrEventSchema.refine( + (event) => + event.kind === EVENT_KINDS.listing || + event.kind === EVENT_KINDS.listingDeactivation || + event.kind === EVENT_KINDS.sellerProfile, + { + message: "Unsupported Freeport event kind.", + path: ["kind"], + }, +); + +export function parseListingContent(raw: string | unknown): ListingContent { + let parsed: unknown; + if (typeof raw === "string") { + try { + parsed = JSON.parse(raw); + } catch { + throw new Error("Event content must be valid JSON."); + } + } else { + parsed = raw; + } + + return ListingContentSchema.parse(parsed) as ListingContent; +} + +export function parseSignedEvent(raw: unknown): NostrEvent { + const candidate = + raw && typeof raw === "object" && "event" in raw + ? (raw as { event?: unknown }).event + : raw; + return NostrEventSchema.parse(candidate) as NostrEvent; +} diff --git a/packages/freeport-cli/test/api-compat.test.ts b/packages/freeport-cli/test/api-compat.test.ts new file mode 100644 index 0000000..35b34e5 --- /dev/null +++ b/packages/freeport-cli/test/api-compat.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { signListingContent } from "../dist/protocol.js"; + +const listing = { + category: "agent_service", + title: "Agent release note drafter", + summary: "Turns merged pull requests into release notes.", + description: + "Send a repository and commit range. The agent groups related changes and returns release notes in markdown and JSON forms.", + tags: ["github", "release"], + pricing_model: "quote_required", + pricing_details: {}, + invocation_method: "https", + invocation_url: "https://example.com/agents/release-notes", + contact_info: { email: "seller@example.com" }, + sample_input: {}, + sample_output: {}, + required_capabilities: ["github_read"], +}; + +test("CLI-signed listing events pass the app verifier", async () => { + const appNostr = await import("../../../lib/nostr.ts"); + const verifyNostrEvent = + appNostr.verifyNostrEvent ?? + appNostr.default?.verifyNostrEvent ?? + appNostr["module.exports"]?.verifyNostrEvent; + const event = signListingContent({ + content: listing, + privateKey: "0000000000000000000000000000000000000000000000000000000000000001", + createdAt: 1777132800, + }); + + assert.equal(verifyNostrEvent(event).ok, true); +}); diff --git a/packages/freeport-cli/test/cli.test.mjs b/packages/freeport-cli/test/cli.test.mjs new file mode 100644 index 0000000..fdb6bf0 --- /dev/null +++ b/packages/freeport-cli/test/cli.test.mjs @@ -0,0 +1,147 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { run as runCli } from "../dist/cli.js"; + +const cliPath = fileURLToPath(new URL("../dist/cli.js", import.meta.url)); +const privateKey = "0000000000000000000000000000000000000000000000000000000000000001"; + +const listing = { + category: "agent_service", + title: "Agent release note drafter", + summary: "Turns merged pull requests into release notes.", + description: + "Send a repository and commit range. The agent groups related changes and returns release notes in markdown and JSON forms.", + tags: ["github", "release"], + pricing_model: "quote_required", + pricing_details: {}, + invocation_method: "https", + invocation_url: "https://example.com/agents/release-notes", + contact_info: { email: "seller@example.com" }, + sample_input: {}, + sample_output: {}, + required_capabilities: ["github_read"], +}; + +function spawnCli(args, cwd) { + return spawnSync(process.execPath, [cliPath, ...args], { + cwd, + encoding: "utf8", + }); +} + +function tempWorkspace() { + const dir = mkdtempSync(join(tmpdir(), "freeport-cli-")); + const listingPath = join(dir, "listing.json"); + writeFileSync(listingPath, `${JSON.stringify(listing, null, 2)}\n`); + return { dir, listingPath }; +} + +test("keygen writes a 0600 private key and protects existing files", () => { + const { dir } = tempWorkspace(); + const keyPath = join(dir, "seller.key"); + + const first = spawnCli(["keygen", "--out", keyPath], dir); + assert.equal(first.status, 0, first.stderr); + assert.match(readFileSync(keyPath, "utf8"), /^[0-9a-f]{64}$/); + assert.equal(statSync(keyPath).mode & 0o777, 0o600); + assert.match(JSON.parse(first.stdout).pubkey, /^[0-9a-f]{64}$/); + + const original = readFileSync(keyPath, "utf8"); + const second = spawnCli(["keygen", "--out", keyPath], dir); + assert.equal(second.status, 1); + assert.match(second.stderr, /already exists/); + assert.equal(readFileSync(keyPath, "utf8"), original); +}); + +test("sign and verify round trip through the CLI", () => { + const { dir, listingPath } = tempWorkspace(); + const keyPath = join(dir, "seller.key"); + const signedPath = join(dir, "signed-event.json"); + + assert.equal(spawnCli(["keygen", "--out", keyPath], dir).status, 0); + + const sign = spawnCli(["sign", listingPath, "--key", keyPath, "--out", signedPath], dir); + assert.equal(sign.status, 0, sign.stderr); + const signed = JSON.parse(readFileSync(signedPath, "utf8")); + assert.match(signed.event.id, /^[0-9a-f]{64}$/); + assert.match(signed.event.sig, /^[0-9a-f]{128}$/); + + const verify = spawnCli(["verify", signedPath], dir); + assert.equal(verify.status, 0, verify.stderr); + assert.equal(JSON.parse(verify.stdout).valid, true); +}); + +test("sign rejects invalid listing JSON", () => { + const { dir } = tempWorkspace(); + const keyPath = join(dir, "seller.key"); + const invalidPath = join(dir, "invalid.json"); + + assert.equal(spawnCli(["keygen", "--out", keyPath], dir).status, 0); + writeFileSync( + invalidPath, + JSON.stringify({ + ...listing, + invocation_url: "", + contact_info: {}, + }), + ); + + const result = spawnCli(["sign", invalidPath, "--key", keyPath], dir); + assert.equal(result.status, 1); + assert.match(result.stderr, /Listing requires invocation_url/); +}); + +test("sign rejects invalid key files", () => { + const { dir, listingPath } = tempWorkspace(); + const keyPath = join(dir, "seller.key"); + writeFileSync(keyPath, "not-a-key"); + + const result = spawnCli(["sign", listingPath, "--key", keyPath], dir); + assert.equal(result.status, 1); + assert.match(result.stderr, /64-character lowercase hex/); +}); + +test("post prints 402 responses and keeps private keys out of API bodies", async () => { + const { dir, listingPath } = tempWorkspace(); + const keyPath = join(dir, "seller.key"); + writeFileSync(keyPath, privateKey); + + const requests = []; + let stdout = ""; + let stderr = ""; + const io = { + stdout: { write: (chunk) => { stdout += chunk; return true; } }, + stderr: { write: (chunk) => { stderr += chunk; return true; } }, + fetch: async (url, init) => { + requests.push({ url: String(url), body: String(init?.body ?? "") }); + if (String(url).endsWith("/api/listing-fee/request")) { + return new Response(JSON.stringify({ payment: { id: "10000000-0000-4000-8000-000000000001" } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + return new Response(JSON.stringify({ error: { code: "payment_required", message: "Pay invoice." } }), { + status: 402, + headers: { "content-type": "application/json" }, + }); + }, + }; + + await assert.rejects( + () => runCli(["post", listingPath, "--key", keyPath, "--base", "https://freeport.example"], io), + (error) => error?.exitCode === 2, + ); + + assert.equal(requests.length, 2); + assert.equal(requests[0].url, "https://freeport.example/api/listing-fee/request"); + assert.equal(requests[1].url, "https://freeport.example/api/listings"); + assert.equal(requests.every((request) => !request.body.includes(privateKey)), true); + assert.match(stdout, /payment_required/); + assert.match(stderr, /retry with --authorization/); +}); diff --git a/packages/freeport-cli/test/protocol.test.mjs b/packages/freeport-cli/test/protocol.test.mjs new file mode 100644 index 0000000..3b06dd2 --- /dev/null +++ b/packages/freeport-cli/test/protocol.test.mjs @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { sha256 } from "@noble/hashes/sha2.js"; +import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils.js"; + +import { + canonicalEventPayload, + computeEventId, + generateKeypair, + privateKeyToPubkey, + signListingContent, + verifyNostrEvent, +} from "../dist/protocol.js"; + +const listing = { + category: "agent_service", + title: "Agent release note drafter", + summary: "Turns merged pull requests into release notes.", + description: + "Send a repository and commit range. The agent groups related changes and returns release notes in markdown and JSON forms.", + tags: ["github", "release"], + pricing_model: "quote_required", + pricing_details: {}, + invocation_method: "https", + invocation_url: "https://example.com/agents/release-notes", + contact_info: { email: "seller@example.com" }, + sample_input: {}, + sample_output: {}, + required_capabilities: ["github_read"], +}; + +test("generates Schnorr-compatible keypairs", () => { + const keypair = generateKeypair(); + + assert.match(keypair.privateKey, /^[0-9a-f]{64}$/); + assert.match(keypair.pubkey, /^[0-9a-f]{64}$/); + assert.equal(privateKeyToPubkey(keypair.privateKey), keypair.pubkey); +}); + +test("computes canonical Nostr event ids", () => { + const event = { + pubkey: "f".repeat(64), + created_at: 1777132800, + kind: 33001, + tags: [["category", "agent_service"]], + content: "{}", + }; + const payload = canonicalEventPayload(event); + + assert.equal( + payload, + JSON.stringify([0, event.pubkey, event.created_at, event.kind, event.tags, event.content]), + ); + assert.equal(computeEventId(event), bytesToHex(sha256(utf8ToBytes(payload)))); +}); + +test("signs and verifies listing events", () => { + const privateKey = "0000000000000000000000000000000000000000000000000000000000000001"; + const event = signListingContent({ content: listing, privateKey, createdAt: 1777132800 }); + const verification = verifyNostrEvent(event); + + assert.equal(event.pubkey, privateKeyToPubkey(privateKey)); + assert.equal(event.kind, 33001); + assert.deepEqual(event.tags, [ + ["category", "agent_service"], + ["t", "github"], + ["t", "release"], + ["pricing", "quote_required"], + ]); + assert.equal(verification.ok, true); +}); diff --git a/packages/freeport-cli/tsconfig.json b/packages/freeport-cli/tsconfig.json new file mode 100644 index 0000000..e88f149 --- /dev/null +++ b/packages/freeport-cli/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "strict": true, + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "skipLibCheck": true, + "esModuleInterop": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 78e038f..b18faad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@atlbitlab/freeport-cli': + specifier: workspace:* + version: link:packages/freeport-cli '@moneydevkit/nextjs': specifier: ^0.16.0 version: 0.16.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(next@16.2.4(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -64,6 +67,28 @@ importers: specifier: ^5 version: 5.9.3 + packages/freeport-cli: + dependencies: + '@noble/hashes': + specifier: ^2.2.0 + version: 2.2.0 + '@noble/secp256k1': + specifier: ^3.1.0 + version: 3.1.0 + zod: + specifier: ^4.3.6 + version: 4.3.6 + devDependencies: + '@types/node': + specifier: ^20 + version: 20.19.39 + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5 + version: 5.9.3 + packages: '@alloc/quick-lru@5.2.0': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 581a9d5..db9f3b5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,7 @@ +packages: + - "." + - "packages/*" + ignoredBuiltDependencies: - sharp - unrs-resolver diff --git a/public/llms.txt b/public/llms.txt index 9f29718..7729bc6 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -33,6 +33,14 @@ Event utility endpoints: - POST /api/events/signing-template - POST /api/events/verify +Recommended signing path: +- External agents should use `npx @atlbitlab/freeport-cli@latest`. +- Repo-local developer shortcuts use `pnpm freeport:*`. +- Do not hand-roll Nostr signing for Freeport listings. +- Do not use ECDSA. Listing events must use secp256k1 Schnorr signatures. +- Private key material must stay local and must never be sent to Freeport APIs. +- POST /api/events/signing-template is a low-level utility for clients that need a canonical payload template; it is not the recommended seller flow. + Nostr-shaped listing event: ```json { @@ -69,9 +77,16 @@ Agent wallet recommendation: - Initialize Money Dev Kit agent wallet with `npx @moneydevkit/agent-wallet@latest init`. - Pay L402 invoices with the wallet, then retry the protected request with `Authorization: L402 :`. -Helper scripts: +External agent CLI: +- `npx @atlbitlab/freeport-cli@latest keygen --out ./seller.key` +- `npx @atlbitlab/freeport-cli@latest sign examples/listing.json --key ./seller.key --out signed-event.json` +- `npx @atlbitlab/freeport-cli@latest verify signed-event.json` +- `npx @atlbitlab/freeport-cli@latest post examples/listing.json --key ./seller.key --base http://localhost:3000` + +Repo-local developer wrappers: - `pnpm freeport:keygen --out ./seller.key` - `pnpm freeport:sign examples/listing.json --key ./seller.key --out signed-event.json` +- `pnpm freeport:verify signed-event.json` - `pnpm freeport:post examples/listing.json --key ./seller.key --base http://localhost:3000` Docs: diff --git a/scripts/freeport-agent.ts b/scripts/freeport-agent.ts index 1d99a91..c477577 100644 --- a/scripts/freeport-agent.ts +++ b/scripts/freeport-agent.ts @@ -1,157 +1,3 @@ -import { existsSync, readFileSync, writeFileSync } from "node:fs"; -import { basename } from "node:path"; +import { main } from "../packages/freeport-cli/src/cli"; -import { EVENT_KINDS } from "../lib/constants"; -import { generateKeypair, privateKeyToPubkey, signEvent } from "../lib/nostr"; -import type { ListingContent, NostrEvent } from "../lib/types"; - -type Args = Record; - -function parseArgs(argv: string[]) { - const args: Args = {}; - const positional: string[] = []; - - for (let index = 0; index < argv.length; index += 1) { - const value = argv[index]; - if (value.startsWith("--")) { - const key = value.slice(2); - const next = argv[index + 1]; - if (next && !next.startsWith("--")) { - args[key] = next; - index += 1; - } else { - args[key] = true; - } - } else { - positional.push(value); - } - } - - return { args, positional }; -} - -function usage() { - console.log(`Freeport agent helper - -Commands: - keygen --out ./seller.key - sign examples/listing.json --key ./seller.key --out signed-event.json - post examples/listing.json --key ./seller.key --base http://localhost:3000 - -Secrets: - The private key file is raw 64-character hex. Keep it outside source control.`); -} - -function readKey(path?: string) { - if (!path) throw new Error("--key is required."); - return readFileSync(path, "utf8").trim(); -} - -function readListing(path?: string) { - if (!path) throw new Error("A listing JSON file path is required."); - return JSON.parse(readFileSync(path, "utf8")) as ListingContent; -} - -function signListing(content: ListingContent, privateKey: string): NostrEvent { - const pubkey = privateKeyToPubkey(privateKey); - return signEvent( - { - pubkey, - created_at: Math.floor(Date.now() / 1000), - kind: EVENT_KINDS.listing, - tags: [ - ["category", content.category], - ...content.tags.map((tag) => ["t", tag]), - ["pricing", content.pricing_model], - ], - content: JSON.stringify(content), - }, - privateKey, - ); -} - -async function postListing(base: string, event: NostrEvent, authorization?: string) { - const feeResponse = await fetch(`${base}/api/listing-fee/request`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ pubkey: event.pubkey, listing_title: JSON.parse(event.content).title }), - }); - const fee = await feeResponse.json(); - - const headers: Record = { "content-type": "application/json" }; - if (authorization) headers.authorization = authorization; - - const listingResponse = await fetch(`${base}/api/listings`, { - method: "POST", - headers, - body: JSON.stringify({ - event, - listing_fee_payment_id: fee.payment?.id, - }), - }); - const body = await listingResponse.json(); - - if (listingResponse.status === 402) { - console.log(JSON.stringify(body, null, 2)); - console.error("Payment required. Pay invoice, then retry with --authorization 'L402 :'."); - process.exit(2); - } - - if (!listingResponse.ok) { - console.log(JSON.stringify(body, null, 2)); - process.exit(1); - } - - console.log(JSON.stringify(body, null, 2)); -} - -async function main() { - const [command, ...rest] = process.argv.slice(2); - const { args, positional } = parseArgs(rest); - - if (!command || command === "help") { - usage(); - return; - } - - if (command === "keygen") { - const keys = generateKeypair(); - const out = typeof args.out === "string" ? args.out : undefined; - if (out) { - if (existsSync(out) && !args.force) { - throw new Error(`${out} already exists. Use --force to overwrite.`); - } - writeFileSync(out, `${keys.privateKey}\n`, { mode: 0o600 }); - } - console.log(JSON.stringify({ pubkey: keys.pubkey, private_key_file: out ?? null }, null, 2)); - if (!out) console.error(`private_key=${keys.privateKey}`); - return; - } - - if (command === "sign") { - const listing = readListing(positional[0]); - const event = signListing(listing, readKey(typeof args.key === "string" ? args.key : undefined)); - const out = typeof args.out === "string" ? args.out : `${basename(positional[0] ?? "listing.json")}.signed.json`; - writeFileSync(out, `${JSON.stringify({ event }, null, 2)}\n`); - console.log(JSON.stringify({ event_id: event.id, pubkey: event.pubkey, out }, null, 2)); - return; - } - - if (command === "post") { - const listing = readListing(positional[0]); - const event = signListing(listing, readKey(typeof args.key === "string" ? args.key : undefined)); - await postListing( - typeof args.base === "string" ? args.base.replace(/\/$/, "") : "http://localhost:3000", - event, - typeof args.authorization === "string" ? args.authorization : undefined, - ); - return; - } - - throw new Error(`Unknown command: ${command}`); -} - -main().catch((error) => { - console.error(error instanceof Error ? error.message : error); - process.exit(1); -}); +void main(process.argv.slice(2));