Skip to content
Merged
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
112 changes: 84 additions & 28 deletions app/api/classic-liq/trustline/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,31 @@ export async function POST(req: NextRequest) {

const parsed = TrustlinePostSchema.safeParse(rawBody)
if (!parsed.success) {
// phase-144 (Module #44): quarantine the malformed payload for operator
// review instead of dropping it silently. No-op when the flag is off.
const quarantined = await import("@/lib/x402-dead-letter")
.then((m) =>
m.quarantineInvoice({
source: "classic-liq/trustline:POST",
raw: rawBody,
reasons: parsed.error.issues,
}),
)
.catch((e) => {
logHorizonSubmitError("classic-liq/trustline dead-letter write", e)
return null
})
if (quarantined?.quarantined) {
return NextResponse.json(
{
error: "Malformed request quarantined for review.",
code: "QUARANTINED",
deadLetterId: quarantined.id,
details: parsed.error.flatten(),
},
{ status: 422 },
)
}
return NextResponse.json({ error: "signedXdr es requerido.", details: parsed.error.flatten() }, { status: 400 })
}

Expand All @@ -48,36 +73,47 @@ export async function POST(req: NextRequest) {
if (!isBatch && isPhase119Enabled() && parsed.data.cid) {
const cidStr = parsed.data.cid.trim()
const expected = parsed.data.expectedSha256 ?? null
// Validate CID format strictly
const { CidSchema, verifyBytesIntegrity, sha256Hex, getCachedCid } = await import("@/lib/cid-cache")
const cidCheck = CidSchema.safeParse(cidStr)
if (!cidCheck.success) {
return NextResponse.json({ error: `Invalid CID: ${cidStr.slice(0, 12)}…`, code: "CID_INVALID" }, { status: 400 })
}
// If cidPath provided, fetch and verify tampering
if (parsed.data.cidPath) {
try {
const { fetchWithCidCache } = await import("@/lib/cid-cache")
const fetched = await fetchWithCidCache(parsed.data.cidPath, { expectedSha256: expected })
if (!fetched.ok) {
return NextResponse.json({ error: fetched.error, code: fetched.code, cid: cidStr }, { status: 409 })
}
// verified — continue to trustline submission
} catch (e) {
return NextResponse.json({ error: e instanceof Error ? e.message : String(e), code: "CID_VERIFY_FAILED" }, { status: 409 })
try {
// Validate CID format strictly
const { CidSchema, verifyBytesIntegrity, sha256Hex, getCachedCid } = await import("@/lib/cid-cache")
const cidCheck = CidSchema.safeParse(cidStr)
if (!cidCheck.success) {
return NextResponse.json({ error: `Invalid CID: ${cidStr.slice(0, 12)}…`, code: "CID_INVALID" }, { status: 400 })
}
} else if (expected) {
// CID + expected hash supplied without bytes: check cache integrity
try {
const cached = await getCachedCid(cidStr, { expectedSha256: expected })
if (cached && !verifyBytesIntegrity(cached.bytes, expected)) {
return NextResponse.json({ error: `Cached CID ${cidStr.slice(0, 8)}… fails integrity check`, code: "HASH_MISMATCH" }, { status: 409 })
// If cidPath provided, fetch and verify tampering
if (parsed.data.cidPath) {
try {
const { fetchWithCidCache } = await import("@/lib/cid-cache")
const fetched = await fetchWithCidCache(parsed.data.cidPath, { expectedSha256: expected })
if (!fetched.ok) {
return NextResponse.json({ error: fetched.error, code: fetched.code, cid: cidStr }, { status: 409 })
}
// verified — continue to trustline submission
} catch (e) {
return NextResponse.json({ error: e instanceof Error ? e.message : String(e), code: "CID_VERIFY_FAILED" }, { status: 409 })
}
} else if (expected) {
// CID + expected hash supplied without bytes: check cache integrity
try {
const cached = await getCachedCid(cidStr, { expectedSha256: expected })
if (cached && !verifyBytesIntegrity(cached.bytes, expected)) {
return NextResponse.json({ error: `Cached CID ${cidStr.slice(0, 8)}… fails integrity check`, code: "HASH_MISMATCH" }, { status: 409 })
}
// if not cached, we allow submission but warn via header later
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
return NextResponse.json({ error: msg, code: "CID_TAMPERED" }, { status: 409 })
}
// if not cached, we allow submission but warn via header later
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
return NextResponse.json({ error: msg, code: "CID_TAMPERED" }, { status: 409 })
}
} catch (e) {
logHorizonSubmitError("classic-liq/trustline CID boundary", e)
await import("@/lib/x402-dead-letter")
.then((m) => m.quarantineInvoice({ source: "classic-liq/trustline:cid", raw: rawBody }))
.catch(() => {})
return NextResponse.json(
{ error: "CID verification failed unexpectedly.", code: "CID_BOUNDARY_ERROR" },
{ status: 409 },
)
}
}

Expand Down Expand Up @@ -111,7 +147,27 @@ export async function POST(req: NextRequest) {
}

// GET exposes cache stats when flag enabled (observability, zero regression when off)
export async function GET() {
export async function GET(req: NextRequest) {
// phase-144 (Module #44): dead-letter review queue for operators.
if (new URL(req.url).searchParams.get("view") === "dead-letter") {
const { isX402DeadLetterEnabled, listDeadLetterQueue, getDeadLetterStats } = await import(
"@/lib/x402-dead-letter"
)
if (!isX402DeadLetterEnabled()) {
return NextResponse.json({ enabled: false, error: "phase-144 flag disabled" }, { status: 404 })
}
const statusParam = new URL(req.url).searchParams.get("status")
const status =
statusParam === "open" || statusParam === "resolved" || statusParam === "discarded"
? statusParam
: undefined
const [queue, stats] = await Promise.all([
listDeadLetterQueue({ status, limit: 100 }),
getDeadLetterStats(),
])
return NextResponse.json({ enabled: true, stats, queue })
}

if (!isPhase119Enabled()) {
return NextResponse.json({ enabled: false, error: "phase-119 flag disabled" }, { status: 404 })
}
Expand Down
31 changes: 28 additions & 3 deletions app/api/og/chamber/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
safeDisplayName,
withOgErrorBoundary,
} from "@/lib/og-render-utils";
import { isSybilResistanceEnabled } from "@/lib/sybil-resistance";

export const runtime = "nodejs";

Expand Down Expand Up @@ -408,6 +409,30 @@ export async function GET(request: NextRequest) {
return resp;
};

// phase-145 (Module #45): attach a sybil-resistance signal when a wallet is
// supplied (e.g. the profile that owns/shares this chamber). Header-only,
// best-effort, does not change pixels.
const walletParam = searchParams.get("wallet")?.trim() ?? "";
const finalize = async (resp: NextResponse): Promise<NextResponse> => {
const piped = await maybePin(resp);
if (isSybilResistanceEnabled()) {
piped.headers.set("X-Phase145", "enabled");
if (/^G[A-Z2-7]{55}$/.test(walletParam)) {
try {
const { assessWalletSybilRisk } = await import("@/lib/sybil-resistance");
const assessment = await assessWalletSybilRisk(walletParam);
if (assessment) {
piped.headers.set("X-Phase-Sybil-Band", assessment.band);
piped.headers.set("X-Phase-Sybil-Score", String(assessment.score));
}
} catch {
// best-effort
}
}
}
return piped;
};

// Token-level OG (individual NFT)
const rawToken = searchParams.get("token_id") ?? searchParams.get("token");
const tokenId = rawToken ? parseInt(rawToken, 10) : NaN;
Expand All @@ -421,7 +446,7 @@ export async function GET(request: NextRequest) {
headers: { "Content-Type": "text/plain" },
});
}
return maybePin(pngResponse(result.value));
return finalize(pngResponse(result.value));
}

// Collection-level OG — monitor frame with best-effort token metadata
Expand All @@ -440,7 +465,7 @@ export async function GET(request: NextRequest) {
)
.png()
.toBuffer();
return maybePin(pngResponse(pngBuffer));
return finalize(pngResponse(pngBuffer));
}

const result = await withOgErrorBoundary(() =>
Expand All @@ -452,5 +477,5 @@ export async function GET(request: NextRequest) {
headers: { "Content-Type": "text/plain" },
});
}
return maybePin(pngResponse(result.value));
return finalize(pngResponse(result.value));
}
24 changes: 24 additions & 0 deletions app/api/og/profile/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import path from "node:path";
import sharp from "sharp";
import { z } from "zod";
import { getProfile } from "@/lib/profile-store";
import {
isSybilResistanceEnabled,
assessWalletSybilRisk,
} from "@/lib/sybil-resistance";
import {
getOgTheme,
resolvePinIntent,
Expand Down Expand Up @@ -313,11 +317,31 @@ export async function GET(request: NextRequest) {
const templatePath = resolveOgTemplatePath();
const usedTemplate = templateName(templatePath);

// phase-145 (Module #45): sybil-resistance signal for the resolved wallet.
// Observability only — does not change pixels. Best-effort; never throws.
let sybilBand: string | null = null;
let sybilScore: number | null = null;
if (wallet.length >= 10 && isSybilResistanceEnabled()) {
try {
const assessment = await assessWalletSybilRisk(wallet);
if (assessment) {
sybilBand = assessment.band;
sybilScore = assessment.score;
}
} catch {
// best-effort — leave headers unset
}
}

const headers: Record<string, string> = {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=300, s-maxage=300",
"X-Phase-Og-Template": usedTemplate,
...(isPhase120Enabled() ? { "X-Phase120": "enabled" } : {}),
...(isSybilResistanceEnabled() ? { "X-Phase145": "enabled" } : {}),
...(sybilBand
? { "X-Phase-Sybil-Band": sybilBand, "X-Phase-Sybil-Score": String(sybilScore) }
: {}),
};
if (pinMeta?.pinned) {
headers["X-Phase-Pin-URI"] = pinMeta.uri;
Expand Down
37 changes: 37 additions & 0 deletions app/api/profile/avatar/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,42 @@ const AvatarQuerySchema = z.object({

export async function GET(request: NextRequest) {
const api = createApiRequestContext(request, "/api/profile/avatar")

// phase-157 (Module #57): batch avatar fetch for a virtualized grid window.
// `?wallets=G...,G...` returns many avatars in one round-trip so a 10k-token
// grid does not fan out thousands of requests. No-op unless the flag is on.
const rawWallets = request.nextUrl.searchParams.get("wallets")
if (rawWallets) {
if (!isNftGridVirtualizationEnabled()) {
return api.json(
{ error: "Batch avatar fetch disabled (phase-157 flag off)" },
{ status: 404, event: "profile.avatar.batch_disabled" },
)
}
const parsedBatch = BatchAvatarQuerySchema.safeParse({
wallets: rawWallets.split(",").map((w) => w.trim()).filter(Boolean),
})
if (!parsedBatch.success) {
return api.json(
{ error: "Invalid wallets list", details: parsedBatch.error.flatten() },
{ status: 400, event: "profile.avatar.batch_validation_failed" },
)
}
try {
const avatars = await getAvatarsForWallets(parsedBatch.data.wallets)
return api.json(
{ avatars },
{
event: "profile.avatar.batch_loaded",
metadata: { count: avatars.length },
headers: { "Cache-Control": "private, max-age=30", "X-Phase157": "enabled" },
},
)
} catch (error) {
return api.errorJson(error, 500, "profile.avatar.batch_failed")
}
}

const rawWallet = request.nextUrl.searchParams.get("wallet")?.trim() ?? ""
const parsedQ = AvatarQuerySchema.safeParse({ wallet: rawWallet })
const wallet = parsedQ.success ? parsedQ.data.wallet : rawWallet
Expand Down Expand Up @@ -97,6 +133,7 @@ export async function GET(request: NextRequest) {
"Cache-Control": "private, max-age=30",
"X-Phase-Locale": preferredLocale,
...(isProfilePinningRedundancyEnabled() ? { "X-Phase117": "enabled", ...(gatewayMeta ? { "X-Phase-Gateway": gatewayMeta } : {}) } : {}),
...(isNftGridVirtualizationEnabled() ? { "X-Phase157": "enabled" } : {}),
},
},
)
Expand Down
21 changes: 21 additions & 0 deletions app/api/signals/[id]/replies/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,27 @@ export async function POST(
)
}

// phase-156 (Module #56): reject posts from wallets on the governed deny-list.
// No-op when the flag is off. Wrapped so a store read failure never 500s the
// reply path.
if (isFaucetDenyListEnabled()) {
try {
if (await isWalletDenied(body.wallet)) {
const entry = await getWalletDenyEntry(body.wallet).catch(() => null)
return api.json(
{
error: "This wallet is excluded from posting.",
code: "WALLET_DENIED",
...(entry ? { reason: entry.reason, entryId: entry.id } : {}),
},
{ status: 403, event: "signals.reply.wallet_denied", metadata: { wallet: body.wallet } },
)
}
} catch (e) {
api.log("warn", "signals.reply.deny_check_failed", { error: e instanceof Error ? e.message : String(e) })
}
}

// phase-116: validate attribution if provided (optional, additive)
let attributionParsed: z.infer<typeof ContributorsArraySchema> | undefined
if (isPhase116Enabled() && (body.attribution != null || body.contributors != null)) {
Expand Down
17 changes: 17 additions & 0 deletions app/signals/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,17 @@ export default async function SignalDetailPage({ params }: Props) {
}
}

// phase-156 (Module #56): flag whether this signal's author is on the governed
// deny-list, so the detail view can show it. Best-effort; false when flag off.
let authorRestricted = false
if (isFaucetDenyListEnabled()) {
try {
authorRestricted = await isWalletDenied(signal.author_wallet)
} catch {
// best-effort
}
}

return (
<div className="min-h-screen" style={{ fontFamily: "var(--font-mono)" }}>
<div className="mx-auto max-w-2xl px-4 py-16">
Expand Down Expand Up @@ -110,6 +121,12 @@ export default async function SignalDetailPage({ params }: Props) {
{signal.title}
</h1>

{authorRestricted && (
<p className="font-mono text-[9px] uppercase tracking-widest text-amber-600 border border-amber-600/40 px-1.5 py-0.5 self-start">
⚠ AUTHOR ON DENY-LIST
</p>
)}

{signal.nft_token_id !== undefined && (
<div className="flex items-center gap-3 border border-[var(--color-border-tertiary)] p-2">
{nftImageSrc && (
Expand Down
10 changes: 9 additions & 1 deletion components/trustline-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -211,9 +211,17 @@ export function TrustlineButton({ address, onRequestConnect, onReady, className,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ signedXdr }),
})
const payload = (await submitRes.json().catch(() => ({}))) as { error?: string; detail?: string; code?: string }
const payload = (await submitRes.json().catch(() => ({}))) as { error?: string; detail?: string; code?: string; deadLetterId?: string }
if (!submitRes.ok) {
// Mensajes de error más específicos basados en el código
if (payload.code === "QUARANTINED") {
// phase-144 (Module #44): malformed payload was filed in the review queue
throw new Error(
lang === "es"
? `Solicitud con formato inválido: se archivó en la cola de revisión${payload.deadLetterId ? ` (ref ${payload.deadLetterId.slice(0, 8)})` : ""}. Un operador la revisará.`
: `Malformed request: filed in the review queue${payload.deadLetterId ? ` (ref ${payload.deadLetterId.slice(0, 8)})` : ""} for an operator to inspect.`,
)
}
if (payload.code === "ACCOUNT_NOT_FOUND" || payload.error?.includes("not found")) {
throw new Error(
`Cuenta no encontrada\n\n` +
Expand Down
Loading