Skip to content

feat(intent): public read-only registry API at /api/v1/intent#885

Open
tannerlinsley wants to merge 3 commits intomainfrom
taren/clever-banzai-a19355
Open

feat(intent): public read-only registry API at /api/v1/intent#885
tannerlinsley wants to merge 3 commits intomainfrom
taren/clever-banzai-a19355

Conversation

@tannerlinsley
Copy link
Copy Markdown
Member

@tannerlinsley tannerlinsley commented May 3, 2026

Summary

Adds five GET endpoints under /api/v1/intent/* so first-party consumers (next stop: the intent CLI) can search and resolve skills programmatically without scraping the registry UI. All handlers are thin wrappers around existing server functions / DB helpers — the same searchSkills, getIntentDirectory, etc. that power the registry website. One source of truth, two consumers.

  • GET /api/v1/intent/search?q=&limit= — skill search, ILIKE on name/description/content
  • GET /api/v1/intent/packages — paginated package directory with filter/sort
  • GET /api/v1/intent/packages/:name — package detail (auto-seeds from npm on cold miss)
  • GET /api/v1/intent/packages/:name/versions/:version/skills — version's skills
  • GET /api/v1/intent/packages/:name/versions/:version/skills/:skill — single skill metadata

No markdown body in default responses. Each skill carries content: { unpkg, jsdelivr } URLs pointing at the immutable npm tarball file; callers fetch raw content from CDN and verify integrity via contentHash. Our egress stays at metadata-only. The single-skill endpoint accepts ?include=markdown as an opt-in escape hatch (undocumented, last-resort).

Auth-aware rate limiting. Anonymous: 60 req/min keyed by IP. Authenticated (Authorization: Bearer ts_* / oa_* / mcp_*): 600 req/min keyed by validated token id, 401 on bad token. Added checkTokenRateLimit as a sibling to the existing checkIpRateLimit.

No OpenAPI spec for v1. Considered and dropped — TS types are the single source of truth, OpenAPI would just be a second contract to keep in sync without enough external demand to justify the maintenance burden. Revisit when there's real third-party consumption or when we're at v2 with more endpoints.

Smoke tested manually

Hit each endpoint against the dev DB:

GET /api/v1/intent/search?q=react&limit=2          → 200, results[].content URLs present, rate-limit headers correct
GET /api/v1/intent/packages/@apollo__client        → 200, versions array
GET /.../versions/4.1.8/skills                     → 200, skill metadata + content URLs
GET /.../skills/apollo-client                       → 200, content URLs only
GET /.../skills/apollo-client?include=markdown      → 200, markdown body present
GET /.../skills/nonexistent                         → 404
GET /api/v1/intent/search?q=react (bad bearer)      → 401

The unpkg URL we hand back resolves directly to the SKILL.md file (verified with curl).

Pre-existing issue noticed (not addressed here)

The site's raw .md route at /intent/registry/$packageName/{$}.md is shadowed by the page route at $packageName.$skillName — the named param wins precedence over {$}.md even with the literal .md suffix. So the legacy getIntentSkillMarkdown handler appears to be unreachable today; requests fall through to the rendered HTML page. Worth a separate small PR (rename the page route to use {$} like the docs .md pattern, then redirect to unpkg from the now-reachable .md route).

Test plan

  • pnpm test (tsc + lint) clean
  • Manual curl against dev server: all five endpoints, both anon and bad-auth paths, ?include=markdown opt-in
  • Verified contentHash is returned and the constructed unpkg URL resolves
  • Reviewer: confirm rate-limit preset values (60 anon / 600 authed) feel right for a public CLI fallback path

Summary by CodeRabbit

  • New Features
    • Public Intent API: search skills, list packages, browse package versions/skills, and optionally return skill markdown and CDN content URLs.
    • API rate limiting: supports anonymous and authenticated clients and surfaces limiter metadata in responses.
  • Bug Fixes / UX
    • Internal skill URLs/navigation updated to a splat-based route format for consistent linking and redirects.

Five GET endpoints wrapping existing server fns / DB helpers so external
consumers (e.g. the intent CLI) can search and resolve skills without
scraping the registry UI.

Markdown bodies aren't returned by default — responses include CDN URLs
(unpkg + jsdelivr) pointing at the immutable npm tarball, so our egress
stays near zero. Callers verify integrity via contentHash. The single-skill
endpoint accepts ?include=markdown as an opt-in escape hatch.

Auth-aware rate limiting: 60 req/min anonymous (IP-keyed) or 600 req/min
authenticated (token-keyed) via the existing MCP bearer flow. Adds
checkTokenRateLimit as a sibling to checkIpRateLimit.
@netlify
Copy link
Copy Markdown

netlify Bot commented May 3, 2026

Deploy Preview for tanstack ready!

Name Link
🔨 Latest commit 938cab4
🔍 Latest deploy log https://app.netlify.com/projects/tanstack/deploys/69f6f5150cfce7000a81aa43
😎 Deploy Preview https://deploy-preview-885--tanstack.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
Lighthouse
Lighthouse
1 paths audited
Performance: 35 (🔴 down 28 from production)
Accessibility: 90 (no change from production)
Best Practices: 83 (🔴 down 9 from production)
SEO: 97 (no change from production)
PWA: 70 (no change from production)
View the detailed breakdown and full score reports
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 3, 2026

📝 Walkthrough

Walkthrough

Adds a new Intent registry public API (five /api/v1/intent routes), shared intent API helpers and token/IP rate-limiting, skill content URL building, DB selection of skillPath, generated route typings, and switches internal intent/registry skill routes to use a TanStack Router splat (_splat) parameter.

Changes

Intent Registry Public API + Wiring

Layer / File(s) Summary
Data Shape / DB
src/utils/intent-db.server.ts
Adds `skillPath: string
Rate-limit primitives
src/utils/rateLimit.server.ts
Refactors IP rate limit to use buildRateLimitResult, adds checkTokenRateLimit(token, ...), and presets RATE_LIMITS.intentApi and intentApiAuthed.
API utilities
src/utils/intent-api.server.ts
New helpers: applyIntentRateLimit (auth-aware token/IP flow), intentJsonResponse, intentErrorResponse, buildSkillContentUrls, and related types.
Route handlers (core)
src/routes/api/v1/intent/search.ts, src/routes/api/v1/intent/packages.ts, src/routes/api/v1/intent/packages.$name.ts, src/routes/api/v1/intent/packages.$name.versions.$version.skills.ts, src/routes/api/v1/intent/packages.$name.versions.$version.skills.$skill.ts
Adds five GET endpoints implementing rate limiting, parameter parsing/validation, DB calls, content URL assembly, optional markdown loading, and standardized JSON/error responses.
Generated router types / wiring
src/routeTree.gen.ts
Generates/registers new /api/v1/intent/... routes in FileRoutesByFullPath, FileRoutesByTo, FileRoutesById, FileRouteTypes, RootRouteChildren, and module augmentation; removes the old /intent/registry/$packageName/$skillName route typing.

Internal Intent Registry Splat Migration

Layer / File(s) Summary
Route path change (route file)
src/routes/intent/registry/$packageName.{$}.tsx, src/routes/intent/registry/$packageName.{$}[.]md.tsx
Converts skill detail route from $skillName segment to splat {$}; loaders and handlers now read params._splat for skillName; md route may redirect to CDN when skillPath exists.
Link targets / components
src/components/intent/SkillDependencyGraph.tsx, src/routes/intent/registry/$packageName.index.tsx, src/routes/intent/registry/$packageName.tsx, src/routes/intent/registry/index.tsx
Updates <Link> usages to target "/intent/registry/$packageName/{$}" and pass skill via params._splat instead of skillName; UI derives active skill from _splat.
Generated router types / wiring
src/routeTree.gen.ts
Removes $skillName-based child route wiring and typings for the old path as part of generation updates.

Sequence Diagram

sequenceDiagram
    participant Client
    participant IntentAPI (Server)
    participant AuthService
    participant RateLimiter
    participant Database
    participant CDNBuilder

    Client->>IntentAPI: GET /api/v1/intent/search?q=...
    IntentAPI->>AuthService: parse Authorization header
    AuthService-->>IntentAPI: auth context (userId / unauthenticated)
    IntentAPI->>RateLimiter: checkTokenRateLimit or checkIpRateLimit
    alt rate limited
        RateLimiter-->>Client: 429 JSON with rate-limit headers
    else allowed
        IntentAPI->>Database: searchSkills(q, limit)
        Database-->>IntentAPI: rows (include skillPath)
        IntentAPI->>CDNBuilder: buildSkillContentUrls(package, version, skillPath)
        CDNBuilder-->>IntentAPI: { unpkg, jsdelivr } or null
        IntentAPI-->>Client: 200 JSON + RL headers (results + content URLs)
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I hopped through routes both new and spry,
Five endpoints stitched beneath the sky.
Tokens guard the carrot trail, IP keeps the pace,
CDN crumbs lead to the skill's sweet place—
thump Intent’s garden grows, a cozy, hoppy space.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: adding a public read-only registry API at /api/v1/intent with five new GET endpoints for accessing intent package/skill data.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch taren/clever-banzai-a19355

Review rate limit: 4/5 reviews remaining, refill in 12 minutes.

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

Copy link
Copy Markdown

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/utils/rateLimit.server.ts (1)

70-74: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Clamp Retry-After to non-negative seconds.

Math.ceil((resetAt - now)/1000) can go negative near boundary/clock skew, which produces invalid retry hints.

Suggested patch
@@
   if (!result.allowed) {
+    const retryAfter = Math.max(
+      0,
+      Math.ceil((result.resetAt.getTime() - Date.now()) / 1000),
+    )
     headers.set(
       'Retry-After',
-      Math.ceil((result.resetAt.getTime() - Date.now()) / 1000).toString(),
+      retryAfter.toString(),
     )
   }
@@
 export function rateLimitedResponse(result: RateLimitResult): Response {
+  const retryAfter = Math.max(
+    0,
+    Math.ceil((result.resetAt.getTime() - Date.now()) / 1000),
+  )
   return new Response(
     JSON.stringify({
       error: 'Rate limit exceeded',
-      retryAfter: Math.ceil((result.resetAt.getTime() - Date.now()) / 1000),
+      retryAfter,
     }),

Also applies to: 88-93

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/rateLimit.server.ts` around lines 70 - 74, The Retry-After header
calculation can produce negative values when resetAt is very near or before now;
update the two places that set headers.set('Retry-After', ...) to clamp the
computed seconds to a non-negative integer (e.g., compute secs = Math.max(0,
Math.ceil((result.resetAt.getTime() - Date.now())/1000)) and use
secs.toString()) so the header never contains a negative value; locate the
assignments that reference result.resetAt, Math.ceil and headers.set in
src/utils/rateLimit.server.ts and replace them with the clamped calculation.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/routes/api/v1/intent/packages.ts`:
- Around line 27-31: The parsed page value can be negative (e.g., page = -1) and
is forwarded downstream; clamp it to zero by normalizing the parsed value before
use: after computing page from parseInt(url.searchParams.get('page') ...),
ensure you set page = Math.max(parsedPage, 0) so the variable used later (page)
cannot be negative; update the pagination logic where the page variable is
defined to use this clamping (refer to the page variable in
src/routes/api/v1/intent/packages.ts).

In `@src/utils/intent-api.server.ts`:
- Around line 113-123: buildSkillContentUrls is constructing CDN URLs by
directly interpolating skillPath, which can produce malformed URLs when segments
contain reserved characters; fix by splitting skillPath on '/' and applying
encodeURIComponent to each segment (preserving directory separators), then join
the encoded segments to build the path used in the `path` variable before
composing the unpkg/jsdelivr URLs in buildSkillContentUrls.

---

Outside diff comments:
In `@src/utils/rateLimit.server.ts`:
- Around line 70-74: The Retry-After header calculation can produce negative
values when resetAt is very near or before now; update the two places that set
headers.set('Retry-After', ...) to clamp the computed seconds to a non-negative
integer (e.g., compute secs = Math.max(0, Math.ceil((result.resetAt.getTime() -
Date.now())/1000)) and use secs.toString()) so the header never contains a
negative value; locate the assignments that reference result.resetAt, Math.ceil
and headers.set in src/utils/rateLimit.server.ts and replace them with the
clamped calculation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8a370c06-a1eb-463e-9daa-da26c16bb824

📥 Commits

Reviewing files that changed from the base of the PR and between bb26993 and c4aa7ec.

📒 Files selected for processing (9)
  • src/routeTree.gen.ts
  • src/routes/api/v1/intent/packages.$name.ts
  • src/routes/api/v1/intent/packages.$name.versions.$version.skills.$skill.ts
  • src/routes/api/v1/intent/packages.$name.versions.$version.skills.ts
  • src/routes/api/v1/intent/packages.ts
  • src/routes/api/v1/intent/search.ts
  • src/utils/intent-api.server.ts
  • src/utils/intent-db.server.ts
  • src/utils/rateLimit.server.ts

Comment on lines +27 to +31
const page = parseInt(url.searchParams.get('page') ?? '0', 10) || 0
const pageSize = Math.min(
Math.max(parseInt(url.searchParams.get('pageSize') ?? '24', 10) || 24, 1),
100,
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Normalize negative page values to 0.

page=-1 currently survives parsing and is forwarded downstream; clamp to avoid invalid pagination input.

Suggested patch
-        const page = parseInt(url.searchParams.get('page') ?? '0', 10) || 0
+        const parsedPage = parseInt(url.searchParams.get('page') ?? '0', 10)
+        const page = Number.isNaN(parsedPage) ? 0 : Math.max(parsedPage, 0)
📝 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
const page = parseInt(url.searchParams.get('page') ?? '0', 10) || 0
const pageSize = Math.min(
Math.max(parseInt(url.searchParams.get('pageSize') ?? '24', 10) || 24, 1),
100,
)
const parsedPage = parseInt(url.searchParams.get('page') ?? '0', 10)
const page = Number.isNaN(parsedPage) ? 0 : Math.max(parsedPage, 0)
const pageSize = Math.min(
Math.max(parseInt(url.searchParams.get('pageSize') ?? '24', 10) || 24, 1),
100,
)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/api/v1/intent/packages.ts` around lines 27 - 31, The parsed page
value can be negative (e.g., page = -1) and is forwarded downstream; clamp it to
zero by normalizing the parsed value before use: after computing page from
parseInt(url.searchParams.get('page') ...), ensure you set page =
Math.max(parsedPage, 0) so the variable used later (page) cannot be negative;
update the pagination logic where the page variable is defined to use this
clamping (refer to the page variable in src/routes/api/v1/intent/packages.ts).

Comment on lines +113 to +123
export function buildSkillContentUrls(
packageName: string,
version: string,
skillPath: string | null,
): SkillContentUrls | null {
if (!skillPath) return null
const path = `${packageName}@${version}/skills/${skillPath}/SKILL.md`
return {
unpkg: `https://unpkg.com/${path}`,
jsdelivr: `https://cdn.jsdelivr.net/npm/${path}`,
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Encode skillPath segments before composing CDN URLs.

Raw interpolation can produce malformed URLs when skillPath contains reserved characters (spaces, #, etc.).

Suggested patch
 export function buildSkillContentUrls(
   packageName: string,
   version: string,
   skillPath: string | null,
 ): SkillContentUrls | null {
   if (!skillPath) return null
-  const path = `${packageName}@${version}/skills/${skillPath}/SKILL.md`
+  const encodedSkillPath = skillPath
+    .split('/')
+    .map((segment) => encodeURIComponent(segment))
+    .join('/')
+  const path = `${packageName}@${version}/skills/${encodedSkillPath}/SKILL.md`
   return {
     unpkg: `https://unpkg.com/${path}`,
     jsdelivr: `https://cdn.jsdelivr.net/npm/${path}`,
   }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/intent-api.server.ts` around lines 113 - 123, buildSkillContentUrls
is constructing CDN URLs by directly interpolating skillPath, which can produce
malformed URLs when segments contain reserved characters; fix by splitting
skillPath on '/' and applying encodeURIComponent to each segment (preserving
directory separators), then join the encoded segments to build the path used in
the `path` variable before composing the unpkg/jsdelivr URLs in
buildSkillContentUrls.

tannerlinsley and others added 2 commits May 3, 2026 01:09
The skill page route used a named param ($skillName), so the more-specific
.md sibling route ({$}.md) was being shadowed by it — TanStack Router's
literal-suffix precedence only kicks in when both routes use the same
param style. The legacy markdown handler was effectively unreachable;
.md URLs were rendering the HTML skill page instead.

Convert the page route to splat ({$}.tsx, matching the docs route pattern),
which lets the .md sibling win precedence as intended. The .md handler now
redirects to the unpkg CDN URL for the underlying SKILL.md, matching the
public API behavior — zero egress for raw markdown bytes. Falls back to
DB-served content for legacy records without a skillPath.

Mechanical updates: every Link to="/intent/registry/$packageName/$skillName"
becomes "/$packageName/{$}" with params { _splat: ... } instead of
{ skillName: ... }, and useParams destructures rename to _splat.
Copy link
Copy Markdown

@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)
src/routes/intent/registry/$packageName.{$}[.]md.tsx (1)

31-39: ⚡ Quick win

Make the CDN redirect cacheable.

Line 38 returns a plain 302, but this route is already version-pinned, so the unpkg target is immutable. That means clients/CDNs may keep re-hitting the app just to rediscover the same URL, which undercuts the CDN offload this change is aiming for. Prefer a permanent redirect here, or attach explicit cache headers to the redirect response.

Proposed change
-              return Response.redirect(urls.unpkg, 302)
+              return new Response(null, {
+                status: 308,
+                headers: {
+                  Location: urls.unpkg,
+                  'Cache-Control': 'public, max-age=31536000, immutable',
+                },
+              })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/intent/registry/`$packageName.{$}[.]md.tsx around lines 31 - 39,
The redirect from buildSkillContentUrls(...) currently returns
Response.redirect(..., 302); change it to a cacheable permanent redirect by
either using Response.redirect(urls.unpkg, 301) or by returning a Response with
the Location header set to urls.unpkg and explicit cache headers (e.g.
Cache-Control: public, max-age=31536000, immutable) so the version-pinned
packageName/version redirect is cached by clients/CDNs; update the branch where
skill?.skillPath is handled to return the new response instead of the 302.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/routes/intent/registry/`$packageName.{$}[.]md.tsx:
- Around line 31-39: The redirect from buildSkillContentUrls(...) currently
returns Response.redirect(..., 302); change it to a cacheable permanent redirect
by either using Response.redirect(urls.unpkg, 301) or by returning a Response
with the Location header set to urls.unpkg and explicit cache headers (e.g.
Cache-Control: public, max-age=31536000, immutable) so the version-pinned
packageName/version redirect is cached by clients/CDNs; update the branch where
skill?.skillPath is handled to return the new response instead of the 302.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 97e83bb1-b98a-4aef-9c5f-92485cff7bab

📥 Commits

Reviewing files that changed from the base of the PR and between c4aa7ec and 938cab4.

📒 Files selected for processing (7)
  • src/components/intent/SkillDependencyGraph.tsx
  • src/routeTree.gen.ts
  • src/routes/intent/registry/$packageName.index.tsx
  • src/routes/intent/registry/$packageName.tsx
  • src/routes/intent/registry/$packageName.{$}.tsx
  • src/routes/intent/registry/$packageName.{$}[.]md.tsx
  • src/routes/intent/registry/index.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/routeTree.gen.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