{skill.name}
@@ -607,8 +607,8 @@ function ChangelogSkillRow({
{statusConfig.prefix}
{name}
diff --git a/src/routes/intent/registry/$packageName.tsx b/src/routes/intent/registry/$packageName.tsx
index 525b6f4a..001c5596 100644
--- a/src/routes/intent/registry/$packageName.tsx
+++ b/src/routes/intent/registry/$packageName.tsx
@@ -142,9 +142,9 @@ function PackageLayoutInner({
readonly activeVersion: string
readonly setVersion: (v: string) => void
}) {
- const { packageName, skillName } = useParams({ strict: false }) as {
+ const { packageName, _splat: skillName } = useParams({ strict: false }) as {
packageName: string
- skillName?: string
+ _splat?: string
}
const skillsQuery = useSuspenseQuery(
@@ -410,8 +410,8 @@ function SkillsNav({
return (
})),
)
-export const Route = createFileRoute(
- '/intent/registry/$packageName/$skillName',
-)({
+export const Route = createFileRoute('/intent/registry/$packageName/{$}')({
loaderDeps: ({ search }) => ({ version: search.version }),
loader: async ({ params, deps, context: { queryClient } }) => {
const name = decodePkgName(params.packageName)
+ const skillName = params._splat ?? ''
const detail = queryClient.getQueryData(
intentPackageDetailQueryOptions(name).queryKey,
)
const latestVersion = detail?.versions[0]?.version ?? ''
const activeVersion = deps.version ?? latestVersion
- if (activeVersion) {
+ if (activeVersion && skillName) {
await queryClient.ensureQueryData(
intentVersionSkillsQueryOptions({
packageName: name,
@@ -48,7 +47,7 @@ export const Route = createFileRoute(
return getIntentSkillPage({
data: {
packageName: name,
- skillName: params.skillName,
+ skillName,
version: activeVersion,
},
})
@@ -58,10 +57,11 @@ export const Route = createFileRoute(
},
head: ({ params }) => {
const pkgName = decodePkgName(params.packageName)
+ const skillName = params._splat ?? ''
return {
meta: seo({
- title: `${params.skillName} | ${pkgName} | Agent Skills Registry | TanStack Intent`,
- description: `Agent Skill "${params.skillName}" from ${pkgName}.`,
+ title: `${skillName} | ${pkgName} | Agent Skills Registry | TanStack Intent`,
+ description: `Agent Skill "${skillName}" from ${pkgName}.`,
}),
}
},
@@ -69,7 +69,7 @@ export const Route = createFileRoute(
})
function SkillDetailPage() {
- const { packageName, skillName } = Route.useParams()
+ const { packageName, _splat: skillName = '' } = Route.useParams()
const skillPage = Route.useLoaderData()
const pkgName = decodePkgName(packageName)
const { activeVersion } = usePackageVersion()
@@ -208,8 +208,8 @@ function SkillDetailPage() {
{skill.requires.map((req) => (
{req}
diff --git a/src/routes/intent/registry/$packageName.{$}[.]md.tsx b/src/routes/intent/registry/$packageName.{$}[.]md.tsx
index 41968a2a..9bf0d906 100644
--- a/src/routes/intent/registry/$packageName.{$}[.]md.tsx
+++ b/src/routes/intent/registry/$packageName.{$}[.]md.tsx
@@ -1,5 +1,10 @@
import { createFileRoute } from '@tanstack/react-router'
import { getIntentSkillMarkdown } from '~/utils/intent.functions'
+import {
+ getPackageVersions,
+ getSkillsForVersion,
+} from '~/utils/intent-db.server'
+import { buildSkillContentUrls } from '~/utils/intent-api.server'
import { decodePkgName } from './$packageName'
export const Route = createFileRoute('/intent/registry/$packageName/{$}.md')({
@@ -14,12 +19,30 @@ export const Route = createFileRoute('/intent/registry/$packageName/{$}.md')({
return new Response('Missing version', { status: 400 })
}
+ const packageName = decodePkgName(params.packageName)
+
+ const versions = await getPackageVersions(packageName)
+ const versionRecord = versions.find((v) => v.version === version)
+
+ if (versionRecord) {
+ const skills = await getSkillsForVersion(versionRecord.id)
+ const skill = skills.find((s) => s.name === skillName)
+
+ if (skill?.skillPath) {
+ const urls = buildSkillContentUrls(
+ packageName,
+ version,
+ skill.skillPath,
+ )
+ if (urls) {
+ return Response.redirect(urls.unpkg, 302)
+ }
+ }
+ }
+
+ // Fallback: legacy records without skillPath. Serve from DB.
const content = await getIntentSkillMarkdown({
- data: {
- packageName: decodePkgName(params.packageName),
- skillName,
- version,
- },
+ data: { packageName, skillName, version },
})
if (!content) {
diff --git a/src/routes/intent/registry/index.tsx b/src/routes/intent/registry/index.tsx
index acfd715a..ec9d43a0 100644
--- a/src/routes/intent/registry/index.tsx
+++ b/src/routes/intent/registry/index.tsx
@@ -749,10 +749,10 @@ function SkillHitRow({
}) {
return (
diff --git a/src/utils/intent-api.server.ts b/src/utils/intent-api.server.ts
new file mode 100644
index 00000000..421d11e7
--- /dev/null
+++ b/src/utils/intent-api.server.ts
@@ -0,0 +1,138 @@
+/**
+ * Shared helpers for the public Intent registry API (/api/v1/intent/*).
+ *
+ * Handles auth-aware rate limiting and response shaping so each route handler
+ * stays a thin wrapper around the underlying server functions / DB helpers.
+ */
+
+import {
+ RATE_LIMITS,
+ checkIpRateLimit,
+ checkTokenRateLimit,
+ rateLimitedResponse,
+ type RateLimitResult,
+} from './rateLimit.server'
+import { validateMcpAuth } from '~/mcp/auth.server'
+
+export interface IntentApiAuth {
+ authenticated: boolean
+ userId: string | null
+}
+
+export interface IntentRateLimitOutcome {
+ limited: false
+ rl: RateLimitResult
+ auth: IntentApiAuth
+}
+
+export interface IntentRateLimitedOutcome {
+ limited: true
+ response: Response
+}
+
+export type IntentRateLimitDecision =
+ | IntentRateLimitOutcome
+ | IntentRateLimitedOutcome
+
+/**
+ * Apply rate limiting to a request. If an Authorization: Bearer header is
+ * present and valid, uses the higher token-keyed tier. If absent, uses the
+ * anonymous IP-keyed tier. If present but invalid, returns 401.
+ */
+export async function applyIntentRateLimit(
+ request: Request,
+): Promise {
+ const authHeader = request.headers.get('authorization')
+
+ if (authHeader) {
+ const authResult = await validateMcpAuth(authHeader)
+ if (!authResult.success) {
+ return {
+ limited: true,
+ response: Response.json(
+ { error: authResult.error, code: 'UNAUTHORIZED' },
+ { status: authResult.status },
+ ),
+ }
+ }
+
+ const rl = await checkTokenRateLimit(
+ authResult.keyId,
+ RATE_LIMITS.intentApiAuthed,
+ )
+ if (!rl.allowed) return { limited: true, response: rateLimitedResponse(rl) }
+ return {
+ limited: false,
+ rl,
+ auth: { authenticated: true, userId: authResult.userId },
+ }
+ }
+
+ const rl = await checkIpRateLimit(request, RATE_LIMITS.intentApi)
+ if (!rl.allowed) return { limited: true, response: rateLimitedResponse(rl) }
+ return {
+ limited: false,
+ rl,
+ auth: { authenticated: false, userId: null },
+ }
+}
+
+/**
+ * Build a JSON response that merges rate-limit headers and standard
+ * Cache-Control for the public read endpoints.
+ */
+export function intentJsonResponse(
+ body: unknown,
+ rl: RateLimitResult,
+ init?: { status?: number; cache?: boolean },
+): Response {
+ const headers = new Headers(rl.headers)
+ headers.set('Content-Type', 'application/json')
+ if (init?.cache !== false) {
+ headers.set('Cache-Control', 'public, max-age=60, s-maxage=300')
+ }
+ return new Response(JSON.stringify(body), {
+ status: init?.status ?? 200,
+ headers,
+ })
+}
+
+/**
+ * CDN URLs for raw skill content. The skill file lives at
+ * `package/skills/{skillPath}/SKILL.md` inside the npm tarball; unpkg/jsdelivr
+ * serve it at `/{name}@{version}/skills/{skillPath}/SKILL.md`.
+ *
+ * Both URLs point at immutable, content-addressable npm tarball contents and
+ * are heavily edge-cached. Callers should verify integrity using `contentHash`.
+ */
+export interface SkillContentUrls {
+ unpkg: string
+ jsdelivr: string
+}
+
+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}`,
+ }
+}
+
+export function intentErrorResponse(
+ error: string,
+ code: string,
+ status: number,
+ rl?: RateLimitResult,
+): Response {
+ const headers = new Headers(rl?.headers)
+ headers.set('Content-Type', 'application/json')
+ return new Response(JSON.stringify({ error, code }), {
+ status,
+ headers,
+ })
+}
diff --git a/src/utils/intent-db.server.ts b/src/utils/intent-db.server.ts
index 02f55f08..fa3fffc2 100644
--- a/src/utils/intent-db.server.ts
+++ b/src/utils/intent-db.server.ts
@@ -216,6 +216,7 @@ export interface SkillSearchResult {
description: string | null
type: string | null
framework: string | null
+ skillPath: string | null
packageName: string
version: string
versionId: number
@@ -236,6 +237,7 @@ export async function searchSkills(
description: intentSkills.description,
type: intentSkills.type,
framework: intentSkills.framework,
+ skillPath: intentSkills.skillPath,
packageName: intentPackageVersions.packageName,
version: intentPackageVersions.version,
versionId: intentPackageVersions.id,
diff --git a/src/utils/rateLimit.server.ts b/src/utils/rateLimit.server.ts
index 7a67cab1..6b36dd7c 100644
--- a/src/utils/rateLimit.server.ts
+++ b/src/utils/rateLimit.server.ts
@@ -33,8 +33,34 @@ export async function checkIpRateLimit(
const result = await checkRateLimit(identifier, 'ip', options.limitPerMinute)
+ return buildRateLimitResult(result, options.limitPerMinute)
+}
+
+/**
+ * Check rate limit keyed by an arbitrary token (e.g. API key id, session id).
+ * Same DB path and headers as checkIpRateLimit; just bypasses IP extraction.
+ */
+export async function checkTokenRateLimit(
+ token: string,
+ options: RateLimitOptions,
+): Promise {
+ const identifier = options.keyPrefix ? `${options.keyPrefix}:${token}` : token
+
+ const result = await checkRateLimit(
+ identifier,
+ 'api_key',
+ options.limitPerMinute,
+ )
+
+ return buildRateLimitResult(result, options.limitPerMinute)
+}
+
+function buildRateLimitResult(
+ result: { allowed: boolean; remaining: number; resetAt: Date },
+ limitPerMinute: number,
+): RateLimitResult {
const headers = new Headers()
- headers.set('X-RateLimit-Limit', options.limitPerMinute.toString())
+ headers.set('X-RateLimit-Limit', limitPerMinute.toString())
headers.set('X-RateLimit-Remaining', result.remaining.toString())
headers.set(
'X-RateLimit-Reset',
@@ -187,4 +213,8 @@ export const RATE_LIMITS = {
builderCompile: { limitPerMinute: 60, keyPrefix: 'builder-compile' },
// Deploy endpoint: 10 requests/minute (more sensitive)
deploy: { limitPerMinute: 10, keyPrefix: 'deploy' },
+ // Intent registry public API (anonymous, IP-keyed): 60 req/min
+ intentApi: { limitPerMinute: 60, keyPrefix: 'intent-api' },
+ // Intent registry public API (authenticated, token-keyed): 600 req/min
+ intentApiAuthed: { limitPerMinute: 600, keyPrefix: 'intent-api-authed' },
} as const