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
117 changes: 73 additions & 44 deletions src/sovereign-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,34 @@
// RPC, no tokenURI, no sonar GraphQL, no honeyroad. The live JSON shape is already
// `{ name, description, image, attributes: [{ trait_type, value }] }` (verified live).

import { NotFoundError } from "./errors.js";
import { NotFoundError, ValidationError } from "./errors.js";
import type { Attribute, MetadataDocument } from "../types.js";

const METADATA_BASE = "https://metadata.0xhoneyjar.xyz";

// Upstream (storage-api / CloudFront) is normally sub-second; bound it so a hung
// origin can't tie up an inventory-api request indefinitely. On timeout the
// AbortController fires → fetch rejects → mapped to the network-failure throw
// below (the consumer fail-softs to imageless). Overridable for tests/ops.
const METADATA_FETCH_TIMEOUT_MS = Number(
process.env.METADATA_FETCH_TIMEOUT_MS ?? 8000
);
Comment on lines +27 to +29
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Default invalid timeout overrides before scheduling fetches

When METADATA_FETCH_TIMEOUT_MS is present but empty or non-numeric, this Number(...) expression produces 0/NaN instead of falling back to the 8s default; setTimeout(..., 0/NaN) then aborts real MST metadata fetches almost immediately in that deployment configuration. Since this is an ops override, validate that the parsed value is a finite positive duration before using it, and otherwise keep the default.

Useful? React with 👍 / 👎.


/** Sovereign storage-api URL for an MST (Mibera Shadow) token's metadata. */
export function mstMetadataUrl(tokenId: string): string {
return `${METADATA_BASE}/mibera/mst/${tokenId}`;
// Caller (getNftMetadata) already validates `^\d+$`, but encode defensively so
// this helper is safe in isolation (no path-injection if reused elsewhere).
return `${METADATA_BASE}/mibera/mst/${encodeURIComponent(tokenId)}`;
}

/** Defensive coercion of the sovereign JSON `attributes` array. */
// Cap mapped attributes — a buggy/hostile upstream returning a giant array must
// not translate into unbounded work + payload here (real MST tokens carry ~19).
const MAX_ATTRIBUTES = 64;

