diff --git a/src/components/intent/SkillDependencyGraph.tsx b/src/components/intent/SkillDependencyGraph.tsx index 91623051..d2990f46 100644 --- a/src/components/intent/SkillDependencyGraph.tsx +++ b/src/components/intent/SkillDependencyGraph.tsx @@ -227,8 +227,8 @@ export function SkillDependencyGraph({ return ( IntentRegistryPackageNameRoute, } as any) -const IntentRegistryPackageNameSkillNameRoute = - IntentRegistryPackageNameSkillNameRouteImport.update({ - id: '/$skillName', - path: '/$skillName', +const IntentRegistryPackageNameChar123Char125Route = + IntentRegistryPackageNameChar123Char125RouteImport.update({ + id: '/{$}', + path: '/{$}', getParentRoute: () => IntentRegistryPackageNameRoute, } as any) +const ApiV1IntentSearchRoute = ApiV1IntentSearchRouteImport.update({ + id: '/api/v1/intent/search', + path: '/api/v1/intent/search', + getParentRoute: () => rootRouteImport, +} as any) +const ApiV1IntentPackagesRoute = ApiV1IntentPackagesRouteImport.update({ + id: '/api/v1/intent/packages', + path: '/api/v1/intent/packages', + getParentRoute: () => rootRouteImport, +} as any) const ApiBuilderDeployGithubRoute = ApiBuilderDeployGithubRouteImport.update({ id: '/api/builder/deploy/github', path: '/api/builder/deploy/github', @@ -849,6 +864,11 @@ const LibraryIdVersionDocsFrameworkIndexRoute = path: '/framework/', getParentRoute: () => LibraryIdVersionDocsRoute, } as any) +const ApiV1IntentPackagesNameRoute = ApiV1IntentPackagesNameRouteImport.update({ + id: '/$name', + path: '/$name', + getParentRoute: () => ApiV1IntentPackagesRoute, +} as any) const ApiAuthCliStatusTicketIdRoute = ApiAuthCliStatusTicketIdRouteImport.update({ id: '/api/auth/cli/status/$ticketId', @@ -879,6 +899,18 @@ const LibraryIdVersionDocsFrameworkFrameworkExamplesSplatRoute = path: '/framework/$framework/examples/$', getParentRoute: () => LibraryIdVersionDocsRoute, } as any) +const ApiV1IntentPackagesNameVersionsVersionSkillsRoute = + ApiV1IntentPackagesNameVersionsVersionSkillsRouteImport.update({ + id: '/versions/$version/skills', + path: '/versions/$version/skills', + getParentRoute: () => ApiV1IntentPackagesNameRoute, + } as any) +const ApiV1IntentPackagesNameVersionsVersionSkillsSkillRoute = + ApiV1IntentPackagesNameVersionsVersionSkillsSkillRouteImport.update({ + id: '/$skill', + path: '/$skill', + getParentRoute: () => ApiV1IntentPackagesNameVersionsVersionSkillsRoute, + } as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -1012,16 +1044,21 @@ export interface FileRoutesByFullPath { '/api/auth/cli/create-ticket': typeof ApiAuthCliCreateTicketRoute '/api/builder/deploy/check-name': typeof ApiBuilderDeployCheckNameRoute '/api/builder/deploy/github': typeof ApiBuilderDeployGithubRoute - '/intent/registry/$packageName/$skillName': typeof IntentRegistryPackageNameSkillNameRoute + '/api/v1/intent/packages': typeof ApiV1IntentPackagesRouteWithChildren + '/api/v1/intent/search': typeof ApiV1IntentSearchRoute + '/intent/registry/$packageName/{$}': typeof IntentRegistryPackageNameChar123Char125Route '/intent/registry/$packageName/{$}.md': typeof IntentRegistryPackageNameChar123Char125DotmdRoute '/$libraryId/$version/docs/': typeof LibraryIdVersionDocsIndexRoute '/intent/registry/$packageName/': typeof IntentRegistryPackageNameIndexRoute '/api/auth/cli/status/$ticketId': typeof ApiAuthCliStatusTicketIdRoute + '/api/v1/intent/packages/$name': typeof ApiV1IntentPackagesNameRouteWithChildren '/$libraryId/$version/docs/framework/': typeof LibraryIdVersionDocsFrameworkIndexRoute '/$libraryId/$version/docs/framework/$framework/$': typeof LibraryIdVersionDocsFrameworkFrameworkSplatRoute '/$libraryId/$version/docs/framework/$framework/{$}.md': typeof LibraryIdVersionDocsFrameworkFrameworkChar123Char125DotmdRoute '/$libraryId/$version/docs/framework/$framework/': typeof LibraryIdVersionDocsFrameworkFrameworkIndexRoute '/$libraryId/$version/docs/framework/$framework/examples/$': typeof LibraryIdVersionDocsFrameworkFrameworkExamplesSplatRoute + '/api/v1/intent/packages/$name/versions/$version/skills': typeof ApiV1IntentPackagesNameVersionsVersionSkillsRouteWithChildren + '/api/v1/intent/packages/$name/versions/$version/skills/$skill': typeof ApiV1IntentPackagesNameVersionsVersionSkillsSkillRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -1145,16 +1182,21 @@ export interface FileRoutesByTo { '/api/auth/cli/create-ticket': typeof ApiAuthCliCreateTicketRoute '/api/builder/deploy/check-name': typeof ApiBuilderDeployCheckNameRoute '/api/builder/deploy/github': typeof ApiBuilderDeployGithubRoute - '/intent/registry/$packageName/$skillName': typeof IntentRegistryPackageNameSkillNameRoute + '/api/v1/intent/packages': typeof ApiV1IntentPackagesRouteWithChildren + '/api/v1/intent/search': typeof ApiV1IntentSearchRoute + '/intent/registry/$packageName/{$}': typeof IntentRegistryPackageNameChar123Char125Route '/intent/registry/$packageName/{$}.md': typeof IntentRegistryPackageNameChar123Char125DotmdRoute '/$libraryId/$version/docs': typeof LibraryIdVersionDocsIndexRoute '/intent/registry/$packageName': typeof IntentRegistryPackageNameIndexRoute '/api/auth/cli/status/$ticketId': typeof ApiAuthCliStatusTicketIdRoute + '/api/v1/intent/packages/$name': typeof ApiV1IntentPackagesNameRouteWithChildren '/$libraryId/$version/docs/framework': typeof LibraryIdVersionDocsFrameworkIndexRoute '/$libraryId/$version/docs/framework/$framework/$': typeof LibraryIdVersionDocsFrameworkFrameworkSplatRoute '/$libraryId/$version/docs/framework/$framework/{$}.md': typeof LibraryIdVersionDocsFrameworkFrameworkChar123Char125DotmdRoute '/$libraryId/$version/docs/framework/$framework': typeof LibraryIdVersionDocsFrameworkFrameworkIndexRoute '/$libraryId/$version/docs/framework/$framework/examples/$': typeof LibraryIdVersionDocsFrameworkFrameworkExamplesSplatRoute + '/api/v1/intent/packages/$name/versions/$version/skills': typeof ApiV1IntentPackagesNameVersionsVersionSkillsRouteWithChildren + '/api/v1/intent/packages/$name/versions/$version/skills/$skill': typeof ApiV1IntentPackagesNameVersionsVersionSkillsSkillRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -1289,16 +1331,21 @@ export interface FileRoutesById { '/api/auth/cli/create-ticket': typeof ApiAuthCliCreateTicketRoute '/api/builder/deploy/check-name': typeof ApiBuilderDeployCheckNameRoute '/api/builder/deploy/github': typeof ApiBuilderDeployGithubRoute - '/intent/registry/$packageName/$skillName': typeof IntentRegistryPackageNameSkillNameRoute + '/api/v1/intent/packages': typeof ApiV1IntentPackagesRouteWithChildren + '/api/v1/intent/search': typeof ApiV1IntentSearchRoute + '/intent/registry/$packageName/{$}': typeof IntentRegistryPackageNameChar123Char125Route '/intent/registry/$packageName/{$}.md': typeof IntentRegistryPackageNameChar123Char125DotmdRoute '/$libraryId/$version/docs/': typeof LibraryIdVersionDocsIndexRoute '/intent/registry/$packageName/': typeof IntentRegistryPackageNameIndexRoute '/api/auth/cli/status/$ticketId': typeof ApiAuthCliStatusTicketIdRoute + '/api/v1/intent/packages/$name': typeof ApiV1IntentPackagesNameRouteWithChildren '/$libraryId/$version/docs/framework/': typeof LibraryIdVersionDocsFrameworkIndexRoute '/$libraryId/$version/docs/framework/$framework/$': typeof LibraryIdVersionDocsFrameworkFrameworkSplatRoute '/$libraryId/$version/docs/framework/$framework/{$}.md': typeof LibraryIdVersionDocsFrameworkFrameworkChar123Char125DotmdRoute '/$libraryId/$version/docs/framework/$framework/': typeof LibraryIdVersionDocsFrameworkFrameworkIndexRoute '/$libraryId/$version/docs/framework/$framework/examples/$': typeof LibraryIdVersionDocsFrameworkFrameworkExamplesSplatRoute + '/api/v1/intent/packages/$name/versions/$version/skills': typeof ApiV1IntentPackagesNameVersionsVersionSkillsRouteWithChildren + '/api/v1/intent/packages/$name/versions/$version/skills/$skill': typeof ApiV1IntentPackagesNameVersionsVersionSkillsSkillRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -1434,16 +1481,21 @@ export interface FileRouteTypes { | '/api/auth/cli/create-ticket' | '/api/builder/deploy/check-name' | '/api/builder/deploy/github' - | '/intent/registry/$packageName/$skillName' + | '/api/v1/intent/packages' + | '/api/v1/intent/search' + | '/intent/registry/$packageName/{$}' | '/intent/registry/$packageName/{$}.md' | '/$libraryId/$version/docs/' | '/intent/registry/$packageName/' | '/api/auth/cli/status/$ticketId' + | '/api/v1/intent/packages/$name' | '/$libraryId/$version/docs/framework/' | '/$libraryId/$version/docs/framework/$framework/$' | '/$libraryId/$version/docs/framework/$framework/{$}.md' | '/$libraryId/$version/docs/framework/$framework/' | '/$libraryId/$version/docs/framework/$framework/examples/$' + | '/api/v1/intent/packages/$name/versions/$version/skills' + | '/api/v1/intent/packages/$name/versions/$version/skills/$skill' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -1567,16 +1619,21 @@ export interface FileRouteTypes { | '/api/auth/cli/create-ticket' | '/api/builder/deploy/check-name' | '/api/builder/deploy/github' - | '/intent/registry/$packageName/$skillName' + | '/api/v1/intent/packages' + | '/api/v1/intent/search' + | '/intent/registry/$packageName/{$}' | '/intent/registry/$packageName/{$}.md' | '/$libraryId/$version/docs' | '/intent/registry/$packageName' | '/api/auth/cli/status/$ticketId' + | '/api/v1/intent/packages/$name' | '/$libraryId/$version/docs/framework' | '/$libraryId/$version/docs/framework/$framework/$' | '/$libraryId/$version/docs/framework/$framework/{$}.md' | '/$libraryId/$version/docs/framework/$framework' | '/$libraryId/$version/docs/framework/$framework/examples/$' + | '/api/v1/intent/packages/$name/versions/$version/skills' + | '/api/v1/intent/packages/$name/versions/$version/skills/$skill' id: | '__root__' | '/' @@ -1710,16 +1767,21 @@ export interface FileRouteTypes { | '/api/auth/cli/create-ticket' | '/api/builder/deploy/check-name' | '/api/builder/deploy/github' - | '/intent/registry/$packageName/$skillName' + | '/api/v1/intent/packages' + | '/api/v1/intent/search' + | '/intent/registry/$packageName/{$}' | '/intent/registry/$packageName/{$}.md' | '/$libraryId/$version/docs/' | '/intent/registry/$packageName/' | '/api/auth/cli/status/$ticketId' + | '/api/v1/intent/packages/$name' | '/$libraryId/$version/docs/framework/' | '/$libraryId/$version/docs/framework/$framework/$' | '/$libraryId/$version/docs/framework/$framework/{$}.md' | '/$libraryId/$version/docs/framework/$framework/' | '/$libraryId/$version/docs/framework/$framework/examples/$' + | '/api/v1/intent/packages/$name/versions/$version/skills' + | '/api/v1/intent/packages/$name/versions/$version/skills/$skill' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -1811,6 +1873,8 @@ export interface RootRouteChildren { ApiAuthCliCreateTicketRoute: typeof ApiAuthCliCreateTicketRoute ApiBuilderDeployCheckNameRoute: typeof ApiBuilderDeployCheckNameRoute ApiBuilderDeployGithubRoute: typeof ApiBuilderDeployGithubRoute + ApiV1IntentPackagesRoute: typeof ApiV1IntentPackagesRouteWithChildren + ApiV1IntentSearchRoute: typeof ApiV1IntentSearchRoute ApiAuthCliStatusTicketIdRoute: typeof ApiAuthCliStatusTicketIdRoute } @@ -2691,13 +2755,27 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IntentRegistryPackageNameChar123Char125DotmdRouteImport parentRoute: typeof IntentRegistryPackageNameRoute } - '/intent/registry/$packageName/$skillName': { - id: '/intent/registry/$packageName/$skillName' - path: '/$skillName' - fullPath: '/intent/registry/$packageName/$skillName' - preLoaderRoute: typeof IntentRegistryPackageNameSkillNameRouteImport + '/intent/registry/$packageName/{$}': { + id: '/intent/registry/$packageName/{$}' + path: '/{$}' + fullPath: '/intent/registry/$packageName/{$}' + preLoaderRoute: typeof IntentRegistryPackageNameChar123Char125RouteImport parentRoute: typeof IntentRegistryPackageNameRoute } + '/api/v1/intent/search': { + id: '/api/v1/intent/search' + path: '/api/v1/intent/search' + fullPath: '/api/v1/intent/search' + preLoaderRoute: typeof ApiV1IntentSearchRouteImport + parentRoute: typeof rootRouteImport + } + '/api/v1/intent/packages': { + id: '/api/v1/intent/packages' + path: '/api/v1/intent/packages' + fullPath: '/api/v1/intent/packages' + preLoaderRoute: typeof ApiV1IntentPackagesRouteImport + parentRoute: typeof rootRouteImport + } '/api/builder/deploy/github': { id: '/api/builder/deploy/github' path: '/api/builder/deploy/github' @@ -2768,6 +2846,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LibraryIdVersionDocsFrameworkIndexRouteImport parentRoute: typeof LibraryIdVersionDocsRoute } + '/api/v1/intent/packages/$name': { + id: '/api/v1/intent/packages/$name' + path: '/$name' + fullPath: '/api/v1/intent/packages/$name' + preLoaderRoute: typeof ApiV1IntentPackagesNameRouteImport + parentRoute: typeof ApiV1IntentPackagesRoute + } '/api/auth/cli/status/$ticketId': { id: '/api/auth/cli/status/$ticketId' path: '/api/auth/cli/status/$ticketId' @@ -2803,6 +2888,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LibraryIdVersionDocsFrameworkFrameworkExamplesSplatRouteImport parentRoute: typeof LibraryIdVersionDocsRoute } + '/api/v1/intent/packages/$name/versions/$version/skills': { + id: '/api/v1/intent/packages/$name/versions/$version/skills' + path: '/versions/$version/skills' + fullPath: '/api/v1/intent/packages/$name/versions/$version/skills' + preLoaderRoute: typeof ApiV1IntentPackagesNameVersionsVersionSkillsRouteImport + parentRoute: typeof ApiV1IntentPackagesNameRoute + } + '/api/v1/intent/packages/$name/versions/$version/skills/$skill': { + id: '/api/v1/intent/packages/$name/versions/$version/skills/$skill' + path: '/$skill' + fullPath: '/api/v1/intent/packages/$name/versions/$version/skills/$skill' + preLoaderRoute: typeof ApiV1IntentPackagesNameVersionsVersionSkillsSkillRouteImport + parentRoute: typeof ApiV1IntentPackagesNameVersionsVersionSkillsRoute + } } } @@ -3004,15 +3103,15 @@ const ShopRouteChildren: ShopRouteChildren = { const ShopRouteWithChildren = ShopRoute._addFileChildren(ShopRouteChildren) interface IntentRegistryPackageNameRouteChildren { - IntentRegistryPackageNameSkillNameRoute: typeof IntentRegistryPackageNameSkillNameRoute + IntentRegistryPackageNameChar123Char125Route: typeof IntentRegistryPackageNameChar123Char125Route IntentRegistryPackageNameChar123Char125DotmdRoute: typeof IntentRegistryPackageNameChar123Char125DotmdRoute IntentRegistryPackageNameIndexRoute: typeof IntentRegistryPackageNameIndexRoute } const IntentRegistryPackageNameRouteChildren: IntentRegistryPackageNameRouteChildren = { - IntentRegistryPackageNameSkillNameRoute: - IntentRegistryPackageNameSkillNameRoute, + IntentRegistryPackageNameChar123Char125Route: + IntentRegistryPackageNameChar123Char125Route, IntentRegistryPackageNameChar123Char125DotmdRoute: IntentRegistryPackageNameChar123Char125DotmdRoute, IntentRegistryPackageNameIndexRoute: IntentRegistryPackageNameIndexRoute, @@ -3023,6 +3122,47 @@ const IntentRegistryPackageNameRouteWithChildren = IntentRegistryPackageNameRouteChildren, ) +interface ApiV1IntentPackagesNameVersionsVersionSkillsRouteChildren { + ApiV1IntentPackagesNameVersionsVersionSkillsSkillRoute: typeof ApiV1IntentPackagesNameVersionsVersionSkillsSkillRoute +} + +const ApiV1IntentPackagesNameVersionsVersionSkillsRouteChildren: ApiV1IntentPackagesNameVersionsVersionSkillsRouteChildren = + { + ApiV1IntentPackagesNameVersionsVersionSkillsSkillRoute: + ApiV1IntentPackagesNameVersionsVersionSkillsSkillRoute, + } + +const ApiV1IntentPackagesNameVersionsVersionSkillsRouteWithChildren = + ApiV1IntentPackagesNameVersionsVersionSkillsRoute._addFileChildren( + ApiV1IntentPackagesNameVersionsVersionSkillsRouteChildren, + ) + +interface ApiV1IntentPackagesNameRouteChildren { + ApiV1IntentPackagesNameVersionsVersionSkillsRoute: typeof ApiV1IntentPackagesNameVersionsVersionSkillsRouteWithChildren +} + +const ApiV1IntentPackagesNameRouteChildren: ApiV1IntentPackagesNameRouteChildren = + { + ApiV1IntentPackagesNameVersionsVersionSkillsRoute: + ApiV1IntentPackagesNameVersionsVersionSkillsRouteWithChildren, + } + +const ApiV1IntentPackagesNameRouteWithChildren = + ApiV1IntentPackagesNameRoute._addFileChildren( + ApiV1IntentPackagesNameRouteChildren, + ) + +interface ApiV1IntentPackagesRouteChildren { + ApiV1IntentPackagesNameRoute: typeof ApiV1IntentPackagesNameRouteWithChildren +} + +const ApiV1IntentPackagesRouteChildren: ApiV1IntentPackagesRouteChildren = { + ApiV1IntentPackagesNameRoute: ApiV1IntentPackagesNameRouteWithChildren, +} + +const ApiV1IntentPackagesRouteWithChildren = + ApiV1IntentPackagesRoute._addFileChildren(ApiV1IntentPackagesRouteChildren) + const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, LibraryIdRouteRoute: LibraryIdRouteRouteWithChildren, @@ -3113,6 +3253,8 @@ const rootRouteChildren: RootRouteChildren = { ApiAuthCliCreateTicketRoute: ApiAuthCliCreateTicketRoute, ApiBuilderDeployCheckNameRoute: ApiBuilderDeployCheckNameRoute, ApiBuilderDeployGithubRoute: ApiBuilderDeployGithubRoute, + ApiV1IntentPackagesRoute: ApiV1IntentPackagesRouteWithChildren, + ApiV1IntentSearchRoute: ApiV1IntentSearchRoute, ApiAuthCliStatusTicketIdRoute: ApiAuthCliStatusTicketIdRoute, } export const routeTree = rootRouteImport diff --git a/src/routes/api/v1/intent/packages.$name.ts b/src/routes/api/v1/intent/packages.$name.ts new file mode 100644 index 00000000..1f9bc69a --- /dev/null +++ b/src/routes/api/v1/intent/packages.$name.ts @@ -0,0 +1,39 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/api/v1/intent/packages/$name')({ + server: { + handlers: { + GET: async ({ + request, + params, + }: { + request: Request + params: { name: string } + }) => { + const [{ applyIntentRateLimit, intentJsonResponse, intentErrorResponse }, fns] = + await Promise.all([ + import('~/utils/intent-api.server'), + import('~/utils/intent.functions'), + ]) + + const decision = await applyIntentRateLimit(request) + if (decision.limited) return decision.response + + const detail = await fns.getIntentPackageDetail({ + data: { name: params.name }, + }) + + if (!detail) { + return intentErrorResponse( + `Package not found: ${params.name}`, + 'NOT_FOUND', + 404, + decision.rl, + ) + } + + return intentJsonResponse(detail, decision.rl) + }, + }, + }, +}) diff --git a/src/routes/api/v1/intent/packages.$name.versions.$version.skills.$skill.ts b/src/routes/api/v1/intent/packages.$name.versions.$version.skills.$skill.ts new file mode 100644 index 00000000..c1127147 --- /dev/null +++ b/src/routes/api/v1/intent/packages.$name.versions.$version.skills.$skill.ts @@ -0,0 +1,102 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute( + '/api/v1/intent/packages/$name/versions/$version/skills/$skill', +)({ + server: { + handlers: { + GET: async ({ + request, + params, + }: { + request: Request + params: { name: string; version: string; skill: string } + }) => { + const [ + { + applyIntentRateLimit, + intentJsonResponse, + intentErrorResponse, + buildSkillContentUrls, + }, + fns, + dbModule, + ] = await Promise.all([ + import('~/utils/intent-api.server'), + import('~/utils/intent.functions'), + import('~/utils/intent-db.server'), + ]) + + const decision = await applyIntentRateLimit(request) + if (decision.limited) return decision.response + + const versions = await dbModule.getPackageVersions(params.name) + const versionRecord = versions.find((v) => v.version === params.version) + if (!versionRecord) { + return intentErrorResponse( + `Version not found: ${params.name}@${params.version}`, + 'NOT_FOUND', + 404, + decision.rl, + ) + } + + const skills = await dbModule.getSkillsForVersion(versionRecord.id) + const skill = skills.find((s) => s.name === params.skill) + + if (!skill) { + return intentErrorResponse( + `Skill not found: ${params.skill} in ${params.name}@${params.version}`, + 'NOT_FOUND', + 404, + decision.rl, + ) + } + + // Markdown body is kept as an undocumented escape hatch only fetched + // when the caller explicitly opts in (?include=markdown). Default + // responses point at the CDN to keep our egress near zero. + const url = new URL(request.url) + const includeMarkdown = url.searchParams + .get('include') + ?.split(',') + .includes('markdown') + + const markdown = includeMarkdown + ? await fns.getIntentSkillMarkdown({ + data: { + packageName: params.name, + version: params.version, + skillName: params.skill, + }, + }) + : undefined + + return intentJsonResponse( + { + packageName: params.name, + version: params.version, + skill: { + id: skill.id, + name: skill.name, + description: skill.description, + type: skill.type, + framework: skill.framework, + requires: skill.requires, + skillPath: skill.skillPath, + contentHash: skill.contentHash, + lineCount: skill.lineCount, + }, + content: buildSkillContentUrls( + params.name, + params.version, + skill.skillPath, + ), + ...(includeMarkdown ? { markdown } : {}), + }, + decision.rl, + ) + }, + }, + }, +}) diff --git a/src/routes/api/v1/intent/packages.$name.versions.$version.skills.ts b/src/routes/api/v1/intent/packages.$name.versions.$version.skills.ts new file mode 100644 index 00000000..e65bae04 --- /dev/null +++ b/src/routes/api/v1/intent/packages.$name.versions.$version.skills.ts @@ -0,0 +1,61 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute( + '/api/v1/intent/packages/$name/versions/$version/skills', +)({ + server: { + handlers: { + GET: async ({ + request, + params, + }: { + request: Request + params: { name: string; version: string } + }) => { + const [ + { + applyIntentRateLimit, + intentJsonResponse, + intentErrorResponse, + buildSkillContentUrls, + }, + fns, + ] = await Promise.all([ + import('~/utils/intent-api.server'), + import('~/utils/intent.functions'), + ]) + + const decision = await applyIntentRateLimit(request) + if (decision.limited) return decision.response + + const result = await fns.getIntentVersionSkills({ + data: { packageName: params.name, version: params.version }, + }) + + if (!result) { + return intentErrorResponse( + `Version not found: ${params.name}@${params.version}`, + 'NOT_FOUND', + 404, + decision.rl, + ) + } + + return intentJsonResponse( + { + ...result, + skills: result.skills.map((skill) => ({ + ...skill, + content: buildSkillContentUrls( + params.name, + params.version, + skill.skillPath, + ), + })), + }, + decision.rl, + ) + }, + }, + }, +}) diff --git a/src/routes/api/v1/intent/packages.ts b/src/routes/api/v1/intent/packages.ts new file mode 100644 index 00000000..68502f08 --- /dev/null +++ b/src/routes/api/v1/intent/packages.ts @@ -0,0 +1,41 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/api/v1/intent/packages')({ + server: { + handlers: { + GET: async ({ request }: { request: Request }) => { + const [{ applyIntentRateLimit, intentJsonResponse }, fns] = + await Promise.all([ + import('~/utils/intent-api.server'), + import('~/utils/intent.functions'), + ]) + + const decision = await applyIntentRateLimit(request) + if (decision.limited) return decision.response + + const url = new URL(request.url) + const search = url.searchParams.get('q') ?? undefined + const framework = url.searchParams.get('framework') ?? undefined + const sortParam = url.searchParams.get('sort') + const sort: 'downloads' | 'name' | 'skills' | 'newest' | undefined = + sortParam === 'downloads' || + sortParam === 'name' || + sortParam === 'skills' || + sortParam === 'newest' + ? sortParam + : undefined + 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 result = await fns.getIntentDirectory({ + data: { search, framework, sort, page, pageSize }, + }) + + return intentJsonResponse(result, decision.rl) + }, + }, + }, +}) diff --git a/src/routes/api/v1/intent/search.ts b/src/routes/api/v1/intent/search.ts new file mode 100644 index 00000000..16dc6a83 --- /dev/null +++ b/src/routes/api/v1/intent/search.ts @@ -0,0 +1,53 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/api/v1/intent/search')({ + server: { + handlers: { + GET: async ({ request }: { request: Request }) => { + const [ + { + applyIntentRateLimit, + intentJsonResponse, + intentErrorResponse, + buildSkillContentUrls, + }, + dbModule, + ] = await Promise.all([ + import('~/utils/intent-api.server'), + import('~/utils/intent-db.server'), + ]) + + const decision = await applyIntentRateLimit(request) + if (decision.limited) return decision.response + + const url = new URL(request.url) + const q = url.searchParams.get('q')?.trim() ?? '' + const limitParam = url.searchParams.get('limit') + const limit = Math.min( + Math.max(parseInt(limitParam ?? '20', 10) || 20, 1), + 100, + ) + + if (!q) { + return intentErrorResponse( + 'Missing required query parameter: q', + 'INVALID_REQUEST', + 400, + decision.rl, + ) + } + + const rows = await dbModule.searchSkills(q, limit) + const results = rows.map((row) => ({ + ...row, + content: buildSkillContentUrls( + row.packageName, + row.version, + row.skillPath, + ), + })) + return intentJsonResponse({ query: q, limit, results }, decision.rl) + }, + }, + }, +}) diff --git a/src/routes/intent/registry/$packageName.index.tsx b/src/routes/intent/registry/$packageName.index.tsx index bf82bdd6..44d474be 100644 --- a/src/routes/intent/registry/$packageName.index.tsx +++ b/src/routes/intent/registry/$packageName.index.tsx @@ -244,8 +244,8 @@ function SkillsList({
{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