Skip to content

feat: add endpoint badge type for external json data#1796

Open
Moshyfawn wants to merge 4 commits intonpmx-dev:mainfrom
Moshyfawn:feat/endpoint-badge
Open

feat: add endpoint badge type for external json data#1796
Moshyfawn wants to merge 4 commits intonpmx-dev:mainfrom
Moshyfawn:feat/endpoint-badge

Conversation

@Moshyfawn
Copy link
Contributor

🧭 Context

Some projects like Solid Primitives use custom metadata hosted as JSON files on GitHub to display project-specific badges.

📚 Description

Adds an endpoint badge type that fetches and renders badge data from an external shields.io compatible JSON endpoint.

URL format: /api/registry/badge/endpoint/_?url=https://...

@vercel
Copy link

vercel bot commented Mar 1, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs.npmx.dev Ready Ready Preview, Comment Mar 2, 2026 9:00pm
npmx.dev Ready Ready Preview, Comment Mar 2, 2026 9:00pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
npmx-lunaria Ignored Ignored Mar 2, 2026 9:00pm

Request Review

@codecov
Copy link

codecov bot commented Mar 1, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ All tests successful. No failed tests found.

📢 Thoughts on this report? Let us know!

@Moshyfawn
Copy link
Contributor Author

Moshyfawn commented Mar 1, 2026

This PR was sparked by https://bsky.app/profile/davedbase.com/post/3mfz63ldthc2p so both the E2E tests and the docs include the Solid Primitives assets as examples. Let me know if you want npmx specific urls instead we could come up with.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 1, 2026

📝 Walkthrough

Walkthrough

Adds an "Endpoint Badge" feature: documentation and examples; cache logic to return a mock stage badge for GitHub raw URLs matching stage-<digits>.json; server API changes to handle badge type endpoint including URL validation, EndpointResponseSchema, fetchEndpointBadge, and mapping remote fields to badge properties; consolidates badge rendering to use a unified strategyResult flow with existing caching and renderer selection (shields.io/default); adjusts endpoint fetch errors to return 502; and adds two end-to-end tests for successful external JSON rendering and missing-URL validation.

Possibly related PRs

Suggested reviewers

  • danielroe
  • ghostdevv
🚥 Pre-merge checks | ✅ 1
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed The pull request description clearly relates to the changeset, explaining the addition of an endpoint badge type that fetches data from external JSON endpoints.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2


ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 55ff287 and 8245d7e.

📒 Files selected for processing (4)
  • docs/content/2.guide/1.features.md
  • modules/runtime/server/cache.ts
  • server/api/registry/badge/[type]/[...pkg].get.ts
  • test/e2e/badge.spec.ts

@Moshyfawn Moshyfawn changed the title feat(badge): add endpoint badge type for external json data feat: add endpoint badge type for external json data Mar 1, 2026
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3


ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8245d7e and 86439b4.

📒 Files selected for processing (1)
  • server/api/registry/badge/[type]/[...pkg].get.ts