/** Defensive coercion of the sovereign JSON `attributes` array (bounded). */
function mapAttributes(raw: unknown): Attribute[] {
if (!Array.isArray(raw)) return [];
return raw.map((entry) => {
return raw.slice(0, MAX_ATTRIBUTES).map((entry) => {
const a = (entry ?? {}) as { trait_type?: unknown; value?: unknown };
return {
trait_type: String(a.trait_type ?? ""),
Expand All @@ -40,56 +54,71 @@ function mapAttributes(raw: unknown): Attribute[] {
/**
* Resolve MST (Mibera Shadow) metadata from the sovereign storage-api.
*
* - 4xx (e.g. 403/404 — unminted tokens return 403): throws NotFoundError, mirroring
* the existing not-found semantics so callers treat an unminted/absent MST token the
* same as a missing codex token.
* - other non-ok / network failure: throws a clear error (NOT swallowed here — the
* downstream consumer try/catches and fail-softs to imageless).
* - 403 (unminted) / 404 (absent): throws NotFoundError, mirroring the existing
* not-found semantics so callers treat an unminted/absent MST token the same as
* a missing codex token.
* - other 4xx (401/429/…) / 5xx / network failure / timeout: throws a clear error
* (NOT swallowed here — the downstream consumer fail-softs to imageless). A
* throttle or auth blip is NOT collapsed into "token not found".
* - 200: parses JSON and maps to the plain MetadataDocument interface, defensively.
*/
export async function fetchMstMetadata(
contract: string,
tokenId: string
): Promise<MetadataDocument> {
// Enforce the numeric-token invariant at this exported boundary too (not only
// in getNftMetadata) so direct/future callers can't build a malformed route.
if (!/^\d+$/.test(tokenId)) {
throw new ValidationError("tokenId", tokenId, "numeric string");
}
const url = mstMetadataUrl(tokenId);

let res: Response;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), METADATA_FETCH_TIMEOUT_MS);
try {
res = await fetch(url);
} catch (cause) {
throw new Error(
`MST metadata fetch failed for token ${tokenId} (${url}): ${String(cause)}`
);
}
let res: Response;
try {
res = await fetch(url, { signal: controller.signal });
} catch (cause) {
// Network failure OR timeout-abort both land here (AbortError on timeout) —
// surfaced as a clear throw; the downstream consumer fail-softs to imageless.
throw new Error(
`MST metadata fetch failed for token ${tokenId} (${url}): ${String(cause)}`
);
}

if (!res.ok) {
// Unminted tokens return 403; absent return 404. Any 4xx => not-found semantics.
if (res.status >= 400 && res.status < 500) {
throw new NotFoundError(tokenId, contract);
if (!res.ok) {
// 403 (unminted) / 404 (absent) => not-found. Other 4xx (401/429/…) are
// transient/config errors, NOT "token absent": surface them as a thrown
// error so a throttle or auth blip doesn't masquerade as a missing token.
if (res.status === 403 || res.status === 404) {
throw new NotFoundError(tokenId, contract);
}
throw new Error(
`MST metadata fetch returned HTTP ${res.status} for token ${tokenId} (${url})`
);
}
throw new Error(
`MST metadata fetch returned HTTP ${res.status} for token ${tokenId} (${url})`
);
}

let json: {
name?: unknown;
description?: unknown;
image?: unknown;
attributes?: unknown;
};
try {
json = (await res.json()) as typeof json;
} catch (cause) {
throw new Error(
`MST metadata JSON parse failed for token ${tokenId} (${url}): ${String(cause)}`
);
}
let json: {
name?: unknown;
description?: unknown;
image?: unknown;
attributes?: unknown;
};
try {
json = (await res.json()) as typeof json;
} catch (cause) {
throw new Error(
`MST metadata JSON parse failed for token ${tokenId} (${url}): ${String(cause)}`
);
}

return {
name: typeof json.name === "string" ? json.name : "",
description: typeof json.description === "string" ? json.description : "",
image: typeof json.image === "string" ? json.image : "",
attributes: mapAttributes(json.attributes),
};
return {
name: typeof json.name === "string" ? json.name : "",
description: typeof json.description === "string" ? json.description : "",
image: typeof json.image === "string" ? json.image : "",
attributes: mapAttributes(json.attributes),
};
} finally {
clearTimeout(timer);
}
}
21 changes: 21 additions & 0 deletions tests/getNftMetadata.mst.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,4 +90,25 @@ describe('getNftMetadata — Mibera Shadow (MST) sovereign path', () => {
expect(err).toBeInstanceOf(Error);
expect(err).not.toBeInstanceOf(NotFoundError);
});

it('bounds the upstream fetch with an abort signal (timeout wiring)', async () => {
// A hung storage-api must not tie up an inventory-api request indefinitely:
// the resolver passes an AbortController signal so the timeout can cancel it.
let sawSignal = false;
vi.stubGlobal('fetch', async (_url: string, init?: RequestInit) => {
sawSignal = init?.signal instanceof AbortSignal;
return new Response(JSON.stringify(mstFixture), { status: 200 });
});

await getNftMetadata(MST_CONTRACT, TOKEN_ID);
expect(sawSignal).toBe(true);
});

it('does NOT collapse a 429 (throttle) into NotFound — it is transient, not absent', async () => {
vi.stubGlobal('fetch', async () => new Response('', { status: 429 }));

const err = await getNftMetadata(MST_CONTRACT, TOKEN_ID).catch((e) => e);
expect(err).toBeInstanceOf(Error);
expect(err).not.toBeInstanceOf(NotFoundError); // a rate-limit ≠ "token doesn't exist"
});
});
Loading