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
134 changes: 134 additions & 0 deletions IMPLEMENTATION_SUMMARY_64_67.md
Original file line number Diff line number Diff line change
@@ -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 · <n>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.
39 changes: 35 additions & 4 deletions app/api/profile/avatar/route.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createApiRequestContext>,
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 ????????????????????????????????????
Expand All @@ -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" } },
Expand Down Expand Up @@ -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")
}
}

Expand Down Expand Up @@ -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
Expand All @@ -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")
}
}
47 changes: 47 additions & 0 deletions app/api/profile/follow/route.ts
Original file line number Diff line number Diff line change
@@ -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)) {
Expand Down Expand Up @@ -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() ?? "";
Expand Down Expand Up @@ -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 {
Expand All @@ -144,12 +187,16 @@ 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);
return NextResponse.json({
ok: true,
...counts,
metadata_validation_enabled: isPhase118Enabled(),
...(isPhase138Enabled() ? { costUnits: getRequestCost(costId).totalUnits } : {}),
});
}
36 changes: 31 additions & 5 deletions app/api/signals/[id]/replies/route.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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")
Expand Down
Loading