Skip to content

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

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

feat: add endpoint badge type for external json data#1796
Moshyfawn wants to merge 5 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 5, 2026 11:22am
npmx.dev Ready Ready Preview, Comment Mar 5, 2026 11:22am
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
npmx-lunaria Ignored Ignored Mar 5, 2026 11:22am

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

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 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); maps endpoint fetch failures to 502; and adds two end-to-end tests for successful external JSON rendering and missing-URL validation.

Possibly related PRs

Suggested reviewers

  • ghostdevv
  • danielroe
🚥 Pre-merge checks | ✅ 1
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed The pull request description clearly explains the context, purpose, and URL format for the new endpoint badge type feature.

✏️ 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 +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

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.

♻️ Duplicate comments (3)
server/api/registry/badge/[type]/[...pkg].get.ts (3)

271-277: ⚠️ Potential issue | 🟠 Major

Still missing timeout and payload-size guards for endpoint fetches.

The outbound call is currently unbounded; slow or very large responses can hold workers and increase memory pressure.

🧯 Proposed hardening
 async function fetchEndpointBadge(url: string) {
-  const response = await fetch(url, { headers: { Accept: 'application/json' } })
+  const controller = new AbortController()
+  const timeout = setTimeout(() => controller.abort(), 5000)
+  let response: Response
+  try {
+    response = await fetch(url, {
+      headers: { Accept: 'application/json' },
+      signal: controller.signal,
+    })
+  } finally {
+    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)
As per coding guidelines: "Use error handling patterns consistently".

437-444: ⚠️ Potential issue | 🔴 Critical

URL validation is still too weak for endpoint mode (SSRF risk).

startsWith('https://') is bypassable for internal or credentialed URLs (https://localhost, private IPs, userinfo). Parse and validate structurally before fetch.

🛡️ Proposed validation tightening
+function validateEndpointUrl(input: string): string {
+  let parsed: URL
+  try {
+    parsed = new URL(input)
+  } catch {
+    throw createError({ statusCode: 400, message: 'Missing or invalid "url" query parameter.' })
+  }
+
+  if (parsed.protocol !== 'https:') {
+    throw createError({ statusCode: 400, message: 'Only HTTPS endpoint URLs are allowed.' })
+  }
+  if (parsed.username || parsed.password) {
+    throw createError({ statusCode: 400, message: 'Credentials are not allowed in endpoint URLs.' })
+  }
+
+  const host = parsed.hostname.toLowerCase()
+  if (
+    host === 'localhost'
+    || host.endsWith('.localhost')
+    || /^127\./.test(host)
+    || /^10\./.test(host)
+    || /^192\.168\./.test(host)
+    || /^172\.(1[6-9]|2\d|3[0-1])\./.test(host)
+  ) {
+    throw createError({ statusCode: 400, message: 'Local/private endpoint URLs are not allowed.' })
+  }
+
+  return parsed.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) {

488-492: ⚠️ Potential issue | 🟠 Major

Named endpoint colours are currently broken by # prefixing.

For shields-compatible endpoints, values like brightgreen are valid; current logic converts them to #brightgreen (invalid), so rendered colours can be wrong.

🎨 Proposed colour normalisation fix
+function normaliseBadgeColor(value: string, fallback: string): string {
+  const trimmed = value.trim()
+  const hex = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed
+
+  if (/^[0-9a-fA-F]{3,8}$/.test(hex)) {
+    return `#${hex}`
+  }
+  if (/^[a-zA-Z]+$/.test(trimmed)) {
+    return trimmed
+  }
+  return fallback
+}
+
     const finalLabel = userLabel ?? strategyResult.label
     const finalValue = strategyResult.value
     const rawColor = userColor ?? strategyResult.color ?? COLORS.slate
-    const finalColor = rawColor.startsWith('#') ? rawColor : `#${rawColor}`
+    const finalColor = normaliseBadgeColor(rawColor, COLORS.slate)
     const defaultLabelColor = badgeStyle === 'shieldsio' ? '#555' : '#0a0a0a'
     const rawLabelColor = labelColor ?? strategyResult.labelColor ?? defaultLabelColor
-    const finalLabelColor = rawLabelColor.startsWith('#') ? rawLabelColor : `#${rawLabelColor}`
+    const finalLabelColor = normaliseBadgeColor(rawLabelColor, defaultLabelColor)

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5e16b331-e707-42d8-b717-3e318dd4f084

📥 Commits

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

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

Copy link
Contributor

@ghostdevv ghostdevv left a comment

Choose a reason for hiding this comment

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

What is your usecase @Moshyfawn? The badges we provide are primarily for npmx data as opposed to being a generic badge service, which shields already does really well

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.

2 participants