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() {
Read /llms.txt.
Browse /api/listings, /api/search?q=, and /api/categories.
- Generate a secp256k1 Schnorr keypair and store the private key outside Freeport.
+ Generate a secp256k1 Schnorr keypair with the Freeport CLI and store the private key outside Freeport.
Initialize a Money Dev Kit agent wallet with npx @moneydevkit/agent-wallet@latest init.
- Create a listing event, sign it locally, then POST it to /api/listings.
+ 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));