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() {
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<{
-
-
Seller
-
{listing.seller?.displayName ?? "Unknown seller"}
+
+
+ {pictureUrl ? null : sellerAvatarInitial(listing.seller)}
+
+
+
Seller
+
{sellerName}
+ {listing.seller?.profileBot !== null && listing.seller?.profileBot !== undefined ? (
+
+ Bot: {listing.seller.profileBot ? "yes" : "no"}
+
+ ) : null}
+
+ {listing.seller?.profileAbout ? (
+
{listing.seller.profileAbout}
+ ) : null}
+ {sellerProfileRows.length ? (
+
+ {sellerProfileRows.map(([label, value]) => (
+
+
{label}
+ {label === "Website" ? (
+
+ {value}
+
+ ) : (
+
{value}
+ )}
+
+ ))}
+
+ ) : null}
Pubkey
{listing.seller?.pubkey}
diff --git a/components/listing-card.tsx b/components/listing-card.tsx
index 3bd52b8..ab17904 100644
--- a/components/listing-card.tsx
+++ b/components/listing-card.tsx
@@ -2,6 +2,7 @@ import Link from "next/link";
import { ArrowUpRight, Bot, Cable, Workflow } from "lucide-react";
import { CATEGORY_LABELS } from "@/lib/constants";
+import { sellerAvatarInitial, sellerDisplayName } from "@/lib/seller-profile";
import type { ListingWithSeller } from "@/lib/types";
const icons = {
@@ -18,6 +19,8 @@ const iconTones = {
export function ListingCard({ listing }: { listing: ListingWithSeller }) {
const Icon = icons[listing.category];
+ const sellerName = sellerDisplayName(listing.seller);
+ const pictureUrl = listing.seller?.profilePictureUrl;
return (
@@ -45,9 +48,18 @@ export function ListingCard({ listing }: { listing: ListingWithSeller }) {
-
-
Seller
-
{listing.seller?.displayName ?? listing.seller?.pubkey}
+
+
+ {pictureUrl ? null : sellerAvatarInitial(listing.seller)}
+
+
+
Seller
+
{sellerName}
+
diff --git a/examples/profile.json b/examples/profile.json
new file mode 100644
index 0000000..140bf75
--- /dev/null
+++ b/examples/profile.json
@@ -0,0 +1,10 @@
+{
+ "name": "Review Cartographer",
+ "display_name": "Review Cartographer",
+ "about": "PR review synthesis for maintainers and implementation agents.",
+ "picture": "https://example.com/freeport/review-cartographer.png",
+ "website": "https://example.com/agents/review-cartographer",
+ "nip05": "review@example.com",
+ "lud16": "review@example.com",
+ "bot": true
+}
diff --git a/lib/constants.ts b/lib/constants.ts
index 08c2a99..0d03bae 100644
--- a/lib/constants.ts
+++ b/lib/constants.ts
@@ -5,7 +5,7 @@ export const LISTING_FEE_USD_CENTS = 50;
export const LISTING_FEE_DISPLAY = "$0.50";
export const EVENT_KINDS = {
- sellerProfile: 33000,
+ sellerProfile: 0,
listing: 33001,
listingDeactivation: 33002,
} as const;
@@ -49,7 +49,8 @@ export const ONBOARDING_PROMPT = `You are onboarding to Freeport, a marketplace
1. Open /llms.txt and read the Freeport agent instructions.
2. Browse /api/listings and /api/categories to understand the marketplace.
3. If selling, generate a Nostr-style secp256k1 keypair and keep the private key local.
-4. Prepare a signed listing event with category, title, summary, description, pricing metadata, and contact or invocation details.
-5. Request the per-listing fee flow. In production, POST /api/listings without Authorization to receive an L402 invoice for $0.50 USD, pay it with a Lightning wallet, then retry with Authorization: L402
:.
-6. POST the signed event to /api/listings.
-7. Use PATCH /api/listings/{id} for updates and POST /api/listings/{id}/deactivate when the listing should stop appearing.`;
+4. Optionally POST a seller-signed Nostr kind 0 profile event to /api/sellers/profile.
+5. Prepare a signed listing event with category, title, summary, description, pricing metadata, and contact or invocation details.
+6. Request the per-listing fee flow. In production, POST /api/listings without Authorization to receive an L402 invoice for $0.50 USD, pay it with a Lightning wallet, then retry with Authorization: L402 :.
+7. POST the signed event to /api/listings.
+8. Use PATCH /api/listings/{id} for updates and POST /api/listings/{id}/deactivate when the listing should stop appearing.`;
diff --git a/lib/db-mappers.ts b/lib/db-mappers.ts
index 7817960..9aa6e6c 100644
--- a/lib/db-mappers.ts
+++ b/lib/db-mappers.ts
@@ -18,6 +18,17 @@ export function sellerFromRow(row: DbRow): Seller {
contactMethodValue: (row.contact_method_value as string | null) ?? null,
walletType: (row.wallet_type as string | null) ?? null,
walletMetadata: (row.wallet_metadata as JsonObject | null) ?? {},
+ profileName: (row.profile_name as string | null) ?? null,
+ profileDisplayName: (row.profile_display_name as string | null) ?? null,
+ profileAbout: (row.profile_about as string | null) ?? null,
+ profilePictureUrl: (row.profile_picture_url as string | null) ?? null,
+ profileWebsite: (row.profile_website as string | null) ?? null,
+ profileNip05: (row.profile_nip05 as string | null) ?? null,
+ profileLud16: (row.profile_lud16 as string | null) ?? null,
+ profileBot: (row.profile_bot as boolean | null) ?? null,
+ profileMetadata: (row.profile_metadata as JsonObject | null) ?? {},
+ profileEventId: (row.profile_event_id as string | null) ?? null,
+ profileEventCreatedAt: (row.profile_event_created_at as number | null) ?? null,
createdAt: row.created_at as string,
updatedAt: row.updated_at as string,
status: row.status as Seller["status"],
@@ -32,6 +43,17 @@ export function sellerToRow(seller: Partial & { pubkey: string }) {
contact_method_value: seller.contactMethodValue ?? null,
wallet_type: seller.walletType ?? "moneydevkit_agent_wallet",
wallet_metadata: seller.walletMetadata ?? {},
+ profile_name: seller.profileName ?? null,
+ profile_display_name: seller.profileDisplayName ?? null,
+ profile_about: seller.profileAbout ?? null,
+ profile_picture_url: seller.profilePictureUrl ?? null,
+ profile_website: seller.profileWebsite ?? null,
+ profile_nip05: seller.profileNip05 ?? null,
+ profile_lud16: seller.profileLud16 ?? null,
+ profile_bot: seller.profileBot ?? null,
+ profile_metadata: seller.profileMetadata ?? {},
+ profile_event_id: seller.profileEventId ?? null,
+ profile_event_created_at: seller.profileEventCreatedAt ?? null,
status: seller.status ?? "active",
};
}
diff --git a/lib/demo-data.ts b/lib/demo-data.ts
index 38f57d1..a269d93 100644
--- a/lib/demo-data.ts
+++ b/lib/demo-data.ts
@@ -24,6 +24,17 @@ function seller(pubkey: string, index: number, displayName: string): Seller {
contactMethodValue: `https://freeport.local/demo/${displayName.toLowerCase().replaceAll(" ", "-")}`,
walletType: "moneydevkit_agent_wallet",
walletMetadata: { network: "mainnet", recommended_client: "agent-wallet" },
+ profileName: null,
+ profileDisplayName: null,
+ profileAbout: null,
+ profilePictureUrl: null,
+ profileWebsite: null,
+ profileNip05: null,
+ profileLud16: null,
+ profileBot: null,
+ profileMetadata: {},
+ profileEventId: null,
+ profileEventCreatedAt: null,
createdAt: now,
updatedAt: now,
status: "active",
diff --git a/lib/event-mapping.ts b/lib/event-mapping.ts
index 238d08b..a124761 100644
--- a/lib/event-mapping.ts
+++ b/lib/event-mapping.ts
@@ -12,6 +12,35 @@ import type {
Seller,
} from "@/lib/types";
+export function sellerToPublicJson(seller: Seller, options: { includeTimestamps?: boolean } = {}) {
+ return {
+ 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,
+ profile_name: seller.profileName,
+ profile_display_name: seller.profileDisplayName,
+ profile_about: seller.profileAbout,
+ profile_picture_url: seller.profilePictureUrl,
+ profile_website: seller.profileWebsite,
+ profile_nip05: seller.profileNip05,
+ profile_lud16: seller.profileLud16,
+ profile_bot: seller.profileBot,
+ profile_metadata: seller.profileMetadata,
+ profile_event_id: seller.profileEventId,
+ profile_event_created_at: seller.profileEventCreatedAt,
+ ...(options.includeTimestamps
+ ? {
+ created_at: seller.createdAt,
+ updated_at: seller.updatedAt,
+ }
+ : {}),
+ };
+}
+
export function uuidFromEventId(eventId: string) {
const hex = eventId.padEnd(32, "0").slice(0, 32).split("");
hex[12] = "4";
@@ -75,17 +104,7 @@ export function listingToPublicJson(listing: ListingWithSeller) {
return {
id: listing.id,
seller_id: listing.sellerId,
- seller: listing.seller
- ? {
- id: listing.seller.id,
- pubkey: listing.seller.pubkey,
- display_name: listing.seller.displayName,
- contact_method_type: listing.seller.contactMethodType,
- contact_method_value: listing.seller.contactMethodValue,
- wallet_type: listing.seller.walletType,
- status: listing.seller.status,
- }
- : null,
+ seller: listing.seller ? sellerToPublicJson(listing.seller) : null,
event_id: listing.eventId,
kind: listing.kind,
category: listing.category,
diff --git a/lib/repository.ts b/lib/repository.ts
index c372066..3ea94ab 100644
--- a/lib/repository.ts
+++ b/lib/repository.ts
@@ -14,6 +14,7 @@ import {
} from "@/lib/db-mappers";
import { eventRecordFromEvent, listingFromEvent } from "@/lib/event-mapping";
import { hasSupabaseConfig } from "@/lib/env";
+import type { SellerProfileData } from "@/lib/seller-profile";
import { createServiceClient } from "@/lib/supabase";
import type {
JsonObject,
@@ -59,6 +60,9 @@ function matchesListing(listing: ListingWithSeller, filters: ListingFilters) {
listing.description,
listing.category,
...listing.tags,
+ listing.seller?.profileDisplayName ?? "",
+ listing.seller?.profileName ?? "",
+ listing.seller?.profileAbout ?? "",
listing.seller?.displayName ?? "",
listing.seller?.pubkey ?? "",
]
@@ -115,6 +119,17 @@ class MemoryRepository {
contactMethodValue: input.contactMethodValue ?? null,
walletType: input.walletType ?? "moneydevkit_agent_wallet",
walletMetadata: (input.walletMetadata as JsonObject | undefined) ?? {},
+ profileName: null,
+ profileDisplayName: null,
+ profileAbout: null,
+ profilePictureUrl: null,
+ profileWebsite: null,
+ profileNip05: null,
+ profileLud16: null,
+ profileBot: null,
+ profileMetadata: {},
+ profileEventId: null,
+ profileEventCreatedAt: null,
createdAt: now,
updatedAt: now,
status: "active",
@@ -123,6 +138,35 @@ class MemoryRepository {
return seller;
}
+ async upsertSellerProfileFromEvent(event: NostrEvent, profile: SellerProfileData) {
+ const seller = await this.upsertSeller({ pubkey: event.pubkey });
+ const profileUpdated = shouldReplaceSellerProfile(seller, event);
+ const eventRecord = eventRecordFromEvent(event, null);
+ if (!profileUpdated) eventRecord.supersededByEventId = seller.profileEventId;
+
+ if (!this.store.events.some((record) => record.eventId === event.id)) {
+ this.store.events.push(eventRecord);
+ }
+
+ if (!profileUpdated) {
+ return { seller, profileUpdated };
+ }
+
+ seller.profileName = profile.profileName;
+ seller.profileDisplayName = profile.profileDisplayName;
+ seller.profileAbout = profile.profileAbout;
+ seller.profilePictureUrl = profile.profilePictureUrl;
+ seller.profileWebsite = profile.profileWebsite;
+ seller.profileNip05 = profile.profileNip05;
+ seller.profileLud16 = profile.profileLud16;
+ seller.profileBot = profile.profileBot;
+ seller.profileMetadata = profile.profileMetadata;
+ seller.profileEventId = event.id;
+ seller.profileEventCreatedAt = event.created_at;
+ seller.updatedAt = new Date().toISOString();
+ return { seller, profileUpdated };
+ }
+
async createListingFromEvent(event: NostrEvent, payment?: ListingFeePayment | null) {
const seller = await this.upsertSeller({ pubkey: event.pubkey });
const listing = listingFromEvent(event, seller);
@@ -272,9 +316,31 @@ class SupabaseRepository {
walletMetadata?: Record;
}) {
const client = this.requireClient();
+ const existing = await this.getSellerByPubkey(input.pubkey);
+
+ if (existing) {
+ const patch: Record = {};
+ if (input.displayName !== undefined) patch.display_name = input.displayName;
+ if (input.contactMethodType !== undefined) patch.contact_method_type = input.contactMethodType;
+ if (input.contactMethodValue !== undefined) patch.contact_method_value = input.contactMethodValue;
+ if (input.walletType !== undefined) patch.wallet_type = input.walletType;
+ if (input.walletMetadata !== undefined) patch.wallet_metadata = input.walletMetadata;
+
+ if (!Object.keys(patch).length) return existing;
+
+ const { data, error } = await client
+ .from("sellers")
+ .update(patch)
+ .eq("pubkey", input.pubkey)
+ .select("*")
+ .single();
+ if (error) throw error;
+ return sellerFromRow(data);
+ }
+
const { data, error } = await client
.from("sellers")
- .upsert(
+ .insert(
sellerToRow({
pubkey: input.pubkey,
displayName: input.displayName,
@@ -283,7 +349,6 @@ class SupabaseRepository {
walletType: input.walletType,
walletMetadata: input.walletMetadata as JsonObject | undefined,
}),
- { onConflict: "pubkey" },
)
.select("*")
.single();
@@ -291,6 +356,55 @@ class SupabaseRepository {
return sellerFromRow(data);
}
+ async upsertSellerProfileFromEvent(event: NostrEvent, profile: SellerProfileData) {
+ const client = this.requireClient();
+ const seller = await this.upsertSeller({ pubkey: event.pubkey });
+ const profileUpdated = shouldReplaceSellerProfile(seller, event);
+ const eventRecord = eventRecordFromEvent(event, null);
+ if (!profileUpdated) eventRecord.supersededByEventId = seller.profileEventId;
+
+ const { error: eventError } = await client
+ .from("listing_events")
+ .upsert(eventRecordToRow(eventRecord), {
+ onConflict: "event_id",
+ ignoreDuplicates: true,
+ });
+ if (eventError) throw eventError;
+
+ if (!profileUpdated) {
+ return { seller, profileUpdated };
+ }
+
+ const { data, error } = await client
+ .from("sellers")
+ .update({
+ profile_name: profile.profileName,
+ profile_display_name: profile.profileDisplayName,
+ profile_about: profile.profileAbout,
+ profile_picture_url: profile.profilePictureUrl,
+ profile_website: profile.profileWebsite,
+ profile_nip05: profile.profileNip05,
+ profile_lud16: profile.profileLud16,
+ profile_bot: profile.profileBot,
+ profile_metadata: profile.profileMetadata,
+ profile_event_id: event.id,
+ profile_event_created_at: event.created_at,
+ })
+ .eq("id", seller.id)
+ .select("*")
+ .single();
+ if (error) throw error;
+
+ if (seller.profileEventId) {
+ await client
+ .from("listing_events")
+ .update({ superseded_by_event_id: event.id })
+ .eq("event_id", seller.profileEventId);
+ }
+
+ return { seller: sellerFromRow(data), profileUpdated };
+ }
+
async createListingFromEvent(event: NostrEvent, payment?: ListingFeePayment | null) {
const client = this.requireClient();
const seller = await this.upsertSeller({ pubkey: event.pubkey });
@@ -435,6 +549,13 @@ class SupabaseRepository {
}
}
+function shouldReplaceSellerProfile(seller: Seller, event: NostrEvent) {
+ if (!seller.profileEventId || seller.profileEventCreatedAt === null) return true;
+ if (event.created_at > seller.profileEventCreatedAt) return true;
+ if (event.created_at < seller.profileEventCreatedAt) return false;
+ return event.id < seller.profileEventId;
+}
+
export function getRepository() {
return hasSupabaseConfig() ? new SupabaseRepository() : new MemoryRepository();
}
diff --git a/lib/seller-profile.ts b/lib/seller-profile.ts
new file mode 100644
index 0000000..79f8a3e
--- /dev/null
+++ b/lib/seller-profile.ts
@@ -0,0 +1,119 @@
+import type { JsonObject, JsonValue, Seller } from "@/lib/types";
+
+export type SellerProfileData = {
+ profileName: string | null;
+ profileDisplayName: string | null;
+ profileAbout: string | null;
+ profilePictureUrl: string | null;
+ profileWebsite: string | null;
+ profileNip05: string | null;
+ profileLud16: string | null;
+ profileBot: boolean | null;
+ profileMetadata: JsonObject;
+};
+
+const STRING_LIMITS = {
+ name: 80,
+ display_name: 80,
+ about: 700,
+ nip05: 256,
+ lud16: 256,
+} as const;
+
+function isJsonObject(value: unknown): value is JsonObject {
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
+}
+
+function readString(metadata: JsonObject, key: keyof typeof STRING_LIMITS) {
+ const value = metadata[key];
+ if (value === undefined || value === null) return null;
+ if (typeof value !== "string") {
+ throw new Error(`Profile ${key} must be a string.`);
+ }
+ const trimmed = value.trim();
+ if (!trimmed) return null;
+ if (trimmed.length > STRING_LIMITS[key]) {
+ throw new Error(`Profile ${key} must be ${STRING_LIMITS[key]} characters or fewer.`);
+ }
+ return trimmed;
+}
+
+function readUrl(metadata: JsonObject, key: "picture" | "website") {
+ const value = metadata[key];
+ if (value === undefined || value === null) return null;
+ if (typeof value !== "string") {
+ throw new Error(`Profile ${key} must be a URL string.`);
+ }
+ const trimmed = value.trim();
+ if (!trimmed) return null;
+ if (trimmed.length > 2048) {
+ throw new Error(`Profile ${key} URL must be 2048 characters or fewer.`);
+ }
+
+ let url: URL;
+ try {
+ url = new URL(trimmed);
+ } catch {
+ throw new Error(`Profile ${key} must be a valid URL.`);
+ }
+
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
+ throw new Error(`Profile ${key} must use http or https.`);
+ }
+
+ return trimmed;
+}
+
+function readBot(metadata: JsonObject) {
+ const value = metadata.bot;
+ if (value === undefined || value === null) return null;
+ if (typeof value !== "boolean") {
+ throw new Error("Profile bot must be a boolean.");
+ }
+ return value;
+}
+
+export function parseSellerProfileContent(raw: string): SellerProfileData {
+ let parsed: JsonValue;
+ try {
+ parsed = JSON.parse(raw) as JsonValue;
+ } catch {
+ throw new Error("Profile content must be valid JSON.");
+ }
+
+ if (!isJsonObject(parsed)) {
+ throw new Error("Profile content must be a JSON object.");
+ }
+
+ return {
+ profileName: readString(parsed, "name"),
+ profileDisplayName: readString(parsed, "display_name"),
+ profileAbout: readString(parsed, "about"),
+ profilePictureUrl: readUrl(parsed, "picture"),
+ profileWebsite: readUrl(parsed, "website"),
+ profileNip05: readString(parsed, "nip05"),
+ profileLud16: readString(parsed, "lud16"),
+ profileBot: readBot(parsed),
+ profileMetadata: parsed,
+ };
+}
+
+export function shortPubkey(pubkey?: string | null) {
+ if (!pubkey) return "Unknown seller";
+ return `${pubkey.slice(0, 8)}...${pubkey.slice(-6)}`;
+}
+
+export function sellerDisplayName(seller?: Seller | null) {
+ return (
+ seller?.profileDisplayName ??
+ seller?.profileName ??
+ seller?.displayName ??
+ shortPubkey(seller?.pubkey)
+ );
+}
+
+export function sellerAvatarInitial(seller?: Seller | null) {
+ const label = sellerDisplayName(seller);
+ const first = label.trim().charAt(0);
+ return first ? first.toUpperCase() : "S";
+}
diff --git a/lib/types.ts b/lib/types.ts
index a216526..534e392 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -45,6 +45,17 @@ export type Seller = {
contactMethodValue: string | null;
walletType: string | null;
walletMetadata: JsonObject;
+ profileName: string | null;
+ profileDisplayName: string | null;
+ profileAbout: string | null;
+ profilePictureUrl: string | null;
+ profileWebsite: string | null;
+ profileNip05: string | null;
+ profileLud16: string | null;
+ profileBot: boolean | null;
+ profileMetadata: JsonObject;
+ profileEventId: string | null;
+ profileEventCreatedAt: number | null;
createdAt: string;
updatedAt: string;
status: SellerStatus;
diff --git a/lib/validation.ts b/lib/validation.ts
index 1463a8c..0034eb1 100644
--- a/lib/validation.ts
+++ b/lib/validation.ts
@@ -75,7 +75,7 @@ export const NostrEventSchema = z.object({
id: hex64,
pubkey: hex64,
created_at: z.number().int().positive(),
- kind: z.number().int().positive(),
+ kind: z.number().int().nonnegative(),
tags: z.array(z.array(z.string())),
content: z.string().min(2).max(20000),
sig: hex128,
@@ -97,6 +97,18 @@ export const CreateListingRequestSchema = z.object({
listing_fee_payment_id: z.string().uuid().optional(),
});
+export const SellerProfileEventSchema = NostrEventSchema.refine(
+ (event) => event.kind === EVENT_KINDS.sellerProfile,
+ {
+ message: "Seller profile event kind must be 0.",
+ path: ["kind"],
+ },
+);
+
+export const SellerProfileRequestSchema = z.object({
+ event: SellerProfileEventSchema,
+});
+
export const SellerRegisterSchema = z.object({
pubkey: hex64,
display_name: z.string().trim().min(2).max(80).optional(),
@@ -108,7 +120,7 @@ export const SellerRegisterSchema = z.object({
export const SigningTemplateRequestSchema = z.object({
pubkey: hex64,
- kind: z.number().int().positive().default(EVENT_KINDS.listing),
+ kind: z.number().int().nonnegative().default(EVENT_KINDS.listing),
content: ListingContentSchema,
tags: z.array(z.array(z.string())).optional(),
});
diff --git a/package.json b/package.json
index 7d18d87..5c80125 100644
--- a/package.json
+++ b/package.json
@@ -8,8 +8,11 @@
"start": "next start",
"lint": "eslint",
"freeport:keygen": "tsx scripts/freeport-agent.ts keygen",
+ "freeport:profile-sign": "tsx scripts/freeport-agent.ts profile-sign",
+ "freeport:profile-post": "tsx scripts/freeport-agent.ts profile-post",
"freeport:sign": "tsx scripts/freeport-agent.ts sign",
"freeport:post": "tsx scripts/freeport-agent.ts post",
+ "freeport:profile-check": "tsx scripts/check-profile-flow.tsx",
"freeport:seed": "tsx scripts/seed-demo.ts"
},
"dependencies": {
diff --git a/public/llms.txt b/public/llms.txt
index 9f29718..c751e93 100644
--- a/public/llms.txt
+++ b/public/llms.txt
@@ -23,6 +23,7 @@ Read endpoints:
Seller endpoints:
- POST /api/sellers/register
+- POST /api/sellers/profile
- POST /api/listing-fee/request
- POST /api/listing-fee/confirm
- POST /api/listings
@@ -46,6 +47,21 @@ Nostr-shaped listing event:
}
```
+Nostr kind 0 seller profile event:
+```json
+{
+ "id": "64 lowercase hex chars",
+ "pubkey": "64 lowercase hex chars",
+ "created_at": 1777132700,
+ "kind": 0,
+ "tags": [],
+ "content": "{\"name\":\"Seller name\",\"display_name\":\"Seller display\",\"picture\":\"https://example.com/avatar.png\",\"website\":\"https://example.com\",\"nip05\":\"seller@example.com\",\"lud16\":\"seller@example.com\",\"bot\":true}",
+ "sig": "128 lowercase hex chars"
+}
+```
+
+POST kind 0 events to /api/sellers/profile before or after posting listings. Freeport stores the full metadata object and renders the newest replaceable profile event for that pubkey.
+
Listing content minimum:
```json
{
@@ -71,6 +87,8 @@ Agent wallet recommendation:
Helper scripts:
- `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`
diff --git a/scripts/check-profile-flow.tsx b/scripts/check-profile-flow.tsx
new file mode 100644
index 0000000..4da98c5
--- /dev/null
+++ b/scripts/check-profile-flow.tsx
@@ -0,0 +1,145 @@
+import assert from "node:assert/strict";
+
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { ListingCard } from "../components/listing-card";
+import { EVENT_KINDS } from "../lib/constants";
+import { listingToPublicJson } from "../lib/event-mapping";
+import { privateKeyToPubkey, signEvent } from "../lib/nostr";
+import { getRepository } from "../lib/repository";
+import type { ListingContent, NostrEvent } from "../lib/types";
+
+delete process.env.NEXT_PUBLIC_SUPABASE_URL;
+delete process.env.SUPABASE_SERVICE_ROLE_KEY;
+delete process.env.SUPABASE_SECRET_KEY;
+delete process.env.MDK_ACCESS_TOKEN;
+delete process.env.MDK_MNEMONIC;
+
+const privateKey = "0000000000000000000000000000000000000000000000000000000000000009";
+const pubkey = privateKeyToPubkey(privateKey);
+const pictureUrl = "https://example.com/profiles/profile-check.png";
+
+function profileEvent(createdAt: number, metadata: Record) {
+ return signEvent(
+ {
+ pubkey,
+ created_at: createdAt,
+ kind: EVENT_KINDS.sellerProfile,
+ tags: [],
+ content: JSON.stringify(metadata),
+ },
+ privateKey,
+ );
+}
+
+async function postProfile(event: NostrEvent) {
+ const { POST } = await import("../app/api/sellers/profile/route");
+ const response = await POST(
+ new Request("http://localhost/api/sellers/profile", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ event }),
+ }),
+ );
+ return { response, body: await response.json() };
+}
+
+async function main() {
+ const newer = profileEvent(200, {
+ name: "Profile Check Seller",
+ display_name: "Signed Profile Check",
+ about: "Seller profile check fixture.",
+ picture: pictureUrl,
+ website: "https://example.com/profile-check",
+ nip05: "profile@example.com",
+ lud16: "profile@example.com",
+ bot: true,
+ custom_field: "preserved",
+ });
+
+ const valid = await postProfile(newer);
+ assert.equal(valid.response.status, 200);
+ assert.equal(valid.body.profile_updated, true);
+ assert.equal(valid.body.seller.profile_display_name, "Signed Profile Check");
+ assert.equal(valid.body.seller.profile_metadata.custom_field, "preserved");
+
+ const invalidSignature = await postProfile({ ...newer, sig: "0".repeat(128) });
+ assert.equal(invalidSignature.response.status, 422);
+ assert.equal(invalidSignature.body.error.code, "invalid_signature");
+
+ const nonJson = signEvent(
+ {
+ pubkey,
+ created_at: 201,
+ kind: EVENT_KINDS.sellerProfile,
+ tags: [],
+ content: "not-json",
+ },
+ privateKey,
+ );
+ const nonJsonResponse = await postProfile(nonJson);
+ assert.equal(nonJsonResponse.response.status, 400);
+ assert.equal(nonJsonResponse.body.error.code, "bad_request");
+
+ const older = profileEvent(100, {
+ name: "Older Profile",
+ display_name: "Older Profile",
+ picture: "https://example.com/profiles/older.png",
+ });
+ const oldResponse = await postProfile(older);
+ assert.equal(oldResponse.response.status, 200);
+ assert.equal(oldResponse.body.profile_updated, false);
+ assert.equal(oldResponse.body.seller.profile_display_name, "Signed Profile Check");
+
+ const storedOlder = await getRepository().getEvent(older.id);
+ assert.ok(storedOlder);
+ assert.equal(storedOlder.listingId, null);
+
+ const latest = profileEvent(300, {
+ name: "Latest Profile",
+ display_name: "Latest Profile",
+ picture: pictureUrl,
+ });
+ const latestResponse = await postProfile(latest);
+ assert.equal(latestResponse.response.status, 200);
+ assert.equal(latestResponse.body.profile_updated, true);
+ assert.equal(latestResponse.body.seller.profile_display_name, "Latest Profile");
+
+ const listingPayload: ListingContent = {
+ category: "agent_service",
+ title: "Profile picture render check",
+ summary: "Checks that profile picture URLs flow into listing card rendering.",
+ description:
+ "This fixture listing exercises the signed seller profile data in listing public JSON and the listing card UI.",
+ tags: ["profile", "check"],
+ pricing_model: "quote_required",
+ pricing_details: {},
+ invocation_method: "https",
+ invocation_url: "https://example.com/profile-check/invoke",
+ contact_info: { url: "https://example.com/profile-check" },
+ required_capabilities: ["http"],
+ };
+ const listingEvent = signEvent(
+ {
+ pubkey,
+ created_at: 301,
+ kind: EVENT_KINDS.listing,
+ tags: [["category", listingPayload.category]],
+ content: JSON.stringify(listingPayload),
+ },
+ privateKey,
+ );
+ const listing = await getRepository().createListingFromEvent(listingEvent, null);
+ const publicJson = listingToPublicJson(listing);
+ assert.equal(publicJson.seller?.profile_picture_url, pictureUrl);
+
+ const markup = renderToStaticMarkup( );
+ assert.ok(markup.includes(pictureUrl));
+
+ console.log("profile flow checks passed");
+}
+
+main().catch((error) => {
+ console.error(error);
+ process.exit(1);
+});
diff --git a/scripts/freeport-agent.ts b/scripts/freeport-agent.ts
index 1d99a91..e03230f 100644
--- a/scripts/freeport-agent.ts
+++ b/scripts/freeport-agent.ts
@@ -3,7 +3,7 @@ import { basename } from "node:path";
import { EVENT_KINDS } from "../lib/constants";
import { generateKeypair, privateKeyToPubkey, signEvent } from "../lib/nostr";
-import type { ListingContent, NostrEvent } from "../lib/types";
+import type { JsonObject, ListingContent, NostrEvent } from "../lib/types";
type Args = Record;
@@ -35,6 +35,8 @@ function usage() {
Commands:
keygen --out ./seller.key
+ profile-sign profile.json --key ./seller.key --out signed-profile.json
+ profile-post profile.json --key ./seller.key --base http://localhost:3000
sign examples/listing.json --key ./seller.key --out signed-event.json
post examples/listing.json --key ./seller.key --base http://localhost:3000
@@ -52,6 +54,15 @@ function readListing(path?: string) {
return JSON.parse(readFileSync(path, "utf8")) as ListingContent;
}
+function readJsonObject(path?: string, label = "JSON") {
+ if (!path) throw new Error(`${label} file path is required.`);
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+ throw new Error(`${label} must be a JSON object.`);
+ }
+ return parsed as JsonObject;
+}
+
function signListing(content: ListingContent, privateKey: string): NostrEvent {
const pubkey = privateKeyToPubkey(privateKey);
return signEvent(
@@ -70,6 +81,36 @@ function signListing(content: ListingContent, privateKey: string): NostrEvent {
);
}
+function signProfile(metadata: JsonObject, privateKey: string): NostrEvent {
+ const pubkey = privateKeyToPubkey(privateKey);
+ return signEvent(
+ {
+ pubkey,
+ created_at: Math.floor(Date.now() / 1000),
+ kind: EVENT_KINDS.sellerProfile,
+ tags: [],
+ content: JSON.stringify(metadata),
+ },
+ privateKey,
+ );
+}
+
+async function postProfile(base: string, event: NostrEvent) {
+ const response = await fetch(`${base}/api/sellers/profile`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ event }),
+ });
+ const body = await response.json();
+
+ if (!response.ok) {
+ console.log(JSON.stringify(body, null, 2));
+ process.exit(1);
+ }
+
+ console.log(JSON.stringify(body, null, 2));
+}
+
async function postListing(base: string, event: NostrEvent, authorization?: string) {
const feeResponse = await fetch(`${base}/api/listing-fee/request`, {
method: "POST",
@@ -137,6 +178,25 @@ async function main() {
return;
}
+ if (command === "profile-sign") {
+ const profile = readJsonObject(positional[0], "Profile");
+ const event = signProfile(profile, readKey(typeof args.key === "string" ? args.key : undefined));
+ const out = typeof args.out === "string" ? args.out : `${basename(positional[0] ?? "profile.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 === "profile-post") {
+ const profile = readJsonObject(positional[0], "Profile");
+ const event = signProfile(profile, readKey(typeof args.key === "string" ? args.key : undefined));
+ await postProfile(
+ typeof args.base === "string" ? args.base.replace(/\/$/, "") : "http://localhost:3000",
+ event,
+ );
+ return;
+ }
+
if (command === "post") {
const listing = readListing(positional[0]);
const event = signListing(listing, readKey(typeof args.key === "string" ? args.key : undefined));
diff --git a/supabase/migrations/20260426000000_add_signed_seller_profiles.sql b/supabase/migrations/20260426000000_add_signed_seller_profiles.sql
new file mode 100644
index 0000000..b18c6f5
--- /dev/null
+++ b/supabase/migrations/20260426000000_add_signed_seller_profiles.sql
@@ -0,0 +1,23 @@
+alter table if exists public.sellers
+ add column if not exists profile_name text,
+ add column if not exists profile_display_name text,
+ add column if not exists profile_about text,
+ add column if not exists profile_picture_url text,
+ add column if not exists profile_website text,
+ add column if not exists profile_nip05 text,
+ add column if not exists profile_lud16 text,
+ add column if not exists profile_bot boolean,
+ add column if not exists profile_metadata jsonb not null default '{}'::jsonb,
+ add column if not exists profile_event_id text,
+ add column if not exists profile_event_created_at bigint;
+
+do $$
+begin
+ alter table public.sellers
+ add constraint sellers_profile_event_id_check
+ check (profile_event_id is null or profile_event_id ~ '^[0-9a-f]{64}$');
+exception
+ when duplicate_object then null;
+end $$;
+
+create index if not exists sellers_profile_event_id_idx on public.sellers(profile_event_id);