diff --git a/IMPLEMENTATION_SUMMARY_64_67.md b/IMPLEMENTATION_SUMMARY_64_67.md new file mode 100644 index 00000000..23aaa8ea --- /dev/null +++ b/IMPLEMENTATION_SUMMARY_64_67.md @@ -0,0 +1,134 @@ +# Implementation Summary: Issues #64, #65, #66, #67 + +All four issues follow the repo's established pattern: an **isolated, flag-gated +domain module** appended to a `lib/*-store.ts`, wired additively into its API +route and UI, with a `node:test` unit suite. Every flag defaults **off** → zero +behavioural change until explicitly enabled. + +New feature flags (`lib/feature-flags.ts`): `phase-136`, `phase-137`, `phase-138`. + +Run the new suites: + +``` +node --test --import ./node_modules/tsx/dist/loader.mjs \ + lib/__tests__/cid-gateway-cache.test.ts \ + lib/__tests__/profile-error-taxonomy.test.ts \ + lib/__tests__/cost-attribution-ledger.test.ts +``` + +--- + +## Issue #64 — CID gateway resolution cache with TTL + health scoring (phase-136) + +**Problem:** every metadata read re-resolved a CID against the gateway list from +scratch, and a degrading gateway kept being picked until it hard-failed. + +**Module:** `lib/signal-store.ts` +- `resolveCidGateway(cid, opts)` — memoizes the CID→gateway URL per CID with a + TTL (default 5 min, bounded 1 s–24 h). Flag off ⇒ deterministic first-gateway + pick, no caching. +- `recordCidGatewayOutcome({ gateway, ok, latencyMs })` — feeds a rolling health + model (success ratio 70 % + EWMA latency 30 %); a recorded failure evicts every + cache entry pinned to that gateway. +- `scoreGateway`, `getCidGatewayCacheStats`, `extractIpfsCidPath`, + `__resetCidGatewayCacheForTests`. +- Typed errors: `CidResolutionError` (`FLAG_DISABLED` / `VALIDATION_FAILED` / + `NO_GATEWAY`); zod `CidResolutionRequestSchema`, `GatewayOutcomeSchema`. + +**Wiring:** +- `app/api/signals/[id]/replies/route.ts` — POST + GET responses now carry + `signalMedia: { image, gateway }` (resolved, cached) and an + `X-Phase136-Gateway` header when the signal has an IPFS `nft_image`. +- `app/signals/[id]/page.tsx` — renders the resolved gateway URL for the signal's + NFT image, falling back to the stored URL on any failure. + +**Tests:** `lib/__tests__/cid-gateway-cache.test.ts` (9 cases) — flag-off +passthrough, TTL hit/expiry, health ranking, failure-driven eviction, cold-cache +best-gateway pick, malformed-input handling, `extractIpfsCidPath`. + +--- + +## Issues #65 & #66 — Structured error taxonomy for avatar / x402 failures (phase-137) + +Issues #65 and #66 are duplicates (same title, same file set); one coherent +implementation resolves both — the server taxonomy (#65) and the client +retry-policy wiring (#66). + +**Problem:** avatar / gateway / invoice failures all surfaced as a single generic +`500 Internal server error`, so a Pinata timeout, a checksum mismatch and a bad +wallet were indistinguishable in logs and the client couldn't tell a retryable +blip from a permanent failure. + +**Module:** `lib/profile-store.ts` +- `ProfileError` — carries `code`, deterministic `status`, `category` + (`client` / `upstream` / `integrity` / `config` / `internal`) and `retryable`. +- Closed taxonomy `PROFILE_ERROR_CODES` (13 codes) with a status/category/retry + table. +- `classifyProfileError(err)` — maps AbortError/timeouts → `GATEWAY_TIMEOUT` + (504, retryable), DNS/conn failures → `GATEWAY_UNREACHABLE`, checksum/tamper → + `CHECKSUM_MISMATCH`, `{ status }` upstream errors → 4xx/5xx/429, `ZodError` → + `MALFORMED_RESPONSE`, everything else → `INTERNAL`. +- `toProfileErrorResponse(err)` → `{ body, status }`; zod + `ProfileErrorResponseSchema`. + +**Wiring:** +- `app/api/profile/avatar/route.ts` — GET/POST catch blocks and the POST + fetch/pin failure branches emit the structured body (`{ error, code, category, + retryable }`) with the taxonomy's status when the flag is on; legacy generic + 500 otherwise. Invalid-wallet GET returns `INVALID_WALLET` (400). +- `components/wallet-avatar.tsx` — reads `retryable` from the response and does + **one** silent retry on a retryable upstream code before settling on initials; + surfaces the final `code` via `data-avatar-error` / `title` for observability. + Fetch is now abortable. + +**Tests:** `lib/__tests__/profile-error-taxonomy.test.ts` (9 cases) — pass-through, +timeout/DNS/checksum/status/Zod mapping, unknown→INTERNAL, schema-valid response +bodies, full-taxonomy coverage. + +--- + +## Issue #67 — Cost attribution ledger per request (phase-138) + +**Problem:** infra spend (Horizon fan-out on the follow-suggestions path, +notification writes, profile enrichment) was never attributed to the request that +caused it, so the treasury couldn't reconcile spend against revenue. + +**Module:** `lib/follow-store.ts` +- `recordRequestCost({ requestId, operation, count?, units?, source?, wallet? })` — + appends a cost line to a bounded (5 000-entry) in-memory ledger; flag off ⇒ + no-op returning `0`. `BILLABLE_OPERATIONS` taxonomy with default unit weights + (`OPERATION_UNIT_COST`). +- `getRequestCost(requestId)`, `getCostLedger({ operation?, sinceMs?, limit? })`, + `summarizeCostByOperation()` (treasury view), `__resetCostLedgerForTests`. +- Typed `CostAttributionError` (`VALIDATION_FAILED`); zod + `CostAttributionInputSchema`. + +**Wiring:** +- `app/api/profile/follow/route.ts` + - **Bug fix:** the file used `isFeatureEnabled`, `FollowSuggestionQuerySchema` + and `getFollowSuggestions` without importing them — the suggestions endpoint + would throw `ReferenceError` at runtime. Imports added (net −22 project type + errors). + - Suggestions path books `follow.suggestions` + `horizon.*` + per-profile + `profile.enrichment` cost and returns `costUnits` + `X-Phase138-Cost-Units`. + - Follow/unfollow POST books `follow.write` (+ `notification.create`) and + returns `costUnits`. +- `app/profile/[wallet]/follow-button.tsx` — `FollowSuggestions` surfaces the + request's cost as `STELLAR GRAPH · u`. + +**Tests:** `lib/__tests__/cost-attribution-ledger.test.ts` (7 cases) — flag-off +no-op, unit weighting, count scaling + explicit override, treasury aggregation, +operation filter, malformed-input error, per-request isolation. + +--- + +## Verification + +- `npx tsc --noEmit`: **65 → 43** pre-existing errors (net −22; the follow-route + missing imports). No new type errors in any changed file. +- Full test suite: **192 → 217 passing**, `+25` new, **0 regressions** (the 6 + pre-existing failures — `narrative-search`, `forge-pipeline`, + `watchlist-price-drops` — are unchanged and unrelated). +- `eslint`: no new errors or warnings in changed files. +- All three flags default off ⇒ every route/UI path is byte-identical to `main` + until `NEXT_PUBLIC_FEATURE_PHASE_136/137/138` (or `FEATURE_PHASE_*`) is set. diff --git a/app/api/profile/avatar/route.ts b/app/api/profile/avatar/route.ts index e46a02c6..2698f3b9 100644 --- a/app/api/profile/avatar/route.ts +++ b/app/api/profile/avatar/route.ts @@ -1,9 +1,23 @@ import { NextRequest } from "next/server" -import { DEFAULT_PROFILE_LOCALE, getProfile, isProfilePinningRedundancyEnabled, localizeAvatarName, normalizeProfileLocale, resolveAvatarWithFallback } from "@/lib/profile-store" +import { DEFAULT_PROFILE_LOCALE, getProfile, isProfilePinningRedundancyEnabled, isPhase137Enabled, localizeAvatarName, normalizeProfileLocale, ProfileError, resolveAvatarWithFallback, toProfileErrorResponse } from "@/lib/profile-store" import { StrKey } from "@stellar/stellar-sdk" import { createApiRequestContext } from "@/lib/api-observability" import { z } from "zod" +/** phase-137: emit a structured error body when the taxonomy flag is on, else the legacy generic 500. */ +function respondWithProfileError( + api: ReturnType, + error: unknown, + event: string, + legacyStatus = 500, +) { + if (isPhase137Enabled()) { + const { body, status } = toProfileErrorResponse(error) + return api.json(body, { status, event, metadata: { code: body.code, category: body.category, retryable: body.retryable } }) + } + return api.errorJson(error, legacyStatus, event) +} + export const dynamic = "force-dynamic" // ??? phase-117: multi-gateway redundancy ???????????????????????????????????? @@ -21,6 +35,13 @@ export async function GET(request: NextRequest) { const wallet = parsedQ.success ? parsedQ.data.wallet : rawWallet if (!wallet || !StrKey.isValidEd25519PublicKey(wallet)) { + if (isPhase137Enabled()) { + const err = new ProfileError("INVALID_WALLET", "Wallet address is not a valid ed25519 public key") + return api.json( + { avatar: null, ...err.toResponse() }, + { status: err.status, event: "profile.avatar.validation_failed", metadata: { reason: "wallet", code: err.code } }, + ) + } return api.json( { avatar: null }, { status: 400, event: "profile.avatar.validation_failed", metadata: { reason: "wallet" } }, @@ -80,7 +101,7 @@ export async function GET(request: NextRequest) { }, ) } catch (error) { - return api.errorJson(error, 500, "profile.avatar.load_failed") + return respondWithProfileError(api, error, "profile.avatar.load_failed") } } @@ -109,13 +130,23 @@ export async function POST(request: NextRequest) { try { // fetch image bytes server-side (with timeout) const imgRes = await fetch(imageUrl, { signal: AbortSignal.timeout(8000) }) - if (!imgRes.ok) return api.json({ error: `Failed to fetch image (${imgRes.status})` }, { status: 502, event: "profile.avatar.fetch_failed" }) + if (!imgRes.ok) { + if (isPhase137Enabled()) { + const err = new ProfileError(imgRes.status >= 500 ? "GATEWAY_5XX" : "GATEWAY_4XX", `Failed to fetch image (${imgRes.status})`, { status: imgRes.status }) + return api.json(err.toResponse(), { status: err.status, event: "profile.avatar.fetch_failed", metadata: { code: err.code } }) + } + return api.json({ error: `Failed to fetch image (${imgRes.status})` }, { status: 502, event: "profile.avatar.fetch_failed" }) + } const ab = await imgRes.arrayBuffer() const blob = new Blob([ab], { type: imgRes.headers.get("content-type") ?? "image/png" }) const { pinAvatarWithRedundancy } = await import("@/lib/profile-store") const result = await pinAvatarWithRedundancy(blob, { quorum, fileName: `avatar-${wallet.slice(0, 6)}.png` }) if (!result.ok) { + if (isPhase137Enabled()) { + const err = new ProfileError(result.code === "NOT_CONFIGURED" ? "NOT_CONFIGURED" : "PIN_QUORUM_FAILED", result.error, { pinCode: result.code, quorum: result.quorum, achieved: result.achieved }) + return api.json(err.toResponse(), { status: err.status, event: "profile.avatar.pin_failed", metadata: { code: err.code } }) + } return api.json({ error: result.error, code: result.code, quorum: result.quorum, achieved: result.achieved }, { status: 502, event: "profile.avatar.pin_failed" }) } // Persist new avatar_image_url as verified gateway URL @@ -129,6 +160,6 @@ export async function POST(request: NextRequest) { { event: "profile.avatar.pinned", metadata: { wallet, cid: result.cid } }, ) } catch (error) { - return api.errorJson(error, 500, "profile.avatar.pin_failed") + return respondWithProfileError(api, error, "profile.avatar.pin_failed") } } \ No newline at end of file diff --git a/app/api/profile/follow/route.ts b/app/api/profile/follow/route.ts index bfda08b9..5c559218 100644 --- a/app/api/profile/follow/route.ts +++ b/app/api/profile/follow/route.ts @@ -1,20 +1,36 @@ import { NextRequest, NextResponse } from "next/server"; +import { randomUUID } from "node:crypto"; import { StrKey } from "@stellar/stellar-sdk"; import { followUser, unfollowUser, getFollowCounts, + getFollowSuggestions, isFollowing, isPhase118Enabled, + isPhase138Enabled, + recordRequestCost, + getRequestCost, + FollowSuggestionQuerySchema, validateSep50MetadataBeforePin, } from "@/lib/follow-store"; import { createNotification } from "@/lib/notification-store"; import { getProfile } from "@/lib/profile-store"; import { checkAndUnlock } from "@/lib/achievement-store"; +import { isFeatureEnabled } from "@/lib/feature-flags"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; +/** phase-138: stable id to attribute a request's infra cost against. */ +function requestCostId(request: NextRequest): string { + return ( + request.headers.get("x-correlation-id")?.trim() || + request.headers.get("x-request-id")?.trim() || + randomUUID() + ).slice(0, 128); +} + export async function GET(request: NextRequest) { const wallet = request.nextUrl.searchParams.get("wallet")?.trim() ?? ""; if (!wallet || !StrKey.isValidEd25519PublicKey(wallet)) { @@ -64,6 +80,27 @@ export async function GET(request: NextRequest) { return { ...suggestion, displayName: profile?.display_name }; }), ); + + // phase-138: attribute the Horizon fan-out + profile enrichment cost to this request + if (isPhase138Enabled()) { + const costId = requestCostId(request); + recordRequestCost({ requestId: costId, operation: "follow.suggestions", source: "api" }); + recordRequestCost({ requestId: costId, operation: "horizon.account_lookup", source: "horizon" }); + recordRequestCost({ requestId: costId, operation: "horizon.asset_holders", source: "horizon" }); + if (enriched.length > 0) { + recordRequestCost({ + requestId: costId, + operation: "profile.enrichment", + count: enriched.length, + source: "store", + }); + } + const costUnits = getRequestCost(costId).totalUnits; + return NextResponse.json( + { suggestions: enriched, costUnits }, + { headers: { "X-Phase138-Cost-Units": String(costUnits) } }, + ); + } return NextResponse.json({ suggestions: enriched }); } const viewer = request.nextUrl.searchParams.get("viewer")?.trim() ?? ""; @@ -125,8 +162,14 @@ export async function POST(request: NextRequest) { } } + const costId = requestCostId(request); + if (body.action === "follow") { await followUser(body.from, body.to); + if (isPhase138Enabled()) { + recordRequestCost({ requestId: costId, operation: "follow.write", source: "api", wallet: body.from }); + recordRequestCost({ requestId: costId, operation: "notification.create", source: "api" }); + } // Fire-and-forget: notify the followed user void (async () => { try { @@ -144,6 +187,9 @@ export async function POST(request: NextRequest) { })(); } else { await unfollowUser(body.from, body.to); + if (isPhase138Enabled()) { + recordRequestCost({ requestId: costId, operation: "follow.write", source: "api", wallet: body.from }); + } } const counts = await getFollowCounts(body.to); @@ -151,5 +197,6 @@ export async function POST(request: NextRequest) { ok: true, ...counts, metadata_validation_enabled: isPhase118Enabled(), + ...(isPhase138Enabled() ? { costUnits: getRequestCost(costId).totalUnits } : {}), }); } diff --git a/app/api/signals/[id]/replies/route.ts b/app/api/signals/[id]/replies/route.ts index 69041d9d..820fa83e 100644 --- a/app/api/signals/[id]/replies/route.ts +++ b/app/api/signals/[id]/replies/route.ts @@ -1,6 +1,6 @@ import { NextRequest } from "next/server" import { StrKey } from "@stellar/stellar-sdk" -import { getSignal, createReply, AttributionInReplySchema, recordReplyAttribution, getSignalContributors, computeCreditLedger } from "@/lib/signal-store" +import { getSignal, createReply, AttributionInReplySchema, recordReplyAttribution, getSignalContributors, computeCreditLedger, isPhase136Enabled, resolveCidGateway, extractIpfsCidPath } from "@/lib/signal-store" import { createNotification } from "@/lib/notification-store" import { dispatchPushNotification, extractMentionedWallets, isPhase92Enabled } from "@/lib/push-notifications" import { createApiRequestContext } from "@/lib/api-observability" @@ -14,6 +14,19 @@ function isPhase116Enabled(): boolean { return isFeatureEnabled("phase-116") } +/** phase-136: resolve a signal's NFT image CID through the cached gateway picker. */ +function resolveSignalImage(nftImage: string | undefined): { image: string; gateway: string } | null { + if (!isPhase136Enabled()) return null + const cidPath = extractIpfsCidPath(nftImage) + if (!cidPath) return null + try { + const resolved = resolveCidGateway(cidPath) + return { image: resolved.url, gateway: resolved.gateway } + } catch { + return null + } +} + type ReplyBody = { body?: unknown wallet?: unknown @@ -195,16 +208,22 @@ export async function POST( } } + const resolvedImage = resolveSignalImage(signal.nft_image) + return api.json( { reply, ...(isPhase116Enabled() ? { contributors: contributors?.contributors ?? [], creditLedger: creditLedger ?? [] } : {}), + ...(resolvedImage ? { signalMedia: resolvedImage } : {}), }, { status: 201, event: "signals.reply.created", - metadata: { signal_id: id, reply_id: reply.id, phase116: isPhase116Enabled(), attribCount: attributionParsed?.length ?? 0 }, - headers: isPhase116Enabled() ? { "X-Phase116": "enabled" } : {}, + metadata: { signal_id: id, reply_id: reply.id, phase116: isPhase116Enabled(), phase136: isPhase136Enabled(), attribCount: attributionParsed?.length ?? 0 }, + headers: { + ...(isPhase116Enabled() ? { "X-Phase116": "enabled" } : {}), + ...(resolvedImage ? { "X-Phase136-Gateway": resolvedImage.gateway } : {}), + }, }, ) } catch (error) { @@ -227,9 +246,16 @@ export async function GET( if (!signal) return api.json({ error: "Signal not found" }, { status: 404, event: "signals.ledger.signal_missing" }) const contributors = await getSignalContributors(id) const creditLedger = await computeCreditLedger(id) + const resolvedImage = resolveSignalImage(signal.nft_image) return api.json( - { signalId: id, contributors: contributors?.contributors ?? [], totalShareBps: contributors?.totalShareBps ?? 0, creditLedger }, - { event: "signals.ledger.loaded", metadata: { signal_id: id } }, + { + signalId: id, + contributors: contributors?.contributors ?? [], + totalShareBps: contributors?.totalShareBps ?? 0, + creditLedger, + ...(resolvedImage ? { signalMedia: resolvedImage } : {}), + }, + { event: "signals.ledger.loaded", metadata: { signal_id: id, phase136: isPhase136Enabled() } }, ) } catch (error) { return api.errorJson(error, 500, "signals.ledger.load_failed") diff --git a/app/profile/[wallet]/follow-button.tsx b/app/profile/[wallet]/follow-button.tsx index 64376066..83aa1eaa 100644 --- a/app/profile/[wallet]/follow-button.tsx +++ b/app/profile/[wallet]/follow-button.tsx @@ -18,6 +18,8 @@ export function FollowSuggestions({ const [suggestions, setSuggestions] = useState([]); const [searchQuery, setSearchQuery] = useState(""); const [isSearching, setIsSearching] = useState(false); + // phase-138: per-request infra cost surfaced for treasury visibility + const [costUnits, setCostUnits] = useState(null); useEffect(() => { if (!address || address !== profileWallet) return; @@ -33,13 +35,13 @@ export function FollowSuggestions({ signal: controller.signal, }, ) - .then(async (response) => - response.ok - ? (response.json() as Promise<{ suggestions?: FollowSuggestion[] }>) - : { suggestions: [] }, - ) + .then(async (response): Promise<{ + suggestions?: FollowSuggestion[]; + costUnits?: number; + }> => (response.ok ? response.json() : { suggestions: [] })) .then((data) => { setSuggestions(data.suggestions ?? []); + setCostUnits(typeof data.costUnits === "number" ? data.costUnits : null); setIsSearching(false); }) .catch(() => { @@ -59,7 +61,9 @@ export function FollowSuggestions({ > PEOPLE_TO_FOLLOW - STELLAR GRAPH + + {costUnits !== null ? `STELLAR GRAPH · ${costUnits}u` : "STELLAR GRAPH"} + {/* Typo-tolerant search input */} diff --git a/app/signals/[id]/page.tsx b/app/signals/[id]/page.tsx index 93d4ee9a..363c123d 100644 --- a/app/signals/[id]/page.tsx +++ b/app/signals/[id]/page.tsx @@ -1,7 +1,7 @@ import { notFound } from "next/navigation" import Link from "next/link" import { WalletAvatar } from "@/components/wallet-avatar" -import { getSignal, getReplies, getSignalContributors, computeCreditLedger, isPhase116Enabled } from "@/lib/signal-store" +import { getSignal, getReplies, getSignalContributors, computeCreditLedger, isPhase116Enabled, isPhase136Enabled, resolveCidGateway, extractIpfsCidPath } from "@/lib/signal-store" import { SignalDetailClient } from "./signal-detail-client" export const dynamic = "force-dynamic" @@ -38,6 +38,17 @@ export default async function SignalDetailPage({ params }: Props) { const replies = await getReplies(id) const shortWallet = `${signal.author_wallet.slice(0, 4)}…${signal.author_wallet.slice(-4)}` + // phase-136: resolve the signal's NFT image CID through the cached gateway picker + let nftImageSrc = signal.nft_image + const nftCidPath = extractIpfsCidPath(signal.nft_image) + if (isPhase136Enabled() && nftCidPath) { + try { + nftImageSrc = resolveCidGateway(nftCidPath).url + } catch { + // fall back to the stored URL (zero regression) + } + } + // phase-116: load contributor ledger when flag enabled (preserves signal detail wiring) const phase116 = isPhase116Enabled() let contributors: Awaited> = null @@ -101,9 +112,9 @@ export default async function SignalDetailPage({ params }: Props) { {signal.nft_token_id !== undefined && (
- {signal.nft_image && ( + {nftImageSrc && ( // eslint-disable-next-line @next/next/no-img-element - {signal.nft_name} + {signal.nft_name} )}
{signal.nft_name} diff --git a/components/wallet-avatar.tsx b/components/wallet-avatar.tsx index 509d3d54..10d88cdf 100644 --- a/components/wallet-avatar.tsx +++ b/components/wallet-avatar.tsx @@ -37,6 +37,23 @@ type AvatarData = { locale?: string } +// phase-137: structured error taxonomy — the avatar route may answer with +// { code, category, retryable } instead of a bare { avatar: null }. A retryable +// code (upstream timeout / unreachable gateway) earns one silent retry before +// the component settles on initials. +type AvatarErrorEnvelope = { + avatar: AvatarData | null + code?: string + category?: string + retryable?: boolean +} + +async function fetchAvatarOnce(wallet: string, signal: AbortSignal): Promise { + const r = await fetch(`/api/profile/avatar?wallet=${encodeURIComponent(wallet)}`, { signal }) + const data = (await r.json().catch(() => ({ avatar: null }))) as AvatarErrorEnvelope + return data +} + function getInitials(wallet: string, displayName?: string): string { if (displayName?.trim()) { const words = displayName.trim().split(/\s+/) @@ -64,6 +81,7 @@ export function WalletAvatar({ const [visible, setVisible] = useState(false) const [fallbackIndex, setFallbackIndex] = useState(0) const [fallbackUrls, setFallbackUrls] = useState([]) + const [errorCode, setErrorCode] = useState(null) const ref = useRef(null) // IntersectionObserver for lazy loading @@ -88,29 +106,40 @@ export function WalletAvatar({ useEffect(() => { if (!visible || !wallet) return + const controller = new AbortController() let aborted = false setLoading(true) setFallbackIndex(0) setFallbackUrls([]) - - fetch(`/api/profile/avatar?wallet=${encodeURIComponent(wallet)}`) - .then((r) => r.json() as Promise<{ avatar: AvatarData | null }>) - .then((data) => { - if (!aborted) { - setAvatar(data.avatar) - if (data.avatar?.image) { - setFallbackUrls(buildFallbackUrls(data.avatar.image)) - } + setErrorCode(null) + + ;(async () => { + try { + let data = await fetchAvatarOnce(wallet, controller.signal) + // phase-137: one silent retry when the taxonomy flags a retryable upstream blip + if (!data.avatar && data.retryable) { + setErrorCode(data.code ?? null) + await new Promise((resolve) => setTimeout(resolve, 400)) + if (aborted) return + data = await fetchAvatarOnce(wallet, controller.signal) + } + if (aborted) return + setAvatar(data.avatar) + setErrorCode(data.avatar ? null : data.code ?? null) + if (data.avatar?.image) { + setFallbackUrls(buildFallbackUrls(data.avatar.image)) } - }) - .catch(() => { + } catch { // Silently fail - will show initials - }) - .finally(() => { + } finally { if (!aborted) setLoading(false) - }) + } + })() - return () => { aborted = true } + return () => { + aborted = true + controller.abort() + } }, [visible, wallet]) const handleImgError = useCallback(() => { @@ -168,6 +197,8 @@ export function WalletAvatar({ background: "#534AB7", fontSize: `${fontSize}px`, }} + data-avatar-error={errorCode ?? undefined} + title={errorCode ? `Avatar unavailable (${errorCode})` : undefined} > {initials}
diff --git a/lib/__tests__/cid-gateway-cache.test.ts b/lib/__tests__/cid-gateway-cache.test.ts new file mode 100644 index 00000000..77b4a949 --- /dev/null +++ b/lib/__tests__/cid-gateway-cache.test.ts @@ -0,0 +1,89 @@ +import { describe, it, beforeEach } from "node:test" +import * as assert from "node:assert/strict" +import { + resolveCidGateway, + recordCidGatewayOutcome, + scoreGateway, + getCidGatewayCacheStats, + extractIpfsCidPath, + CidResolutionError, + __resetCidGatewayCacheForTests, +} from "@/lib/signal-store" + +const CID = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi" + +describe("phase-136 CID gateway resolution cache", () => { + beforeEach(() => { + __resetCidGatewayCacheForTests() + process.env.FEATURE_PHASE_136 = "1" + }) + + it("flag off: deterministic first-gateway pick, no caching", () => { + process.env.FEATURE_PHASE_136 = "" + const r = resolveCidGateway(CID) + assert.equal(r.fromCache, false) + assert.match(r.url, /^https:\/\/w3s\.link\/ipfs\//) + assert.equal(getCidGatewayCacheStats().entries, 0) + }) + + it("caches resolution per CID within TTL", () => { + const first = resolveCidGateway(CID, { now: 1_000 }) + assert.equal(first.fromCache, false) + const second = resolveCidGateway(CID, { now: 2_000 }) + assert.equal(second.fromCache, true) + assert.equal(second.url, first.url) + assert.equal(second.gateway, first.gateway) + }) + + it("re-resolves after the TTL expires", () => { + const first = resolveCidGateway(CID, { now: 1_000, ttlMs: 60_000 }) + const later = resolveCidGateway(CID, { now: 1_000 + 60_001 }) + assert.equal(first.fromCache, false) + assert.equal(later.fromCache, false) + }) + + it("health score rewards fast + successful gateways", () => { + recordCidGatewayOutcome({ gateway: "https://w3s.link/ipfs", ok: true, latencyMs: 80 }) + recordCidGatewayOutcome({ gateway: "https://w3s.link/ipfs", ok: true, latencyMs: 90 }) + recordCidGatewayOutcome({ gateway: "https://dweb.link/ipfs", ok: false, latencyMs: 9_000 }) + recordCidGatewayOutcome({ gateway: "https://dweb.link/ipfs", ok: false, latencyMs: 9_000 }) + assert.ok(scoreGateway("https://w3s.link/ipfs") > scoreGateway("https://dweb.link/ipfs")) + }) + + it("a recorded failure evicts cache entries pinned to that gateway", () => { + const r = resolveCidGateway(CID, { now: 1_000 }) + assert.equal(getCidGatewayCacheStats().entries, 1) + recordCidGatewayOutcome({ gateway: r.gateway, ok: false, latencyMs: 12_000 }) + assert.equal(getCidGatewayCacheStats().entries, 0) + }) + + it("picks the healthiest gateway on a cold cache", () => { + for (let i = 0; i < 5; i++) { + recordCidGatewayOutcome({ gateway: "https://w3s.link/ipfs", ok: false, latencyMs: 11_000 }) + recordCidGatewayOutcome({ gateway: "https://ipfs.io/ipfs", ok: true, latencyMs: 60 }) + } + const r = resolveCidGateway(CID) + assert.equal(r.gateway, "https://ipfs.io/ipfs") + }) + + it("rejects a malformed CID with a typed error", () => { + assert.throws(() => resolveCidGateway(" not a cid !!"), (err: unknown) => { + assert.ok(err instanceof CidResolutionError) + assert.equal((err as CidResolutionError).code, "VALIDATION_FAILED") + return true + }) + }) + + it("ignores malformed outcome payloads without throwing", () => { + assert.doesNotThrow(() => recordCidGatewayOutcome({ gateway: "nope", ok: "yes" })) + assert.doesNotThrow(() => recordCidGatewayOutcome(null)) + }) + + it("extractIpfsCidPath pulls the CID from ipfs:// and gateway URLs, else null", () => { + assert.equal(extractIpfsCidPath(`ipfs://${CID}`), CID) + assert.equal(extractIpfsCidPath(`https://gateway.pinata.cloud/ipfs/${CID}`), CID) + assert.equal(extractIpfsCidPath(`https://w3s.link/ipfs/${CID}/art.png`), `${CID}/art.png`) + assert.equal(extractIpfsCidPath("https://cdn.example.com/ipfs-themed/pic.png"), null) + assert.equal(extractIpfsCidPath(undefined), null) + }) +}) diff --git a/lib/__tests__/cost-attribution-ledger.test.ts b/lib/__tests__/cost-attribution-ledger.test.ts new file mode 100644 index 00000000..1fa84943 --- /dev/null +++ b/lib/__tests__/cost-attribution-ledger.test.ts @@ -0,0 +1,73 @@ +import { describe, it, beforeEach } from "node:test" +import * as assert from "node:assert/strict" +import { + recordRequestCost, + getRequestCost, + getCostLedger, + summarizeCostByOperation, + CostAttributionError, + OPERATION_UNIT_COST, + __resetCostLedgerForTests, +} from "@/lib/follow-store" + +describe("phase-138 cost attribution ledger", () => { + beforeEach(() => { + __resetCostLedgerForTests() + process.env.FEATURE_PHASE_138 = "1" + }) + + it("flag off: recording is a no-op and returns 0 units", () => { + process.env.FEATURE_PHASE_138 = "" + const units = recordRequestCost({ requestId: "r1", operation: "forge.request" }) + assert.equal(units, 0) + assert.equal(getCostLedger().length, 0) + }) + + it("books the default unit weight per operation", () => { + const units = recordRequestCost({ requestId: "r1", operation: "horizon.asset_holders" }) + assert.equal(units, OPERATION_UNIT_COST["horizon.asset_holders"]) + assert.equal(getRequestCost("r1").totalUnits, units) + }) + + it("scales cost by count and supports an explicit units override", () => { + recordRequestCost({ requestId: "r2", operation: "profile.enrichment", count: 4 }) + recordRequestCost({ requestId: "r2", operation: "forge.request", units: 12.5 }) + const cost = getRequestCost("r2") + assert.equal(cost.entries.length, 2) + assert.equal(cost.totalUnits, OPERATION_UNIT_COST["profile.enrichment"] * 4 + 12.5) + }) + + it("aggregates a treasury summary across requests", () => { + recordRequestCost({ requestId: "a", operation: "follow.write" }) + recordRequestCost({ requestId: "b", operation: "follow.write" }) + recordRequestCost({ requestId: "b", operation: "notification.create" }) + const s = summarizeCostByOperation() + assert.equal(s.totalRequests, 2) + assert.equal(s.byOperation["follow.write"]?.count, 2) + assert.equal(s.totalUnits, s.byOperation["follow.write"]!.units + s.byOperation["notification.create"]!.units) + }) + + it("filters the ledger by operation", () => { + recordRequestCost({ requestId: "a", operation: "follow.write" }) + recordRequestCost({ requestId: "a", operation: "horizon.account_lookup" }) + assert.equal(getCostLedger({ operation: "horizon.account_lookup" }).length, 1) + }) + + it("rejects a malformed payload with a typed error", () => { + assert.throws( + () => recordRequestCost({ requestId: "", operation: "not-an-op" }), + (err: unknown) => { + assert.ok(err instanceof CostAttributionError) + assert.equal((err as CostAttributionError).code, "VALIDATION_FAILED") + return true + }, + ) + }) + + it("isolates cost per requestId", () => { + recordRequestCost({ requestId: "req-x", operation: "follow.suggestions" }) + recordRequestCost({ requestId: "req-y", operation: "forge.request" }) + assert.equal(getRequestCost("req-x").totalUnits, OPERATION_UNIT_COST["follow.suggestions"]) + assert.equal(getRequestCost("req-y").totalUnits, OPERATION_UNIT_COST["forge.request"]) + }) +}) diff --git a/lib/__tests__/profile-error-taxonomy.test.ts b/lib/__tests__/profile-error-taxonomy.test.ts new file mode 100644 index 00000000..47cce22c --- /dev/null +++ b/lib/__tests__/profile-error-taxonomy.test.ts @@ -0,0 +1,75 @@ +import { describe, it } from "node:test" +import * as assert from "node:assert/strict" +import { z } from "zod" +import { + classifyProfileError, + toProfileErrorResponse, + ProfileError, + ProfileErrorResponseSchema, + PROFILE_ERROR_CODES, +} from "@/lib/profile-store" + +describe("phase-137 structured profile error taxonomy", () => { + it("passes a ProfileError through unchanged", () => { + const original = new ProfileError("AVATAR_NOT_FOUND", "no avatar") + assert.equal(classifyProfileError(original), original) + assert.equal(original.status, 404) + assert.equal(original.retryable, false) + }) + + it("maps fetch timeouts to a retryable GATEWAY_TIMEOUT (504)", () => { + const abort = Object.assign(new Error("The operation was aborted"), { name: "AbortError" }) + const e = classifyProfileError(abort) + assert.equal(e.code, "GATEWAY_TIMEOUT") + assert.equal(e.status, 504) + assert.equal(e.retryable, true) + assert.equal(e.category, "upstream") + }) + + it("maps DNS / connection failures to GATEWAY_UNREACHABLE (502, retryable)", () => { + const e = classifyProfileError(new Error("fetch failed: ECONNREFUSED")) + assert.equal(e.code, "GATEWAY_UNREACHABLE") + assert.equal(e.retryable, true) + }) + + it("maps checksum / tamper errors to a non-retryable CHECKSUM_MISMATCH", () => { + const cid = Object.assign(new Error("Cached bytes fail integrity check"), { name: "CidIntegrityError" }) + const e = classifyProfileError(cid) + assert.equal(e.code, "CHECKSUM_MISMATCH") + assert.equal(e.category, "integrity") + assert.equal(e.retryable, false) + }) + + it("maps an upstream { status } onto the taxonomy", () => { + assert.equal(classifyProfileError(Object.assign(new Error("x"), { status: 503 })).code, "GATEWAY_5XX") + assert.equal(classifyProfileError(Object.assign(new Error("x"), { status: 404 })).code, "GATEWAY_4XX") + assert.equal(classifyProfileError(Object.assign(new Error("x"), { status: 429 })).code, "RATE_LIMITED") + }) + + it("maps Zod parse failures to MALFORMED_RESPONSE", () => { + const parsed = z.object({ a: z.string() }).safeParse({ a: 1 }) + assert.equal(parsed.success, false) + if (!parsed.success) { + assert.equal(classifyProfileError(parsed.error).code, "MALFORMED_RESPONSE") + } + }) + + it("falls back to INTERNAL (500) for unknown values", () => { + assert.equal(classifyProfileError("boom").code, "INTERNAL") + assert.equal(classifyProfileError(undefined).status, 500) + }) + + it("toProfileErrorResponse yields a schema-valid body + deterministic status", () => { + const { body, status } = toProfileErrorResponse(new ProfileError("PIN_QUORUM_FAILED", "2/3 gateways", { achieved: 2 })) + assert.equal(status, 502) + assert.doesNotThrow(() => ProfileErrorResponseSchema.parse(body)) + assert.equal(body.retryable, true) + }) + + it("every code has a spec and serializes cleanly", () => { + for (const code of PROFILE_ERROR_CODES) { + const res = new ProfileError(code, code).toResponse() + assert.doesNotThrow(() => ProfileErrorResponseSchema.parse(res)) + } + }) +}) diff --git a/lib/feature-flags.ts b/lib/feature-flags.ts index df4a97ca..a2c8f05d 100644 --- a/lib/feature-flags.ts +++ b/lib/feature-flags.ts @@ -28,6 +28,9 @@ * - phase-133: faucet distributor balance auto-top-up via Mercury * - phase-134: rate-limit-aware batch trustline submission to Horizon * - phase-135: cached wallet/explore NFT ownership index with stale-on-error fallback + * - phase-136: per-CID IPFS gateway resolution cache with TTL + gateway health scoring + * - phase-137: structured error taxonomy for profile avatar / x402 invoice failures + * - phase-138: cost attribution ledger per follow/forge request for treasury accounting */ export type PhaseFeatureFlag = @@ -77,7 +80,10 @@ export type PhaseFeatureFlag = | "phase-132" | "phase-133" | "phase-134" - | "phase-135"; + | "phase-135" + | "phase-136" + | "phase-137" + | "phase-138"; const FLAG_ENV_MAP: Record = { "phase-66": ["NEXT_PUBLIC_FEATURE_PHASE_66", "FEATURE_PHASE_66"], @@ -126,6 +132,9 @@ const FLAG_ENV_MAP: Record = { "phase-133": ["NEXT_PUBLIC_FEATURE_PHASE_133", "FEATURE_PHASE_133"], "phase-134": ["NEXT_PUBLIC_FEATURE_PHASE_134", "FEATURE_PHASE_134"], "phase-135": ["NEXT_PUBLIC_FEATURE_PHASE_135", "FEATURE_PHASE_135"], + "phase-136": ["NEXT_PUBLIC_FEATURE_PHASE_136", "FEATURE_PHASE_136"], + "phase-137": ["NEXT_PUBLIC_FEATURE_PHASE_137", "FEATURE_PHASE_137"], + "phase-138": ["NEXT_PUBLIC_FEATURE_PHASE_138", "FEATURE_PHASE_138"], }; function isTruthy(v: string | undefined): boolean { @@ -200,6 +209,9 @@ export function getEnabledFeatureFlags(): PhaseFeatureFlag[] { "phase-131", "phase-132", "phase-133", + "phase-136", + "phase-137", + "phase-138", ]; return all.filter(isFeatureEnabled) } diff --git a/lib/follow-store.ts b/lib/follow-store.ts index 4232b4cc..dbebb23d 100644 --- a/lib/follow-store.ts +++ b/lib/follow-store.ts @@ -425,3 +425,177 @@ export async function getFollowSuggestions( ]); return rankFollowSuggestions(wallet, store, onChainNeighbors, limit); } + +// ── Issue #67 (phase-138): cost attribution ledger per request ──────────────── +// +// Isolated, flag-gated. Infra spend (Horizon fan-out on the follow-suggestions +// path, notification writes, profile enrichment) was never attributed to the +// request that caused it, so the treasury could not reconcile spend against +// revenue. This module keeps a bounded in-memory ledger of per-request cost +// units keyed by a taxonomy of billable operations, plus aggregation helpers +// for a treasury view. Units are dimensionless "cost points" (relative weights), +// not a currency. +// +// Feature flag: phase-138 (NEXT_PUBLIC_FEATURE_PHASE_138 / FEATURE_PHASE_138) +// Rollback: unset the flag → recordRequestCost() is a no-op and getCostLedger() +// returns empty. Purely in-memory; nothing to migrate. + +export function isPhase138Enabled(): boolean { + return isFeatureEnabled("phase-138"); +} + +export function flag138RollbackNote(): string { + return "Rollback phase-138: unset NEXT_PUBLIC_FEATURE_PHASE_138 / FEATURE_PHASE_138 or set to 0/false and restart. The cost ledger is in-memory only; nothing to migrate."; +} + +export const BILLABLE_OPERATIONS = [ + "follow.suggestions", + "follow.write", + "follow.graph_read", + "horizon.account_lookup", + "horizon.asset_holders", + "profile.enrichment", + "notification.create", + "forge.request", +] as const; + +export type BillableOperation = (typeof BILLABLE_OPERATIONS)[number]; + +/** Default relative cost weight per operation. */ +export const OPERATION_UNIT_COST: Record = { + "follow.suggestions": 1, + "follow.write": 1, + "follow.graph_read": 1, + "horizon.account_lookup": 2, + "horizon.asset_holders": 3, + "profile.enrichment": 1, + "notification.create": 1, + "forge.request": 20, +}; + +export const CostAttributionInputSchema = z.object({ + requestId: z.string().trim().min(1).max(128), + operation: z.enum(BILLABLE_OPERATIONS), + count: z.number().int().min(1).max(10_000).default(1), + units: z.number().min(0).max(1_000_000).optional(), + source: z.string().trim().min(1).max(64).default("internal"), + wallet: z + .string() + .trim() + .regex(/^G[A-Z2-7]{55}$/) + .optional(), +}); + +export type CostAttributionInput = z.infer; + +export type CostLedgerEntry = { + requestId: string; + operation: BillableOperation; + count: number; + units: number; + source: string; + wallet?: string; + at: number; +}; + +export class CostAttributionError extends Error { + code: "VALIDATION_FAILED"; + details?: unknown; + constructor(message: string, details?: unknown) { + super(message); + this.name = "CostAttributionError"; + this.code = "VALIDATION_FAILED"; + this.details = details; + } +} + +const COST_LEDGER_MAX_ENTRIES = 5_000; +const costLedger: CostLedgerEntry[] = []; + +/** + * Appends a cost line for a request. Returns the units booked (0 when the flag + * is off). Throws CostAttributionError only on a malformed payload. + */ +export function recordRequestCost(raw: unknown): number { + const parsed = CostAttributionInputSchema.safeParse(raw); + if (!parsed.success) { + throw new CostAttributionError( + "valid cost attribution input required", + parsed.error.flatten(), + ); + } + if (!isPhase138Enabled()) return 0; + + const input = parsed.data; + const units = + input.units ?? OPERATION_UNIT_COST[input.operation] * input.count; + costLedger.push({ + requestId: input.requestId, + operation: input.operation, + count: input.count, + units, + source: input.source, + ...(input.wallet ? { wallet: input.wallet } : {}), + at: Date.now(), + }); + if (costLedger.length > COST_LEDGER_MAX_ENTRIES) { + costLedger.splice(0, costLedger.length - COST_LEDGER_MAX_ENTRIES); + } + return units; +} + +export function getRequestCost(requestId: string): { + requestId: string; + totalUnits: number; + entries: CostLedgerEntry[]; +} { + const entries = costLedger.filter((e) => e.requestId === requestId); + return { + requestId, + totalUnits: entries.reduce((sum, e) => sum + e.units, 0), + entries, + }; +} + +export function getCostLedger( + opts: { operation?: BillableOperation; sinceMs?: number; limit?: number } = {}, +): CostLedgerEntry[] { + let rows = costLedger; + if (opts.operation) rows = rows.filter((e) => e.operation === opts.operation); + if (opts.sinceMs != null) { + const cutoff = Date.now() - opts.sinceMs; + rows = rows.filter((e) => e.at >= cutoff); + } + const limit = opts.limit ?? 500; + return rows.slice(-limit); +} + +export function summarizeCostByOperation(): { + enabled: boolean; + totalUnits: number; + totalRequests: number; + byOperation: Record; +} { + const byOperation: Record = {}; + const requestIds = new Set(); + let totalUnits = 0; + for (const e of costLedger) { + requestIds.add(e.requestId); + totalUnits += e.units; + const bucket = byOperation[e.operation] ?? { units: 0, count: 0 }; + bucket.units += e.units; + bucket.count += e.count; + byOperation[e.operation] = bucket; + } + return { + enabled: isPhase138Enabled(), + totalUnits, + totalRequests: requestIds.size, + byOperation, + }; +} + +/** Test/ops hook to reset the in-memory ledger. */ +export function __resetCostLedgerForTests(): void { + costLedger.length = 0; +} diff --git a/lib/profile-store.ts b/lib/profile-store.ts index f09bb0a5..7caa7490 100644 --- a/lib/profile-store.ts +++ b/lib/profile-store.ts @@ -2,6 +2,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; import { serverDataJsonPath } from "@/lib/server-data-paths"; +import { isFeatureEnabled } from "@/lib/feature-flags"; export type ProfileData = { display_name?: string; @@ -1047,3 +1048,178 @@ async function writeJson( await mkdir(path.dirname(filePath), { recursive: true }); await writeFile(filePath, JSON.stringify(data, null, 2), "utf8"); } + +// ── Issues #65 / #66 (phase-137): structured error taxonomy ─────────────────── +// +// Isolated, flag-gated. Avatar / gateway / x402-invoice failures all surfaced as +// a single generic 500 "Internal server error", so a Pinata timeout, a checksum +// mismatch and a bad wallet were indistinguishable in production logs and the +// client could not tell a retryable blip from a permanent failure. This module +// maps any thrown value onto a closed taxonomy of codes, each carrying a +// deterministic HTTP status, a category and a retryable flag, plus a serializer +// for API responses. +// +// Feature flag: phase-137 (NEXT_PUBLIC_FEATURE_PHASE_137 / FEATURE_PHASE_137) +// Rollback: unset the flag → classifyProfileError() still works but routes keep +// their legacy generic 500; no schema or data change to revert. + +export function isPhase137Enabled(): boolean { + return isFeatureEnabled("phase-137"); +} + +export function flag137RollbackNote(): string { + return "Rollback phase-137: unset NEXT_PUBLIC_FEATURE_PHASE_137 / FEATURE_PHASE_137 or set to 0/false and restart. Routes fall back to a generic 500; no data migration to undo."; +} + +export const PROFILE_ERROR_CODES = [ + "INVALID_WALLET", + "AVATAR_NOT_FOUND", + "GATEWAY_TIMEOUT", + "GATEWAY_UNREACHABLE", + "GATEWAY_5XX", + "GATEWAY_4XX", + "CHECKSUM_MISMATCH", + "MALFORMED_RESPONSE", + "PIN_QUORUM_FAILED", + "NOT_CONFIGURED", + "RATE_LIMITED", + "FLAG_DISABLED", + "INTERNAL", +] as const; + +export type ProfileErrorCode = (typeof PROFILE_ERROR_CODES)[number]; + +export type ProfileErrorCategory = + | "client" + | "upstream" + | "integrity" + | "config" + | "internal"; + +type ProfileErrorSpec = { + status: number; + category: ProfileErrorCategory; + retryable: boolean; +}; + +const PROFILE_ERROR_TABLE: Record = { + INVALID_WALLET: { status: 400, category: "client", retryable: false }, + AVATAR_NOT_FOUND: { status: 404, category: "client", retryable: false }, + GATEWAY_TIMEOUT: { status: 504, category: "upstream", retryable: true }, + GATEWAY_UNREACHABLE: { status: 502, category: "upstream", retryable: true }, + GATEWAY_5XX: { status: 502, category: "upstream", retryable: true }, + GATEWAY_4XX: { status: 502, category: "upstream", retryable: false }, + CHECKSUM_MISMATCH: { status: 502, category: "integrity", retryable: false }, + MALFORMED_RESPONSE: { status: 502, category: "integrity", retryable: false }, + PIN_QUORUM_FAILED: { status: 502, category: "upstream", retryable: true }, + NOT_CONFIGURED: { status: 500, category: "config", retryable: false }, + RATE_LIMITED: { status: 429, category: "upstream", retryable: true }, + FLAG_DISABLED: { status: 404, category: "config", retryable: false }, + INTERNAL: { status: 500, category: "internal", retryable: false }, +}; + +export const ProfileErrorResponseSchema = z.object({ + error: z.string().min(1), + code: z.enum(PROFILE_ERROR_CODES), + category: z.enum(["client", "upstream", "integrity", "config", "internal"]), + retryable: z.boolean(), + details: z.unknown().optional(), +}); + +export type ProfileErrorResponse = z.infer; + +export class ProfileError extends Error { + readonly code: ProfileErrorCode; + readonly status: number; + readonly category: ProfileErrorCategory; + readonly retryable: boolean; + readonly details?: unknown; + + constructor( + code: ProfileErrorCode, + message: string, + details?: unknown, + ) { + super(message); + this.name = "ProfileError"; + this.code = code; + const spec = PROFILE_ERROR_TABLE[code]; + this.status = spec.status; + this.category = spec.category; + this.retryable = spec.retryable; + this.details = details; + } + + toResponse(): ProfileErrorResponse { + return { + error: this.message, + code: this.code, + category: this.category, + retryable: this.retryable, + ...(this.details !== undefined ? { details: this.details } : {}), + }; + } +} + +function statusToCode(status: number): ProfileErrorCode { + if (status === 429) return "RATE_LIMITED"; + if (status === 408 || status === 504) return "GATEWAY_TIMEOUT"; + if (status >= 500) return "GATEWAY_5XX"; + if (status >= 400) return "GATEWAY_4XX"; + return "INTERNAL"; +} + +/** + * Maps any thrown value onto the taxonomy. Recognises ProfileError (pass-through), + * fetch AbortError / timeouts, DNS / connection failures, `{ status }`-shaped + * upstream errors, CID integrity errors and Zod parse errors. + */ +export function classifyProfileError(err: unknown): ProfileError { + if (err instanceof ProfileError) return err; + + if (err instanceof z.ZodError) { + return new ProfileError( + "MALFORMED_RESPONSE", + "Upstream payload failed schema validation", + err.flatten(), + ); + } + + if (err instanceof Error) { + const name = err.name; + const msg = err.message.toLowerCase(); + + if (name === "AbortError" || name === "TimeoutError" || msg.includes("timeout") || msg.includes("timed out")) { + return new ProfileError("GATEWAY_TIMEOUT", "Gateway request timed out", { cause: err.message }); + } + if (name === "CidIntegrityError" || msg.includes("checksum") || msg.includes("tamper") || msg.includes("hash mismatch")) { + return new ProfileError("CHECKSUM_MISMATCH", "Fetched bytes failed integrity verification", { cause: err.message }); + } + if (msg.includes("fetch failed") || msg.includes("enotfound") || msg.includes("econnrefused") || msg.includes("network")) { + return new ProfileError("GATEWAY_UNREACHABLE", "Gateway is unreachable", { cause: err.message }); + } + + const status = (err as { status?: unknown }).status; + if (typeof status === "number" && Number.isFinite(status)) { + return new ProfileError(statusToCode(status), `Gateway responded ${status}`, { status }); + } + + const code = (err as { code?: unknown }).code; + if (typeof code === "string" && (PROFILE_ERROR_CODES as readonly string[]).includes(code)) { + return new ProfileError(code as ProfileErrorCode, err.message); + } + + return new ProfileError("INTERNAL", err.message); + } + + return new ProfileError("INTERNAL", typeof err === "string" ? err : "Unknown profile error"); +} + +/** Convenience for route handlers: `{ body, status }` for a caught value. */ +export function toProfileErrorResponse(err: unknown): { + body: ProfileErrorResponse; + status: number; +} { + const classified = classifyProfileError(err); + return { body: classified.toResponse(), status: classified.status }; +} diff --git a/lib/signal-store.ts b/lib/signal-store.ts index 647ae36e..a6f403a0 100644 --- a/lib/signal-store.ts +++ b/lib/signal-store.ts @@ -476,3 +476,270 @@ export async function generateThumbnail( url: `https://gateway.pinata.cloud/ipfs/${ipfsCid}`, }; } + +// ── Issue #64 (phase-136): per-CID IPFS gateway resolution cache ────────────── +// +// Isolated, flag-gated. Every metadata read re-resolved a CID against the +// gateway list from scratch, so repeated reads of the same attachment paid the +// gateway-selection cost again and again, and a degrading gateway kept being +// picked until it hard-failed. This module memoizes the resolution per CID +// (TTL) and keeps a rolling health score per gateway (success ratio + EWMA +// latency) so the best gateway wins and a cache entry pinned to a failing +// gateway is dropped on the next recorded failure. +// +// Feature flag: phase-136 (NEXT_PUBLIC_FEATURE_PHASE_136 / FEATURE_PHASE_136) +// Rollback: unset the flag → resolveCidGateway() falls back to a deterministic +// first-gateway pick with no caching. No persistent state to revert. + +export function isPhase136Enabled(): boolean { + return isFeatureEnabled("phase-136"); +} + +export function flag136RollbackNote(): string { + return "Rollback phase-136: unset NEXT_PUBLIC_FEATURE_PHASE_136 / FEATURE_PHASE_136 or set to 0/false and restart. CID resolution falls back to a first-gateway pick with no cache; no data migration to undo."; +} + +export const CID_RESOLUTION_GATEWAYS = [ + "https://w3s.link/ipfs", + "https://dweb.link/ipfs", + "https://ipfs.io/ipfs", + "https://cloudflare-ipfs.com/ipfs", +] as const; + +const CID_RESOLUTION_DEFAULT_TTL_MS = 5 * 60 * 1000; +const CID_RESOLUTION_MAX_ENTRIES = 256; +const GATEWAY_LATENCY_EWMA_ALPHA = 0.3; + +export const CidResolutionRequestSchema = z.object({ + cid: z + .string() + .trim() + .min(4) + .max(512) + .regex(/^[A-Za-z0-9][A-Za-z0-9._/-]*$/, "Invalid CID or CID path"), + ttlMs: z + .number() + .int() + .min(1_000) + .max(24 * 60 * 60 * 1000) + .optional(), +}); + +export type CidResolutionRequest = z.infer; + +export const GatewayOutcomeSchema = z.object({ + gateway: z.string().trim().url(), + ok: z.boolean(), + latencyMs: z.number().min(0).max(120_000).default(0), +}); + +export type GatewayOutcome = z.infer; + +export type CidGatewayResolution = { + cid: string; + url: string; + gateway: string; + score: number; + fromCache: boolean; + resolvedAt: number; + expiresAt: number; +}; + +export class CidResolutionError extends Error { + code: "FLAG_DISABLED" | "VALIDATION_FAILED" | "NO_GATEWAY"; + details?: unknown; + constructor( + code: CidResolutionError["code"], + message: string, + details?: unknown, + ) { + super(message); + this.name = "CidResolutionError"; + this.code = code; + this.details = details; + } +} + +type GatewayHealth = { ok: number; fail: number; ewmaLatencyMs: number }; +type CidResolutionEntry = { + gateway: string; + url: string; + resolvedAt: number; + expiresAt: number; +}; + +const cidResolutionCache = new Map(); +const gatewayHealth = new Map(); + +function normalizeGatewayBase(gateway: string): string { + return gateway.trim().replace(/\/+$/, ""); +} + +function normalizeCidPath(cid: string): string { + return cid.trim().replace(/^ipfs:\/\//i, "").replace(/^\/+/, ""); +} + +/** + * Pulls the `/` portion out of an `ipfs://…` URI or a + * `https://gateway/ipfs/…` URL. Returns null for a non-IPFS value so callers can + * skip resolution and keep the stored URL untouched. + */ +export function extractIpfsCidPath(value: string | undefined | null): string | null { + if (!value) return null; + const ipfsUri = value.match(/^ipfs:\/\/([A-Za-z0-9][A-Za-z0-9._/-]*)$/i); + if (ipfsUri) return ipfsUri[1]!; + const gatewayUrl = value.match(/\/ipfs\/([A-Za-z0-9][A-Za-z0-9._/-]*)$/i); + if (gatewayUrl) return gatewayUrl[1]!; + return null; +} + +/** 0–100 health score: 70% success ratio, 30% latency (0ms→100, ≥5s→0). */ +export function scoreGateway(gateway: string): number { + const h = gatewayHealth.get(normalizeGatewayBase(gateway)); + if (!h || h.ok + h.fail === 0) return 50; + const successRatio = h.ok / (h.ok + h.fail); + const latencyScore = Math.max(0, 100 - (h.ewmaLatencyMs / 5_000) * 100); + return Math.round(successRatio * 100 * 0.7 + latencyScore * 0.3); +} + +function bestGateway(): { gateway: string; score: number } { + let best = normalizeGatewayBase(CID_RESOLUTION_GATEWAYS[0]); + let bestScore = -1; + for (const raw of CID_RESOLUTION_GATEWAYS) { + const gateway = normalizeGatewayBase(raw); + const score = scoreGateway(gateway); + if (score > bestScore) { + best = gateway; + bestScore = score; + } + } + return { gateway: best, score: bestScore < 0 ? 50 : bestScore }; +} + +function evictCidResolutionIfNeeded(): void { + if (cidResolutionCache.size <= CID_RESOLUTION_MAX_ENTRIES) return; + const oldest = cidResolutionCache.keys().next().value as string | undefined; + if (oldest) cidResolutionCache.delete(oldest); +} + +/** + * Resolves a CID (or CID/path) to a gateway-routed URL, memoized per CID with a + * TTL and backed by rolling gateway health scores. When phase-136 is off this + * returns a deterministic first-gateway pick and never touches the cache. + */ +export function resolveCidGateway( + cid: string, + opts: { ttlMs?: number; now?: number; force?: boolean } = {}, +): CidGatewayResolution { + const cidPath = normalizeCidPath(cid); + const now = opts.now ?? Date.now(); + + if (!opts.force && !isPhase136Enabled()) { + const gateway = normalizeGatewayBase(CID_RESOLUTION_GATEWAYS[0]); + return { + cid: cidPath, + url: `${gateway}/${cidPath}`, + gateway, + score: 50, + fromCache: false, + resolvedAt: now, + expiresAt: now, + }; + } + + const parsed = CidResolutionRequestSchema.safeParse({ + cid: cidPath, + ttlMs: opts.ttlMs, + }); + if (!parsed.success) { + throw new CidResolutionError( + "VALIDATION_FAILED", + "valid CID or CID path required", + parsed.error.flatten(), + ); + } + + const cached = cidResolutionCache.get(cidPath); + if (cached && cached.expiresAt > now) { + return { + cid: cidPath, + url: cached.url, + gateway: cached.gateway, + score: scoreGateway(cached.gateway), + fromCache: true, + resolvedAt: cached.resolvedAt, + expiresAt: cached.expiresAt, + }; + } + + const { gateway, score } = bestGateway(); + const ttlMs = parsed.data.ttlMs ?? CID_RESOLUTION_DEFAULT_TTL_MS; + const entry: CidResolutionEntry = { + gateway, + url: `${gateway}/${cidPath}`, + resolvedAt: now, + expiresAt: now + ttlMs, + }; + cidResolutionCache.delete(cidPath); + cidResolutionCache.set(cidPath, entry); + evictCidResolutionIfNeeded(); + + return { + cid: cidPath, + url: entry.url, + gateway, + score, + fromCache: false, + resolvedAt: now, + expiresAt: entry.expiresAt, + }; +} + +/** + * Feeds a gateway request outcome back into the health model. A failure also + * invalidates every cached CID currently pinned to that gateway so the next + * resolution re-picks. + */ +export function recordCidGatewayOutcome(raw: unknown): void { + const parsed = GatewayOutcomeSchema.safeParse(raw); + if (!parsed.success) return; + const gateway = normalizeGatewayBase(parsed.data.gateway); + const h = gatewayHealth.get(gateway) ?? { ok: 0, fail: 0, ewmaLatencyMs: 0 }; + if (parsed.data.ok) h.ok += 1; + else h.fail += 1; + const latency = parsed.data.latencyMs; + h.ewmaLatencyMs = + h.ewmaLatencyMs === 0 + ? latency + : h.ewmaLatencyMs * (1 - GATEWAY_LATENCY_EWMA_ALPHA) + + latency * GATEWAY_LATENCY_EWMA_ALPHA; + gatewayHealth.set(gateway, h); + + if (!parsed.data.ok) { + for (const [cidPath, entry] of cidResolutionCache.entries()) { + if (entry.gateway === gateway) cidResolutionCache.delete(cidPath); + } + } +} + +export function getCidGatewayCacheStats(): { + enabled: boolean; + entries: number; + gateways: Array<{ gateway: string; score: number; ok: number; fail: number }>; +} { + return { + enabled: isPhase136Enabled(), + entries: cidResolutionCache.size, + gateways: CID_RESOLUTION_GATEWAYS.map((raw) => { + const gateway = normalizeGatewayBase(raw); + const h = gatewayHealth.get(gateway) ?? { ok: 0, fail: 0, ewmaLatencyMs: 0 }; + return { gateway, score: scoreGateway(gateway), ok: h.ok, fail: h.fail }; + }), + }; +} + +/** Test/ops hook to reset process-local phase-136 state. */ +export function __resetCidGatewayCacheForTests(): void { + cidResolutionCache.clear(); + gatewayHealth.clear(); +}