feat(intent): public read-only registry API at /api/v1/intent#885
feat(intent): public read-only registry API at /api/v1/intent#885tannerlinsley wants to merge 3 commits intomainfrom
Conversation
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.
✅ Deploy Preview for tanstack ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
📝 WalkthroughWalkthroughAdds 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 ( ChangesIntent Registry Public API + Wiring
Internal Intent Registry Splat Migration
Sequence DiagramsequenceDiagram
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Review rate limit: 4/5 reviews remaining, refill in 12 minutes. Comment |
There was a problem hiding this comment.
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 winClamp
Retry-Afterto 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
📒 Files selected for processing (9)
src/routeTree.gen.tssrc/routes/api/v1/intent/packages.$name.tssrc/routes/api/v1/intent/packages.$name.versions.$version.skills.$skill.tssrc/routes/api/v1/intent/packages.$name.versions.$version.skills.tssrc/routes/api/v1/intent/packages.tssrc/routes/api/v1/intent/search.tssrc/utils/intent-api.server.tssrc/utils/intent-db.server.tssrc/utils/rateLimit.server.ts
| 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, | ||
| ) |
There was a problem hiding this comment.
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.
| 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).
| 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}`, | ||
| } |
There was a problem hiding this comment.
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.
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.
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/routes/intent/registry/$packageName.{$}[.]md.tsx (1)
31-39: ⚡ Quick winMake 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
📒 Files selected for processing (7)
src/components/intent/SkillDependencyGraph.tsxsrc/routeTree.gen.tssrc/routes/intent/registry/$packageName.index.tsxsrc/routes/intent/registry/$packageName.tsxsrc/routes/intent/registry/$packageName.{$}.tsxsrc/routes/intent/registry/$packageName.{$}[.]md.tsxsrc/routes/intent/registry/index.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
- src/routeTree.gen.ts

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 samesearchSkills,getIntentDirectory, etc. that power the registry website. One source of truth, two consumers.GET /api/v1/intent/search?q=&limit=— skill search,ILIKEon name/description/contentGET /api/v1/intent/packages— paginated package directory with filter/sortGET /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 skillsGET /api/v1/intent/packages/:name/versions/:version/skills/:skill— single skill metadataNo 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 viacontentHash. Our egress stays at metadata-only. The single-skill endpoint accepts?include=markdownas 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. AddedcheckTokenRateLimitas a sibling to the existingcheckIpRateLimit.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:
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
.mdroute at/intent/registry/$packageName/{$}.mdis shadowed by the page route at$packageName.$skillName— the named param wins precedence over{$}.mdeven with the literal.mdsuffix. So the legacygetIntentSkillMarkdownhandler 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.mdpattern, then redirect to unpkg from the now-reachable.mdroute).Test plan
pnpm test(tsc + lint) cleancurlagainst dev server: all five endpoints, both anon and bad-auth paths,?include=markdownopt-incontentHashis returned and the constructed unpkg URL resolvesSummary by CodeRabbit