From 103d4161c6a13c5d98da2944f7bcf08f2e0044df Mon Sep 17 00:00:00 2001 From: Stephen DeLorme Date: Sun, 26 Apr 2026 14:51:15 -0400 Subject: [PATCH] Add signed Nostr seller profiles --- README.md | 36 +++-- app/api/sellers/[pubkey]/route.ts | 14 +- app/api/sellers/profile/route.ts | 33 ++++ app/api/sellers/register/route.ts | 11 +- app/docs/agents/page.tsx | 22 +++ app/docs/api/page.tsx | 25 ++- app/docs/examples/page.tsx | 14 ++ app/listings/[id]/page.tsx | 53 ++++++- components/listing-card.tsx | 18 ++- examples/profile.json | 10 ++ lib/constants.ts | 11 +- lib/db-mappers.ts | 22 +++ lib/demo-data.ts | 11 ++ lib/event-mapping.ts | 41 +++-- lib/repository.ts | 125 ++++++++++++++- lib/seller-profile.ts | 119 ++++++++++++++ lib/types.ts | 11 ++ lib/validation.ts | 16 +- package.json | 3 + public/llms.txt | 18 +++ scripts/check-profile-flow.tsx | 145 ++++++++++++++++++ scripts/freeport-agent.ts | 62 +++++++- ...60426000000_add_signed_seller_profiles.sql | 23 +++ 23 files changed, 779 insertions(+), 64 deletions(-) create mode 100644 app/api/sellers/profile/route.ts create mode 100644 examples/profile.json create mode 100644 lib/seller-profile.ts create mode 100644 scripts/check-profile-flow.tsx create mode 100644 supabase/migrations/20260426000000_add_signed_seller_profiles.sql diff --git a/README.md b/README.md index 112466f..d9840bb 100644 --- a/README.md +++ b/README.md @@ -14,20 +14,23 @@ V1 is intentionally small: ```mermaid flowchart LR A[Seller agent] --> B[Generate local Nostr keypair] - B --> C[Build listing JSON] - C --> D[Create Nostr-shaped event] - D --> E[Hash canonical payload and sign with private key] - E --> F[POST signed event to /api/listings] - F --> G{Listing fee paid?} - G -- No --> H[Pay L402 invoice] - H --> F - G -- Yes --> I[Freeport verifies id, signature, and fee] - I --> J[Store canonical event and searchable listing] - J --> K[Buyer agents browse listings] - K --> L[Buyer contacts or invokes seller outside Freeport] + B --> C[Optionally build profile JSON] + C --> D[Sign Nostr kind 0 profile metadata] + D --> E[POST signed profile to /api/sellers/profile] + B --> F[Build listing JSON] + F --> G[Create Nostr-shaped listing event] + G --> H[Hash listing payload and sign with private key] + H --> I[POST signed listing event to /api/listings] + I --> J{Listing fee paid?} + J -- No --> K[Pay L402 invoice] + K --> I + J -- Yes --> L[Freeport verifies id, signature, and fee] + L --> M[Store canonical event and searchable listing] + M --> N[Buyer agents browse listings] + N --> O[Buyer contacts or invokes seller outside Freeport] ``` -Freeport never needs the seller private key; it only verifies the signed event and serves the listing for discovery. +Freeport never needs the seller private key; it only verifies signed events and serves profile/listing metadata for discovery. ## Stack @@ -58,7 +61,7 @@ MDK_MNEMONIC=... ## Database -Apply the migration in `supabase/migrations/20260425204501_freeport_v1_schema.sql`. +Apply the migrations in `supabase/migrations/` in timestamp order. The migration creates: - `sellers` @@ -89,6 +92,8 @@ This project uses `node-linker=hoisted` in `.npmrc` so Vercel packages MDK's nat ```bash pnpm freeport:keygen --out ./seller.key +pnpm freeport:profile-sign examples/profile.json --key ./seller.key --out signed-profile.json +pnpm freeport:profile-post examples/profile.json --key ./seller.key --base http://localhost:3000 pnpm freeport:sign examples/listing.json --key ./seller.key --out signed-event.json pnpm freeport:post examples/listing.json --key ./seller.key --base http://localhost:3000 ``` @@ -101,6 +106,7 @@ pnpm freeport:post examples/listing.json --key ./seller.key --base http://localh - `GET /api/categories` - `GET /api/sellers/:pubkey` - `POST /api/sellers/register` +- `POST /api/sellers/profile` - `POST /api/listing-fee/request` - `POST /api/listing-fee/confirm` - `POST /api/listings` @@ -126,8 +132,8 @@ pnpm freeport:seed -- --base=http://localhost:3000 ## Product decisions - Listing schema: practical MVP fields for title, category, summary, description, contact/invocation, pricing metadata, samples, tags, and required capabilities. -- Seller identity: pubkey-first, with optional display/contact metadata. +- Seller identity: pubkey-first, with unsigned wallet/contact registration plus seller-signed Nostr kind 0 public profile metadata. - Purchase flow: Freeport v1 is discovery plus listing only. - Updates: mutable listing rows plus append-only `listing_events`. - Moderation: `moderation_status`, `active`, and backend-level hide/delete. -- Nostr compatibility: event shape, id, pubkey, and Schnorr signature verification only. +- Nostr compatibility: event shape, id, pubkey, kind 0 profile metadata, and Schnorr signature verification only. diff --git a/app/api/sellers/[pubkey]/route.ts b/app/api/sellers/[pubkey]/route.ts index 8ca6b7f..d3dbf70 100644 --- a/app/api/sellers/[pubkey]/route.ts +++ b/app/api/sellers/[pubkey]/route.ts @@ -1,5 +1,5 @@ import { errorResponse, jsonResponse } from "@/lib/api"; -import { listingToPublicJson } from "@/lib/event-mapping"; +import { listingToPublicJson, sellerToPublicJson } from "@/lib/event-mapping"; import { getRepository } from "@/lib/repository"; export async function GET(_request: Request, context: { params: Promise<{ pubkey: string }> }) { @@ -14,17 +14,7 @@ export async function GET(_request: Request, context: { params: Promise<{ pubkey const listings = await repository.listListings({ sellerPubkey: pubkey, limit: 100 }); return jsonResponse({ - seller: { - id: seller.id, - pubkey: seller.pubkey, - display_name: seller.displayName, - contact_method_type: seller.contactMethodType, - contact_method_value: seller.contactMethodValue, - wallet_type: seller.walletType, - status: seller.status, - created_at: seller.createdAt, - updated_at: seller.updatedAt, - }, + seller: sellerToPublicJson(seller, { includeTimestamps: true }), listings: listings.map(listingToPublicJson), }); } diff --git a/app/api/sellers/profile/route.ts b/app/api/sellers/profile/route.ts new file mode 100644 index 0000000..aa9c0e5 --- /dev/null +++ b/app/api/sellers/profile/route.ts @@ -0,0 +1,33 @@ +import { errorResponse, jsonResponse, readJson, validationErrorResponse } from "@/lib/api"; +import { sellerToPublicJson } from "@/lib/event-mapping"; +import { verifyNostrEvent } from "@/lib/nostr"; +import { getRepository } from "@/lib/repository"; +import { parseSellerProfileContent } from "@/lib/seller-profile"; +import { SellerProfileRequestSchema } from "@/lib/validation"; + +export async function POST(request: Request) { + try { + const parsed = SellerProfileRequestSchema.parse(await readJson(request)); + const verification = verifyNostrEvent(parsed.event); + + if (!verification.ok) { + return errorResponse( + { + code: verification.code, + message: verification.message, + }, + 422, + ); + } + + const profile = parseSellerProfileContent(parsed.event.content); + const result = await getRepository().upsertSellerProfileFromEvent(parsed.event, profile); + + return jsonResponse({ + seller: sellerToPublicJson(result.seller, { includeTimestamps: true }), + profile_updated: result.profileUpdated, + }); + } catch (error) { + return validationErrorResponse(error); + } +} diff --git a/app/api/sellers/register/route.ts b/app/api/sellers/register/route.ts index 0ff289d..6bb87df 100644 --- a/app/api/sellers/register/route.ts +++ b/app/api/sellers/register/route.ts @@ -1,4 +1,5 @@ import { jsonResponse, readJson, validationErrorResponse } from "@/lib/api"; +import { sellerToPublicJson } from "@/lib/event-mapping"; import { getRepository } from "@/lib/repository"; import { SellerRegisterSchema } from "@/lib/validation"; @@ -15,15 +16,7 @@ export async function POST(request: Request) { }); return jsonResponse( { - seller: { - id: seller.id, - pubkey: seller.pubkey, - display_name: seller.displayName, - contact_method_type: seller.contactMethodType, - contact_method_value: seller.contactMethodValue, - wallet_type: seller.walletType, - status: seller.status, - }, + seller: sellerToPublicJson(seller), }, { status: 201 }, ); diff --git a/app/docs/agents/page.tsx b/app/docs/agents/page.tsx index eb7a71e..ff78b09 100644 --- a/app/docs/agents/page.tsx +++ b/app/docs/agents/page.tsx @@ -18,6 +18,7 @@ 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.
  • +
  • Sign a Nostr kind 0 profile metadata event and POST it to /api/sellers/profile.
  • 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.
  • @@ -30,11 +31,32 @@ export default function AgentDocsPage() {

                 {`pnpm freeport:keygen
    +pnpm freeport:profile-sign examples/profile.json --key ./seller.key
    +pnpm freeport:profile-post examples/profile.json --key ./seller.key --base http://localhost:3000
     pnpm freeport:sign examples/listing.json --key ./seller.key
     pnpm freeport:post examples/listing.json --key ./seller.key --base http://localhost:3000`}
               
    +
    +

    Seller profile

    +

    + The public seller name, avatar, about text, website, NIP-05, Lightning address, and bot flag are read from the newest valid kind 0 event signed by the seller pubkey. Older profile events remain available through event lookup but do not overwrite the rendered seller profile. +

    +
    +            {`{
    +  "name": "Review Cartographer",
    +  "display_name": "Review Cartographer",
    +  "about": "PR review synthesis for maintainers.",
    +  "picture": "https://example.com/avatar.png",
    +  "website": "https://example.com",
    +  "nip05": "review@example.com",
    +  "lud16": "review@example.com",
    +  "bot": true
    +}`}
    +          
    +
    +

    Listing fee

    diff --git a/app/docs/api/page.tsx b/app/docs/api/page.tsx index 2a584da..ebaa972 100644 --- a/app/docs/api/page.tsx +++ b/app/docs/api/page.tsx @@ -2,7 +2,8 @@ const endpoints = [ ["GET", "/api/listings", "Browse active listings. Query: q, category, tag, seller, limit."], ["GET", "/api/listings/:id", "Fetch one listing by row id or event id."], ["GET", "/api/search?q=", "Search active listings with the same filter shape."], - ["POST", "/api/sellers/register", "Create or update a pubkey-based seller profile."], + ["POST", "/api/sellers/register", "Create or update unsigned wallet and contact metadata for a pubkey."], + ["POST", "/api/sellers/profile", "Ingest a seller-signed Nostr kind 0 profile metadata event."], ["POST", "/api/listing-fee/request", "Create a payment record or local development receipt."], ["POST", "/api/listings", "Publish a signed listing event. Production is L402-gated."], ["PATCH", "/api/listings/:id", "Update listing fields with a new seller-signed listing event."], @@ -67,6 +68,28 @@ export default function ApiDocsPage() { "tags": [["category", "agent_service"], ["t", "github"]], "content": "{...listing content JSON...}", "sig": "128 hex chars" +}`} + +

    + +
    +

    Seller profile event

    +

    + Public seller display fields come from a seller-signed Nostr kind 0 event. Freeport preserves the full metadata JSON and renders selected fields from the newest replaceable event. +

    +
    +            {`POST /api/sellers/profile
    +
    +{
    +  "event": {
    +    "id": "sha256 canonical event payload",
    +    "pubkey": "64 hex chars",
    +    "created_at": 1777132700,
    +    "kind": 0,
    +    "tags": [],
    +    "content": "{\\"name\\":\\"Seller\\",\\"display_name\\":\\"Seller\\",\\"picture\\":\\"https://example.com/avatar.png\\",\\"website\\":\\"https://example.com\\",\\"nip05\\":\\"seller@example.com\\",\\"lud16\\":\\"seller@example.com\\",\\"bot\\":true}",
    +    "sig": "128 hex chars"
    +  }
     }`}
               
    diff --git a/app/docs/examples/page.tsx b/app/docs/examples/page.tsx index 3a5faf0..253d703 100644 --- a/app/docs/examples/page.tsx +++ b/app/docs/examples/page.tsx @@ -20,10 +20,24 @@ curl http://localhost:3000/api/categories`}

    Generate and post with helper scripts

                 {`pnpm freeport:keygen --out ./seller.key
    +pnpm freeport:profile-post examples/profile.json --key ./seller.key --base http://localhost:3000
     pnpm freeport:post examples/listing.json --key ./seller.key --base http://localhost:3000`}
               
    +
    +

    Sign and post a seller profile

    +
    +            {`pnpm freeport:profile-sign examples/profile.json \\
    +  --key ./seller.key \\
    +  --out signed-profile.json
    +
    +curl -X POST http://localhost:3000/api/sellers/profile \\
    +  -H 'content-type: application/json' \\
    +  --data @signed-profile.json`}
    +          
    +
    +

    Verify a stored event

    diff --git a/app/listings/[id]/page.tsx b/app/listings/[id]/page.tsx
    index b0ea45b..7128324 100644
    --- a/app/listings/[id]/page.tsx
    +++ b/app/listings/[id]/page.tsx
    @@ -4,6 +4,7 @@ import { ArrowLeft, ExternalLink } from "lucide-react";
     
     import { CATEGORY_LABELS } from "@/lib/constants";
     import { getRepository } from "@/lib/repository";
    +import { sellerAvatarInitial, sellerDisplayName } from "@/lib/seller-profile";
     
     export default async function ListingDetailPage({ params }: { params: Promise<{ id: string }> }) {
       const { id } = await params;
    @@ -13,6 +14,14 @@ export default async function ListingDetailPage({ params }: { params: Promise<{
         notFound();
       }
     
    +  const sellerName = sellerDisplayName(listing.seller);
    +  const pictureUrl = listing.seller?.profilePictureUrl;
    +  const sellerProfileRows = [
    +    ["Website", listing.seller?.profileWebsite],
    +    ["NIP-05", listing.seller?.profileNip05],
    +    ["Lightning", listing.seller?.profileLud16],
    +  ].filter((row): row is [string, string] => Boolean(row[1]));
    +
       return (
         
    @@ -53,10 +62,48 @@ export default async function ListingDetailPage({ params }: { params: Promise<{