Comment on lines +259 to +271
async function fetchEndpointBadge(url: string) {
const response = await fetch(url, { headers: { Accept: 'application/json' } })
if (!response.ok) {
throw createError({ statusCode: 502, message: `Endpoint returned ${response.status}` })
}
const data = await response.json()
const parsed = v.parse(EndpointResponseSchema, data)
return {
label: parsed.label,
value: parsed.message,
color: parsed.color,
labelColor: parsed.labelColor,
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Escape and validate endpoint-derived values before SVG interpolation.

label, message, and colour values from external JSON flow directly into SVG attributes/text. A malicious endpoint can inject markup or break the SVG structure.

🛡️ Proposed fix
+function escapeXml(value: string): string {
+  return value
+    .replaceAll('&', '&amp;')
+    .replaceAll('<', '&lt;')
+    .replaceAll('>', '&gt;')
+    .replaceAll('"', '&quot;')
+    .replaceAll("'", '&apos;')
+}
+
+function normalizeBadgeColor(value: string, fallback: string): string {
+  const trimmed = value.trim()
+  const isSafeColour = /^#?[0-9a-fA-F]{3,8}$|^[a-zA-Z]+$/.test(trimmed)
+  if (!isSafeColour) return fallback
+  return trimmed.startsWith('#') ? trimmed : `#${trimmed}`
+}
+
 async function fetchEndpointBadge(url: string) {
   const response = await fetch(url, { headers: { Accept: 'application/json' } })
   if (!response.ok) {
     throw createError({ statusCode: 502, message: `Endpoint returned ${response.status}` })
   }
   const data = await response.json()
   const parsed = v.parse(EndpointResponseSchema, data)
   return {
-    label: parsed.label,
-    value: parsed.message,
+    label: escapeXml(parsed.label),
+    value: escapeXml(parsed.message),
     color: parsed.color,
     labelColor: parsed.labelColor,
   }
 }
@@
-    const finalLabel = userLabel ?? strategyResult.label
-    const finalValue = strategyResult.value
-    const rawColor = userColor ?? strategyResult.color ?? COLORS.slate
-    const finalColor = rawColor.startsWith('#') ? rawColor : `#${rawColor}`
+    const finalLabel = escapeXml(userLabel ?? strategyResult.label)
+    const finalValue = escapeXml(strategyResult.value)
+    const rawColor = userColor ?? strategyResult.color ?? COLORS.slate
+    const finalColor = normalizeBadgeColor(rawColor, COLORS.slate)
@@
-    const rawLabelColor = labelColor ?? strategyResult.labelColor ?? defaultLabelColor
-    const finalLabelColor = rawLabelColor.startsWith('#') ? rawLabelColor : `#${rawLabelColor}`
+    const rawLabelColor = labelColor ?? strategyResult.labelColor ?? defaultLabelColor
+    const finalLabelColor = normalizeBadgeColor(rawLabelColor, defaultLabelColor)

Also applies to: 474-483

Comment on lines +259 to +265
async function fetchEndpointBadge(url: string) {
const response = await fetch(url, { headers: { Accept: 'application/json' } })
if (!response.ok) {
throw createError({ statusCode: 502, message: `Endpoint returned ${response.status}` })
}
const data = await response.json()
const parsed = v.parse(EndpointResponseSchema, data)
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Add timeout and response-size guards for endpoint fetches.

The outbound call is currently unbounded. Slow or very large responses can tie up workers and increase memory pressure.

🧯 Proposed fix
 async function fetchEndpointBadge(url: string) {
-  const response = await fetch(url, { headers: { Accept: 'application/json' } })
+  const controller = new AbortController()
+  const timeout = setTimeout(() => controller.abort(), 5000)
+  const response = await fetch(url, {
+    headers: { Accept: 'application/json' },
+    signal: controller.signal,
+  })
+  clearTimeout(timeout)
+
   if (!response.ok) {
     throw createError({ statusCode: 502, message: `Endpoint returned ${response.status}` })
   }
+
+  const contentLength = Number(response.headers.get('content-length') ?? 0)
+  if (Number.isFinite(contentLength) && contentLength > 64_000) {
+    throw createError({ statusCode: 502, message: 'Endpoint response is too large.' })
+  }
+
   const data = await response.json()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function fetchEndpointBadge(url: string) {
const response = await fetch(url, { headers: { Accept: 'application/json' } })
if (!response.ok) {
throw createError({ statusCode: 502, message: `Endpoint returned ${response.status}` })
}
const data = await response.json()
const parsed = v.parse(EndpointResponseSchema, data)
async function fetchEndpointBadge(url: string) {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 5000)
const response = await fetch(url, {
headers: { Accept: 'application/json' },
signal: controller.signal,
})
clearTimeout(timeout)
if (!response.ok) {
throw createError({ statusCode: 502, message: `Endpoint returned ${response.status}` })
}
const contentLength = Number(response.headers.get('content-length') ?? 0)
if (Number.isFinite(contentLength) && contentLength > 64_000) {
throw createError({ statusCode: 502, message: 'Endpoint response is too large.' })
}
const data = await response.json()
const parsed = v.parse(EndpointResponseSchema, data)

Comment on lines +425 to +427
const endpointUrl = typeof query.url === 'string' ? query.url : undefined
if (!endpointUrl || !endpointUrl.startsWith('https://')) {
throw createError({ statusCode: 400, message: 'Missing or invalid "url" query parameter.' })
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Harden endpoint URL validation to prevent SSRF.

The current https:// prefix check is bypassable for internal targets (e.g. localhost aliases, private hosts, credentialed URLs). Parse and validate the URL structurally before fetch.

🛡️ Proposed fix
+function validateEndpointUrl(input: string): string {
+  let url: URL
+  try {
+    url = new URL(input)
+  } catch {
+    throw createError({ statusCode: 400, message: 'Invalid "url" query parameter.' })
+  }
+
+  if (url.protocol !== 'https:') {
+    throw createError({ statusCode: 400, message: 'Only HTTPS endpoint URLs are allowed.' })
+  }
+
+  if (url.username || url.password) {
+    throw createError({ statusCode: 400, message: 'Credentials are not allowed in endpoint URLs.' })
+  }
+
+  const blockedHosts = new Set(['localhost', '127.0.0.1', '::1'])
+  if (blockedHosts.has(url.hostname)) {
+    throw createError({ statusCode: 400, message: 'Local endpoint URLs are not allowed.' })
+  }
+
+  return url.toString()
+}
+
     if (typeParam === 'endpoint') {
       const endpointUrl = typeof query.url === 'string' ? query.url : undefined
-      if (!endpointUrl || !endpointUrl.startsWith('https://')) {
+      if (!endpointUrl) {
         throw createError({ statusCode: 400, message: 'Missing or invalid "url" query parameter.' })
       }
+      const validatedEndpointUrl = validateEndpointUrl(endpointUrl)
 
       try {
-        strategyResult = await fetchEndpointBadge(endpointUrl)
+        strategyResult = await fetchEndpointBadge(validatedEndpointUrl)
       } catch (error: unknown) {
         handleApiError(error, { statusCode: 502, message: 'Failed to fetch endpoint data.' })
       }

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
docs/content/2.guide/1.features.md (1)

127-127: Prefer showing a URL-encoded url parameter in the endpoint badge docs.

At Line 158, using an encoded nested URL makes the example safer to copy for endpoints that include their own query string.

Suggested docs tweak
-- **endpoint**: Displays data from an external JSON endpoint via `url` query parameter. :img{src="https://img.shields.io/badge/%2364748b-64748b" class="inline align-middle h-5 w-14"}
+- **endpoint**: Displays data from an external JSON endpoint via a URL-encoded `url` query parameter. :img{src="https://img.shields.io/badge/%2364748b-64748b" class="inline align-middle h-5 w-14"}

-[![Stage](https://npmx.dev/api/registry/badge/endpoint/_?url=https://raw.githubusercontent.com/solidjs-community/solid-primitives/af34b836baba599c525d0db4b1c9871dd0b13f27/assets/badges/stage-2.json)](https://github.com/solidjs-community/solid-primitives)
+[![Stage](https://npmx.dev/api/registry/badge/endpoint/_?url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Faf34b836baba599c525d0db4b1c9871dd0b13f27%2Fassets%2Fbadges%2Fstage-2.json)](https://github.com/solidjs-community/solid-primitives)

Also applies to: 156-158


ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8635172 and 9d00a33.

📒 Files selected for processing (2)
  • docs/content/2.guide/1.features.md
  • modules/runtime/server/cache.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant