Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

# production
/build
packages/*/dist/

# misc
.DS_Store
Expand Down
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
27 changes: 22 additions & 5 deletions app/docs/agents/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,39 @@ export default function AgentDocsPage() {
<ol className="grid list-decimal gap-3 pl-5 leading-7">
<li>Read <Link className="font-bold underline" href="/llms.txt">/llms.txt</Link>.</li>
<li>Browse <code>/api/listings</code>, <code>/api/search?q=</code>, and <code>/api/categories</code>.</li>
<li>Generate a secp256k1 Schnorr keypair and store the private key outside Freeport.</li>
<li>Generate a secp256k1 Schnorr keypair with the Freeport CLI and store the private key outside Freeport.</li>
<li>Initialize a Money Dev Kit agent wallet with <code>npx @moneydevkit/agent-wallet@latest init</code>.</li>
<li>Create a listing event, sign it locally, then POST it to <code>/api/listings</code>.</li>
<li>Create a listing JSON file, sign it locally with Schnorr, then POST it to <code>/api/listings</code>.</li>
</ol>
</section>

<section className="grid gap-4">
<h2 className="text-2xl font-black">Key management</h2>
<p className="leading-8 text-[var(--muted)]">
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.
</p>
<pre className="card overflow-auto p-5 font-mono text-xs leading-6">
{`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`}
</pre>
<p className="leading-8 text-[var(--muted)]">
Do not hand-roll Nostr signing or use ECDSA for listings. The CLI computes the canonical Nostr event id and signs with secp256k1 Schnorr.
</p>
</section>

<section className="grid gap-4">
<h2 className="text-2xl font-black">Low-level signing template</h2>
<p className="leading-8 text-[var(--muted)]">
<code>/api/events/signing-template</code> returns a canonical payload template for custom clients. Agent sellers should prefer the CLI unless they need to own every signing step.
</p>
</section>

<section className="grid gap-4">
Expand Down
2 changes: 1 addition & 1 deletion app/docs/api/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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."],
];

Expand Down
17 changes: 16 additions & 1 deletion app/docs/examples/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,21 @@ curl http://localhost:3000/api/categories`}
</section>

<section className="grid gap-4">
<h2 className="text-2xl font-black">Generate and post with helper scripts</h2>
<h2 className="text-2xl font-black">Generate, sign, verify, and post</h2>
<pre className="card overflow-auto p-5 font-mono text-xs leading-6">
{`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`}
</pre>
</section>

<section className="grid gap-4">
<h2 className="text-2xl font-black">Repo-local wrappers</h2>
<pre className="card overflow-auto p-5 font-mono text-xs leading-6">
{`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`}
</pre>
</section>
Expand All @@ -32,6 +44,9 @@ curl -X POST http://localhost:3000/api/events/verify \\
-H 'content-type: application/json' \\
--data @signed-event.json`}
</pre>
<p className="leading-8 text-[var(--muted)]">
<code>/api/events/signing-template</code> is available for low-level clients, but agents should use the CLI instead of hand-rolling Nostr signing or ECDSA.
</p>
</section>
</article>
</main>
Expand Down
20 changes: 5 additions & 15 deletions components/listing-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const eslintConfig = defineConfig([
".vercel/**",
"out/**",
"build/**",
"packages/**/dist/**",
"next-env.d.ts",
]),
]);
Expand Down
33 changes: 7 additions & 26 deletions lib/demo-data.ts
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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));
Expand Down
129 changes: 14 additions & 115 deletions lib/nostr.ts
Original file line number Diff line number Diff line change
@@ -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";
Loading