diff --git a/CHANGELOG.md b/CHANGELOG.md index 17fa40bd..6958ab85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.0.6 - 2026-01-07 + +### Added +- API: v1 public REST endpoints with rate limits, raw file fetch, and OpenAPI spec. +- Docs: `docs/api.md` and `DEPRECATIONS.md` for the v1 cutover plan. + +### Changed +- CLI: publish now uses single multipart `POST /api/v1/skills`. +- Registry: legacy `/api/*` + `/api/cli/*` marked for deprecation (kept for now). + ## 0.0.5 - 2026-01-06 ### Added diff --git a/DEPRECATIONS.md b/DEPRECATIONS.md new file mode 100644 index 00000000..43692186 --- /dev/null +++ b/DEPRECATIONS.md @@ -0,0 +1,7 @@ +# Deprecations + +## Legacy /api routes (pre-v1) + +- Deprecated: 2026-01-07 +- TODO: remove legacy `/api/*` and `/api/cli/*` routes after clients migrate to `/api/v1`. +- Legacy handlers live in `convex/http.ts` and `convex/httpApi.ts`. diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 3e89f14a..7358be20 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -13,6 +13,7 @@ import type * as comments from "../comments.js"; import type * as downloads from "../downloads.js"; import type * as http from "../http.js"; import type * as httpApi from "../httpApi.js"; +import type * as httpApiV1 from "../httpApiV1.js"; import type * as lib_access from "../lib/access.js"; import type * as lib_apiTokenAuth from "../lib/apiTokenAuth.js"; import type * as lib_changelog from "../lib/changelog.js"; @@ -23,6 +24,7 @@ import type * as lib_skills from "../lib/skills.js"; import type * as lib_tokens from "../lib/tokens.js"; import type * as lib_webhooks from "../lib/webhooks.js"; import type * as maintenance from "../maintenance.js"; +import type * as rateLimits from "../rateLimits.js"; import type * as search from "../search.js"; import type * as skills from "../skills.js"; import type * as stars from "../stars.js"; @@ -44,6 +46,7 @@ declare const fullApi: ApiFromModules<{ downloads: typeof downloads; http: typeof http; httpApi: typeof httpApi; + httpApiV1: typeof httpApiV1; "lib/access": typeof lib_access; "lib/apiTokenAuth": typeof lib_apiTokenAuth; "lib/changelog": typeof lib_changelog; @@ -54,6 +57,7 @@ declare const fullApi: ApiFromModules<{ "lib/tokens": typeof lib_tokens; "lib/webhooks": typeof lib_webhooks; maintenance: typeof maintenance; + rateLimits: typeof rateLimits; search: typeof search; skills: typeof skills; stars: typeof stars; diff --git a/convex/http.ts b/convex/http.ts index 35a1555c..ae7090a6 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -1,4 +1,4 @@ -import { ApiRoutes } from 'clawdhub-schema' +import { ApiRoutes, LegacyApiRoutes } from 'clawdhub-schema' import { httpRouter } from 'convex/server' import { auth } from './auth' import { downloadZip } from './downloads' @@ -13,6 +13,16 @@ import { resolveSkillVersionHttp, searchSkillsHttp, } from './httpApi' +import { + listSkillsV1Http, + publishSkillV1Http, + resolveSkillVersionV1Http, + searchSkillsV1Http, + skillsDeleteRouterV1Http, + skillsGetRouterV1Http, + skillsPostRouterV1Http, + whoamiV1Http, +} from './httpApiV1' const http = httpRouter() @@ -27,53 +37,107 @@ http.route({ http.route({ path: ApiRoutes.search, method: 'GET', + handler: searchSkillsV1Http, +}) + +http.route({ + path: ApiRoutes.resolve, + method: 'GET', + handler: resolveSkillVersionV1Http, +}) + +http.route({ + path: ApiRoutes.skills, + method: 'GET', + handler: listSkillsV1Http, +}) + +http.route({ + pathPrefix: `${ApiRoutes.skills}/`, + method: 'GET', + handler: skillsGetRouterV1Http, +}) + +http.route({ + path: ApiRoutes.skills, + method: 'POST', + handler: publishSkillV1Http, +}) + +http.route({ + pathPrefix: `${ApiRoutes.skills}/`, + method: 'POST', + handler: skillsPostRouterV1Http, +}) + +http.route({ + pathPrefix: `${ApiRoutes.skills}/`, + method: 'DELETE', + handler: skillsDeleteRouterV1Http, +}) + +http.route({ + path: ApiRoutes.whoami, + method: 'GET', + handler: whoamiV1Http, +}) + +// TODO: remove legacy /api routes after deprecation window. +http.route({ + path: LegacyApiRoutes.download, + method: 'GET', + handler: downloadZip, +}) +http.route({ + path: LegacyApiRoutes.search, + method: 'GET', handler: searchSkillsHttp, }) http.route({ - path: ApiRoutes.skill, + path: LegacyApiRoutes.skill, method: 'GET', handler: getSkillHttp, }) http.route({ - path: ApiRoutes.skillResolve, + path: LegacyApiRoutes.skillResolve, method: 'GET', handler: resolveSkillVersionHttp, }) http.route({ - path: ApiRoutes.cliWhoami, + path: LegacyApiRoutes.cliWhoami, method: 'GET', handler: cliWhoamiHttp, }) http.route({ - path: ApiRoutes.cliUploadUrl, + path: LegacyApiRoutes.cliUploadUrl, method: 'POST', handler: cliUploadUrlHttp, }) http.route({ - path: ApiRoutes.cliPublish, + path: LegacyApiRoutes.cliPublish, method: 'POST', handler: cliPublishHttp, }) http.route({ - path: ApiRoutes.cliTelemetrySync, + path: LegacyApiRoutes.cliTelemetrySync, method: 'POST', handler: cliTelemetrySyncHttp, }) http.route({ - path: ApiRoutes.cliSkillDelete, + path: LegacyApiRoutes.cliSkillDelete, method: 'POST', handler: cliSkillDeleteHttp, }) http.route({ - path: ApiRoutes.cliSkillUndelete, + path: LegacyApiRoutes.cliSkillUndelete, method: 'POST', handler: cliSkillUndeleteHttp, }) diff --git a/convex/httpApiV1.ts b/convex/httpApiV1.ts new file mode 100644 index 00000000..4f7a29a3 --- /dev/null +++ b/convex/httpApiV1.ts @@ -0,0 +1,662 @@ +import { CliPublishRequestSchema, parseArk } from 'clawdhub-schema' +import { api, internal } from './_generated/api' +import type { Doc, Id } from './_generated/dataModel' +import type { ActionCtx } from './_generated/server' +import { httpAction } from './_generated/server' +import { requireApiTokenUser } from './lib/apiTokenAuth' +import { hashToken } from './lib/tokens' +import { publishVersionForUser } from './skills' + +const RATE_LIMIT_WINDOW_MS = 60_000 +const RATE_LIMITS = { + read: { ip: 120, key: 600 }, + write: { ip: 30, key: 120 }, +} as const +const MAX_RAW_FILE_BYTES = 200 * 1024 + +type SearchSkillEntry = { + score: number + skill: { + slug?: string + displayName?: string + summary?: string | null + updatedAt?: number + } | null + version: { version?: string; createdAt?: number } | null +} + +type ListSkillsResult = { + items: Array<{ + skill: { + _id: Id<'skills'> + slug: string + displayName: string + summary?: string + tags: Record> + stats: unknown + createdAt: number + updatedAt: number + latestVersionId?: Id<'skillVersions'> + } + latestVersion: { version: string; createdAt: number; changelog: string } | null + }> + nextCursor: string | null +} + +type GetBySlugResult = { + skill: { + _id: Id<'skills'> + slug: string + displayName: string + summary?: string + tags: Record> + stats: unknown + createdAt: number + updatedAt: number + } | null + latestVersion: Doc<'skillVersions'> | null + owner: { handle?: string; displayName?: string; image?: string } | null +} | null + +type ListVersionsResult = { + items: Array<{ + version: string + createdAt: number + changelog: string + changelogSource?: 'auto' | 'user' + files: Array<{ + path: string + size: number + storageId: Id<'_storage'> + sha256: string + contentType?: string + }> + softDeletedAt?: number + }> + nextCursor: string | null +} + +export const searchSkillsV1Http = httpAction(async (ctx, request) => { + const rate = await applyRateLimit(ctx, request, 'read') + if (!rate.ok) return rate.response + + const url = new URL(request.url) + const query = url.searchParams.get('q')?.trim() ?? '' + const limit = toOptionalNumber(url.searchParams.get('limit')) + const highlightedOnly = url.searchParams.get('highlightedOnly') === 'true' + + if (!query) return json({ results: [] }, 200, rate.headers) + + const results = (await ctx.runAction(api.search.searchSkills, { + query, + limit, + highlightedOnly: highlightedOnly || undefined, + })) as SearchSkillEntry[] + + return json( + { + results: results.map((result) => ({ + score: result.score, + slug: result.skill?.slug, + displayName: result.skill?.displayName, + summary: result.skill?.summary ?? null, + version: result.version?.version ?? null, + updatedAt: result.skill?.updatedAt, + })), + }, + 200, + rate.headers, + ) +}) + +export const resolveSkillVersionV1Http = httpAction(async (ctx, request) => { + const rate = await applyRateLimit(ctx, request, 'read') + if (!rate.ok) return rate.response + + const url = new URL(request.url) + const slug = url.searchParams.get('slug')?.trim().toLowerCase() + const hash = url.searchParams.get('hash')?.trim().toLowerCase() + if (!slug || !hash) return text('Missing slug or hash', 400, rate.headers) + if (!/^[a-f0-9]{64}$/.test(hash)) return text('Invalid hash', 400, rate.headers) + + const resolved = await ctx.runQuery(api.skills.resolveVersionByHash, { slug, hash }) + if (!resolved) return text('Skill not found', 404, rate.headers) + + return json( + { slug, match: resolved.match, latestVersion: resolved.latestVersion }, + 200, + rate.headers, + ) +}) + +export const listSkillsV1Http = httpAction(async (ctx, request) => { + const rate = await applyRateLimit(ctx, request, 'read') + if (!rate.ok) return rate.response + + const url = new URL(request.url) + const limit = toOptionalNumber(url.searchParams.get('limit')) + const cursor = url.searchParams.get('cursor')?.trim() || undefined + + const result = (await ctx.runQuery(api.skills.listPublicPage, { + limit, + cursor, + })) as ListSkillsResult + + const items = await Promise.all( + result.items.map(async (item) => { + const tags = await resolveTags(ctx, item.skill.tags) + return { + slug: item.skill.slug, + displayName: item.skill.displayName, + summary: item.skill.summary ?? null, + tags, + stats: item.skill.stats, + createdAt: item.skill.createdAt, + updatedAt: item.skill.updatedAt, + latestVersion: item.latestVersion + ? { + version: item.latestVersion.version, + createdAt: item.latestVersion.createdAt, + changelog: item.latestVersion.changelog, + } + : null, + } + }), + ) + + return json({ items, nextCursor: result.nextCursor ?? null }, 200, rate.headers) +}) + +export const skillsGetRouterV1Http = httpAction(async (ctx, request) => { + const rate = await applyRateLimit(ctx, request, 'read') + if (!rate.ok) return rate.response + + const segments = getPathSegments(request, '/api/v1/skills/') + if (segments.length === 0) return text('Missing slug', 400, rate.headers) + const slug = segments[0]?.trim().toLowerCase() ?? '' + const second = segments[1] + const third = segments[2] + + if (segments.length === 1) { + const result = (await ctx.runQuery(api.skills.getBySlug, { slug })) as GetBySlugResult + if (!result?.skill) return text('Skill not found', 404, rate.headers) + + const tags = await resolveTags(ctx, result.skill.tags) + return json( + { + skill: { + slug: result.skill.slug, + displayName: result.skill.displayName, + summary: result.skill.summary ?? null, + tags, + stats: result.skill.stats, + createdAt: result.skill.createdAt, + updatedAt: result.skill.updatedAt, + }, + latestVersion: result.latestVersion + ? { + version: result.latestVersion.version, + createdAt: result.latestVersion.createdAt, + changelog: result.latestVersion.changelog, + } + : null, + owner: result.owner + ? { + handle: result.owner.handle ?? null, + displayName: result.owner.displayName ?? null, + image: result.owner.image ?? null, + } + : null, + }, + 200, + rate.headers, + ) + } + + if (second === 'versions' && segments.length === 2) { + const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug }) + if (!skill || skill.softDeletedAt) return text('Skill not found', 404, rate.headers) + + const url = new URL(request.url) + const limit = toOptionalNumber(url.searchParams.get('limit')) + const cursor = url.searchParams.get('cursor')?.trim() || undefined + const result = (await ctx.runQuery(api.skills.listVersionsPage, { + skillId: skill._id, + limit, + cursor, + })) as ListVersionsResult + + const items = result.items + .filter((version) => !version.softDeletedAt) + .map((version) => ({ + version: version.version, + createdAt: version.createdAt, + changelog: version.changelog, + changelogSource: version.changelogSource ?? null, + })) + + return json({ items, nextCursor: result.nextCursor ?? null }, 200, rate.headers) + } + + if (second === 'versions' && third && segments.length === 3) { + const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug }) + if (!skill || skill.softDeletedAt) return text('Skill not found', 404, rate.headers) + + const version = await ctx.runQuery(api.skills.getVersionBySkillAndVersion, { + skillId: skill._id, + version: third, + }) + if (!version) return text('Version not found', 404, rate.headers) + if (version.softDeletedAt) return text('Version not available', 410, rate.headers) + + return json( + { + skill: { slug: skill.slug, displayName: skill.displayName }, + version: { + version: version.version, + createdAt: version.createdAt, + changelog: version.changelog, + changelogSource: version.changelogSource ?? null, + files: version.files.map((file) => ({ + path: file.path, + size: file.size, + sha256: file.sha256, + contentType: file.contentType ?? null, + })), + }, + }, + 200, + rate.headers, + ) + } + + if (second === 'file' && segments.length === 2) { + const url = new URL(request.url) + const path = url.searchParams.get('path')?.trim() + if (!path) return text('Missing path', 400, rate.headers) + const versionParam = url.searchParams.get('version')?.trim() + const tagParam = url.searchParams.get('tag')?.trim() + + const skillResult = (await ctx.runQuery(api.skills.getBySlug, { + slug, + })) as GetBySlugResult + if (!skillResult?.skill) return text('Skill not found', 404, rate.headers) + + let version = skillResult.latestVersion + if (versionParam) { + version = await ctx.runQuery(api.skills.getVersionBySkillAndVersion, { + skillId: skillResult.skill._id, + version: versionParam, + }) + } else if (tagParam) { + const versionId = skillResult.skill.tags[tagParam] + if (versionId) { + version = await ctx.runQuery(api.skills.getVersionById, { versionId }) + } + } + + if (!version) return text('Version not found', 404, rate.headers) + if (version.softDeletedAt) return text('Version not available', 410, rate.headers) + + const normalized = path.trim() + const normalizedLower = normalized.toLowerCase() + const file = + version.files.find((entry) => entry.path === normalized) ?? + version.files.find((entry) => entry.path.toLowerCase() === normalizedLower) + if (!file) return text('File not found', 404, rate.headers) + if (file.size > MAX_RAW_FILE_BYTES) return text('File exceeds 200KB limit', 413, rate.headers) + + const blob = await ctx.storage.get(file.storageId) + if (!blob) return text('File missing in storage', 410, rate.headers) + const textContent = await blob.text() + + const headers = mergeHeaders(rate.headers, { + 'Content-Type': file.contentType + ? `${file.contentType}; charset=utf-8` + : 'text/plain; charset=utf-8', + 'Cache-Control': 'private, max-age=60', + ETag: file.sha256, + 'X-Content-SHA256': file.sha256, + 'X-Content-Size': String(file.size), + }) + return new Response(textContent, { status: 200, headers }) + } + + return text('Not found', 404, rate.headers) +}) + +export const publishSkillV1Http = httpAction(async (ctx, request) => { + const rate = await applyRateLimit(ctx, request, 'write') + if (!rate.ok) return rate.response + + try { + if (!parseBearerToken(request)) return text('Unauthorized', 401, rate.headers) + } catch { + return text('Unauthorized', 401, rate.headers) + } + const { userId } = await requireApiTokenUser(ctx, request) + + const contentType = request.headers.get('content-type') ?? '' + try { + if (contentType.includes('application/json')) { + const body = await request.json() + const payload = parsePublishBody(body) + const result = await publishVersionForUser(ctx, userId, payload) + return json({ ok: true, ...result }, 200, rate.headers) + } + + if (contentType.includes('multipart/form-data')) { + const payload = await parseMultipartPublish(ctx, request) + const result = await publishVersionForUser(ctx, userId, payload) + return json({ ok: true, ...result }, 200, rate.headers) + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Publish failed' + return text(message, 400, rate.headers) + } + + return text('Unsupported content type', 415, rate.headers) +}) + +export const skillsPostRouterV1Http = httpAction(async (ctx, request) => { + const rate = await applyRateLimit(ctx, request, 'write') + if (!rate.ok) return rate.response + + const segments = getPathSegments(request, '/api/v1/skills/') + if (segments.length !== 2 || segments[1] !== 'undelete') { + return text('Not found', 404, rate.headers) + } + const slug = segments[0]?.trim().toLowerCase() ?? '' + try { + const { userId } = await requireApiTokenUser(ctx, request) + await ctx.runMutation(internal.skills.setSkillSoftDeletedInternal, { + userId, + slug, + deleted: false, + }) + return json({ ok: true }, 200, rate.headers) + } catch { + return text('Unauthorized', 401, rate.headers) + } +}) + +export const skillsDeleteRouterV1Http = httpAction(async (ctx, request) => { + const rate = await applyRateLimit(ctx, request, 'write') + if (!rate.ok) return rate.response + + const segments = getPathSegments(request, '/api/v1/skills/') + if (segments.length !== 1) return text('Not found', 404, rate.headers) + const slug = segments[0]?.trim().toLowerCase() ?? '' + try { + const { userId } = await requireApiTokenUser(ctx, request) + await ctx.runMutation(internal.skills.setSkillSoftDeletedInternal, { + userId, + slug, + deleted: true, + }) + return json({ ok: true }, 200, rate.headers) + } catch { + return text('Unauthorized', 401, rate.headers) + } +}) + +export const whoamiV1Http = httpAction(async (ctx, request) => { + const rate = await applyRateLimit(ctx, request, 'read') + if (!rate.ok) return rate.response + + try { + const { user } = await requireApiTokenUser(ctx, request) + return json( + { + user: { + handle: user.handle ?? null, + displayName: user.displayName ?? null, + image: user.image ?? null, + }, + }, + 200, + rate.headers, + ) + } catch { + return text('Unauthorized', 401, rate.headers) + } +}) + +async function parseMultipartPublish( + ctx: ActionCtx, + request: Request, +): Promise<{ + slug: string + displayName: string + version: string + changelog: string + tags?: string[] + forkOf?: { slug: string; version?: string } + files: Array<{ + path: string + size: number + storageId: Id<'_storage'> + sha256: string + contentType?: string + }> +}> { + const form = await request.formData() + const payloadRaw = form.get('payload') + if (!payloadRaw || typeof payloadRaw !== 'string') { + throw new Error('Missing payload') + } + let payload: Record + try { + payload = JSON.parse(payloadRaw) as Record + } catch { + throw new Error('Invalid JSON payload') + } + + const files: Array<{ + path: string + size: number + storageId: Id<'_storage'> + sha256: string + contentType?: string + }> = [] + + for (const entry of form.getAll('files')) { + if (!(entry instanceof File)) continue + const path = entry.name + const size = entry.size + const contentType = entry.type || undefined + const buffer = new Uint8Array(await entry.arrayBuffer()) + const sha256 = await sha256Hex(buffer) + const storageId = await ctx.storage.store(entry) + files.push({ path, size, storageId, sha256, contentType }) + } + + const body = { + slug: payload.slug, + displayName: payload.displayName, + version: payload.version, + changelog: typeof payload.changelog === 'string' ? payload.changelog : '', + tags: Array.isArray(payload.tags) ? payload.tags : undefined, + forkOf: payload.forkOf, + files, + } + + return parsePublishBody(body) +} + +function parsePublishBody(body: unknown) { + const parsed = parseArk(CliPublishRequestSchema, body, 'Publish payload') + if (parsed.files.length === 0) throw new Error('files required') + const tags = parsed.tags && parsed.tags.length > 0 ? parsed.tags : undefined + return { + slug: parsed.slug, + displayName: parsed.displayName, + version: parsed.version, + changelog: parsed.changelog, + tags, + forkOf: parsed.forkOf + ? { + slug: parsed.forkOf.slug, + version: parsed.forkOf.version ?? undefined, + } + : undefined, + files: parsed.files.map((file) => ({ + ...file, + storageId: file.storageId as Id<'_storage'>, + })), + } +} + +async function resolveTags( + ctx: ActionCtx, + tags: Record>, +): Promise> { + const resolved: Record = {} + for (const [tag, versionId] of Object.entries(tags)) { + const version = await ctx.runQuery(api.skills.getVersionById, { versionId }) + if (version && !version.softDeletedAt) { + resolved[tag] = version.version + } + } + return resolved +} + +async function applyRateLimit( + ctx: ActionCtx, + request: Request, + kind: 'read' | 'write', +): Promise<{ ok: true; headers: HeadersInit } | { ok: false; response: Response }> { + const ip = getClientIp(request) ?? 'unknown' + const ipResult = await checkRateLimit(ctx, `ip:${ip}`, RATE_LIMITS[kind].ip) + const token = parseBearerToken(request) + const keyResult = token + ? await checkRateLimit(ctx, `key:${await hashToken(token)}`, RATE_LIMITS[kind].key) + : null + + const chosen = pickMostRestrictive(ipResult, keyResult) + const headers = rateHeaders(chosen) + + if (!ipResult.allowed || (keyResult && !keyResult.allowed)) { + return { + ok: false, + response: text('Rate limit exceeded', 429, headers), + } + } + + return { ok: true, headers } +} + +type RateLimitResult = { + allowed: boolean + remaining: number + limit: number + resetAt: number +} + +async function checkRateLimit( + ctx: ActionCtx, + key: string, + limit: number, +): Promise { + return (await ctx.runMutation(internal.rateLimits.checkRateLimitInternal, { + key, + limit, + windowMs: RATE_LIMIT_WINDOW_MS, + })) as RateLimitResult +} + +function pickMostRestrictive(primary: RateLimitResult, secondary: RateLimitResult | null) { + if (!secondary) return primary + if (!primary.allowed) return primary + if (!secondary.allowed) return secondary + return secondary.remaining < primary.remaining ? secondary : primary +} + +function rateHeaders(result: RateLimitResult): HeadersInit { + const resetSeconds = Math.ceil(result.resetAt / 1000) + return { + 'X-RateLimit-Limit': String(result.limit), + 'X-RateLimit-Remaining': String(result.remaining), + 'X-RateLimit-Reset': String(resetSeconds), + ...(result.allowed ? {} : { 'Retry-After': String(resetSeconds) }), + } +} + +function getClientIp(request: Request) { + const header = + request.headers.get('cf-connecting-ip') ?? + request.headers.get('x-real-ip') ?? + request.headers.get('x-forwarded-for') ?? + request.headers.get('fly-client-ip') + if (!header) return null + if (header.includes(',')) return header.split(',')[0]?.trim() || null + return header.trim() +} + +function parseBearerToken(request: Request) { + const header = request.headers.get('authorization') ?? request.headers.get('Authorization') + if (!header) return null + const trimmed = header.trim() + if (!trimmed.toLowerCase().startsWith('bearer ')) return null + const token = trimmed.slice(7).trim() + return token || null +} + +function json(value: unknown, status = 200, headers?: HeadersInit) { + return new Response(JSON.stringify(value), { + status, + headers: mergeHeaders( + { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }, + headers, + ), + }) +} + +function text(value: string, status: number, headers?: HeadersInit) { + return new Response(value, { + status, + headers: mergeHeaders( + { + 'Content-Type': 'text/plain; charset=utf-8', + 'Cache-Control': 'no-store', + }, + headers, + ), + }) +} + +function mergeHeaders(base: HeadersInit, extra?: HeadersInit) { + return { ...(base as Record), ...(extra as Record) } +} + +function getPathSegments(request: Request, prefix: string) { + const pathname = new URL(request.url).pathname + if (!pathname.startsWith(prefix)) return [] + const rest = pathname.slice(prefix.length) + return rest + .split('/') + .map((segment) => segment.trim()) + .filter(Boolean) + .map((segment) => decodeURIComponent(segment)) +} + +function toOptionalNumber(value: string | null) { + if (!value) return undefined + const parsed = Number.parseInt(value, 10) + return Number.isFinite(parsed) ? parsed : undefined +} + +async function sha256Hex(bytes: Uint8Array) { + const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) + const digest = await crypto.subtle.digest('SHA-256', buffer) + return toHex(new Uint8Array(digest)) +} + +function toHex(bytes: Uint8Array) { + let out = '' + for (const byte of bytes) out += byte.toString(16).padStart(2, '0') + return out +} diff --git a/convex/rateLimits.ts b/convex/rateLimits.ts new file mode 100644 index 00000000..052330bb --- /dev/null +++ b/convex/rateLimits.ts @@ -0,0 +1,50 @@ +import { v } from 'convex/values' +import { internalMutation } from './_generated/server' + +export const checkRateLimitInternal = internalMutation({ + args: { + key: v.string(), + limit: v.number(), + windowMs: v.number(), + }, + handler: async (ctx, args) => { + const now = Date.now() + const windowStart = Math.floor(now / args.windowMs) * args.windowMs + const resetAt = windowStart + args.windowMs + if (args.limit <= 0) { + return { allowed: false, remaining: 0, limit: args.limit, resetAt } + } + + const existing = await ctx.db + .query('rateLimits') + .withIndex('by_key_window', (q) => q.eq('key', args.key).eq('windowStart', windowStart)) + .unique() + + if (!existing) { + await ctx.db.insert('rateLimits', { + key: args.key, + windowStart, + count: 1, + limit: args.limit, + updatedAt: now, + }) + return { allowed: true, remaining: Math.max(0, args.limit - 1), limit: args.limit, resetAt } + } + + if (existing.count >= args.limit) { + return { allowed: false, remaining: 0, limit: args.limit, resetAt } + } + + await ctx.db.patch(existing._id, { + count: existing.count + 1, + limit: args.limit, + updatedAt: now, + }) + return { + allowed: true, + remaining: Math.max(0, args.limit - existing.count - 1), + limit: args.limit, + resetAt, + } + }, +}) diff --git a/convex/schema.ts b/convex/schema.ts index 4f1844e5..bdabf80c 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -163,6 +163,16 @@ const apiTokens = defineTable({ .index('by_user', ['userId']) .index('by_hash', ['tokenHash']) +const rateLimits = defineTable({ + key: v.string(), + windowStart: v.number(), + count: v.number(), + limit: v.number(), + updatedAt: v.number(), +}) + .index('by_key_window', ['key', 'windowStart']) + .index('by_key', ['key']) + const userSyncRoots = defineTable({ userId: v.id('users'), rootId: v.string(), @@ -212,6 +222,7 @@ export default defineSchema({ stars, auditLogs, apiTokens, + rateLimits, userSyncRoots, userSkillInstalls, userSkillRootInstalls, diff --git a/convex/skills.ts b/convex/skills.ts index 2c1b59bb..17b9bc8c 100644 --- a/convex/skills.ts +++ b/convex/skills.ts @@ -19,6 +19,7 @@ type ReadmeResult = { path: string; text: string } type FileTextResult = { path: string; text: string; size: number; sha256: string } const MAX_DIFF_FILE_BYTES = 200 * 1024 +const MAX_LIST_LIMIT = 50 export const getBySlug = query({ args: { slug: v.string() }, @@ -112,6 +113,34 @@ export const list = query({ }, }) +export const listPublicPage = query({ + args: { + cursor: v.optional(v.string()), + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const limit = clampInt(args.limit ?? 24, 1, MAX_LIST_LIMIT) + const { page, isDone, continueCursor } = await ctx.db + .query('skills') + .withIndex('by_updated', (q) => q) + .order('desc') + .paginate({ cursor: args.cursor ?? null, numItems: limit }) + + const items: Array<{ + skill: Doc<'skills'> + latestVersion: Doc<'skillVersions'> | null + }> = [] + + for (const skill of page) { + if (skill.softDeletedAt) continue + const latestVersion = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null + items.push({ skill, latestVersion }) + } + + return { items, nextCursor: isDone ? null : continueCursor } + }, +}) + export const listVersions = query({ args: { skillId: v.id('skills'), limit: v.optional(v.number()) }, handler: async (ctx, args) => { @@ -124,6 +153,24 @@ export const listVersions = query({ }, }) +export const listVersionsPage = query({ + args: { + skillId: v.id('skills'), + cursor: v.optional(v.string()), + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const limit = clampInt(args.limit ?? 20, 1, MAX_LIST_LIMIT) + const { page, isDone, continueCursor } = await ctx.db + .query('skillVersions') + .withIndex('by_skill', (q) => q.eq('skillId', args.skillId)) + .order('desc') + .paginate({ cursor: args.cursor ?? null, numItems: limit }) + const items = page.filter((version) => !version.softDeletedAt) + return { items, nextCursor: isDone ? null : continueCursor } + }, +}) + export const getVersionById = query({ args: { versionId: v.id('skillVersions') }, handler: async (ctx, args) => ctx.db.get(args.versionId), @@ -656,6 +703,11 @@ function visibilityFor(isLatest: boolean, isApproved: boolean) { return 'archived' } +function clampInt(value: number, min: number, max: number) { + const rounded = Number.isFinite(value) ? Math.round(value) : min + return Math.min(max, Math.max(min, rounded)) +} + async function findCanonicalSkillForFingerprint( ctx: { db: MutationCtx['db'] }, fingerprint: string, diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 00000000..e74ad623 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,50 @@ +--- +summary: 'Public REST API (v1) overview and conventions.' +read_when: + - Building API clients + - Adding endpoints or schemas +--- + +# API v1 + +Base: `https://clawdhub.com` + +OpenAPI: `/api/v1/openapi.json` + +## Auth + +- Public read: no token required. +- Write + account: `Authorization: Bearer clh_...`. + +## Rate limits + +Per IP + per API key: + +- Read: 120/min per IP, 600/min per key +- Write: 30/min per IP, 120/min per key + +Headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, `Retry-After` (on 429). + +## Endpoints + +Public read: + +- `GET /api/v1/search?q=...` +- `GET /api/v1/skills?limit=&cursor=` +- `GET /api/v1/skills/{slug}` +- `GET /api/v1/skills/{slug}/versions?limit=&cursor=` +- `GET /api/v1/skills/{slug}/versions/{version}` +- `GET /api/v1/skills/{slug}/file?path=&version=&tag=` +- `GET /api/v1/resolve?slug=&hash=` +- `GET /api/v1/download?slug=&version=&tag=` + +Auth required: + +- `POST /api/v1/skills` (publish, multipart preferred) +- `DELETE /api/v1/skills/{slug}` +- `POST /api/v1/skills/{slug}/undelete` +- `GET /api/v1/whoami` + +## Legacy + +Legacy `/api/*` and `/api/cli/*` still available. See `DEPRECATIONS.md`. diff --git a/docs/architecture.md b/docs/architecture.md index e73c2461..b374e90b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -29,13 +29,13 @@ read_when: ### Search (HTTP) -- `/api/search?q=...` routes to Convex action for vector search. +- `/api/v1/search?q=...` routes to Convex action for vector search. - Embeddings currently generated during publish. ### Install (CLI) -- Resolve latest version via `/api/skill?slug=...`. -- Download zip via `/api/download?slug=...&version=...`. +- Resolve latest version via `/api/v1/skills/`. +- Download zip via `/api/v1/download?slug=...&version=...`. - Extract into `./skills/` (default). - Persist install state: - `./.clawdhub/lock.json` (per workdir) @@ -43,7 +43,7 @@ read_when: ### Update (CLI) -- Hash local files, call `/api/skill/resolve?slug=...&hash=`. +- Hash local files, call `/api/v1/resolve?slug=...&hash=`. - If local matches a known version → use that for “current”. - If local doesn’t match: - refuse by default @@ -51,8 +51,7 @@ read_when: ### Publish (CLI) -- Upload each text file via `/api/cli/upload-url` (Convex upload URL). -- Publish metadata via `/api/cli/publish` (requires Bearer token). +- Publish via `POST /api/v1/skills` (multipart; requires Bearer token). ### Sync (CLI) diff --git a/docs/cli.md b/docs/cli.md index f6661174..13cbf919 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -44,16 +44,16 @@ Stores your API token + cached registry URL. ### `whoami` -- Verifies the stored token via `/api/cli/whoami`. +- Verifies the stored token via `/api/v1/whoami`. ### `search ` -- Calls `/api/search?q=...`. +- Calls `/api/v1/search?q=...`. ### `install ` -- Resolves latest version via `/api/skill?slug=...`. -- Downloads zip via `/api/download`. +- Resolves latest version via `/api/v1/skills/`. +- Downloads zip via `/api/v1/download`. - Extracts into `//`. - Writes: - `/.clawdhub/lock.json` @@ -73,8 +73,7 @@ Stores your API token + cached registry URL. ### `publish ` -- Uploads each file via `/api/cli/upload-url`. -- Publishes via `/api/cli/publish`. +- Publishes via `POST /api/v1/skills` (multipart). - Requires semver: `--version 1.2.3`. ### `sync` diff --git a/docs/deploy.md b/docs/deploy.md index 2b870d95..ff1af4c6 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -66,8 +66,8 @@ export CLAWDHUB_REGISTRY=https://your-site.example ## 5) Post-deploy checks ```bash -curl -i "https:///api/search?q=test" -curl -i "https:///api/skill?slug=gifgrep" +curl -i "https:///api/v1/search?q=test" +curl -i "https:///api/v1/skills/gifgrep" ``` Then: diff --git a/docs/http-api.md b/docs/http-api.md index 7ee2c722..af3dbcee 100644 --- a/docs/http-api.md +++ b/docs/http-api.md @@ -9,17 +9,30 @@ read_when: Base URL: `https://clawdhub.com` (default). -All paths below are under `/api/...` and implemented by Convex HTTP routes (`convex/http.ts`). +All v1 paths are under `/api/v1/...` and implemented by Convex HTTP routes (`convex/http.ts`). +Legacy `/api/...` and `/api/cli/...` remain for compatibility (see `DEPRECATIONS.md`). +OpenAPI: `/api/v1/openapi.json`. + +## Rate limits + +Enforced per IP + per API key: + +- Read: 120/min per IP, 600/min per key +- Write: 30/min per IP, 120/min per key + +Headers: + +- `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, `Retry-After` (when limited) ## Public endpoints (no auth) -### `GET /api/search` +### `GET /api/v1/search` Query params: - `q` (required): query string - `limit` (optional): integer -- `approvedOnly` (optional): `true` to filter to approved-only skills (server may treat as “approved”/badged) +- `highlightedOnly` (optional): `true` to filter to highlighted skills Response: @@ -27,19 +40,54 @@ Response: { "results": [{ "score": 0.123, "slug": "gifgrep", "displayName": "GifGrep", "summary": "…", "version": "1.2.3", "updatedAt": 1730000000000 }] } ``` -### `GET /api/skill` +### `GET /api/v1/skills` Query params: -- `slug` (required) +- `limit` (optional): integer +- `cursor` (optional): pagination cursor -Response (shape is stable; contents may expand): +Response: ```json -{ "skill": { "slug": "gifgrep", "displayName": "GifGrep", "summary": "…", "tags": { "latest": "…" }, "stats": {}, "createdAt": 0, "updatedAt": 0 }, "latestVersion": { "version": "1.2.3", "createdAt": 0, "changelog": "…" }, "owner": { "handle": "steipete", "displayName": "Peter", "image": null } } +{ "items": [{ "slug": "gifgrep", "displayName": "GifGrep", "summary": "…", "tags": { "latest": "1.2.3" }, "stats": {}, "createdAt": 0, "updatedAt": 0, "latestVersion": { "version": "1.2.3", "createdAt": 0, "changelog": "…" } }], "nextCursor": null } ``` -### `GET /api/skill/resolve` +### `GET /api/v1/skills/{slug}` + +Response: + +```json +{ "skill": { "slug": "gifgrep", "displayName": "GifGrep", "summary": "…", "tags": { "latest": "1.2.3" }, "stats": {}, "createdAt": 0, "updatedAt": 0 }, "latestVersion": { "version": "1.2.3", "createdAt": 0, "changelog": "…" }, "owner": { "handle": "steipete", "displayName": "Peter", "image": null } } +``` + +### `GET /api/v1/skills/{slug}/versions` + +Query params: + +- `limit` (optional): integer +- `cursor` (optional): pagination cursor + +### `GET /api/v1/skills/{slug}/versions/{version}` + +Returns version metadata + files list. + +### `GET /api/v1/skills/{slug}/file` + +Returns raw text content. + +Query params: + +- `path` (required) +- `version` (optional) +- `tag` (optional) + +Notes: + +- Defaults to latest version. +- File size limit: 200KB. + +### `GET /api/v1/resolve` Used by the CLI to map a local fingerprint to a known version. @@ -54,7 +102,7 @@ Response: { "slug": "gifgrep", "match": { "version": "1.2.2" }, "latestVersion": { "version": "1.2.3" } } ``` -### `GET /api/download` +### `GET /api/v1/download` Downloads a zip of a skill version. @@ -69,45 +117,42 @@ Notes: - If neither `version` nor `tag` is provided, the latest version is used. - Soft-deleted versions return `410`. -## CLI endpoints (Bearer token) +## Auth endpoints (Bearer token) -All CLI endpoints require: +All endpoints require: ``` Authorization: Bearer clh_... ``` -### `GET /api/cli/whoami` +### `GET /api/v1/whoami` Validates token and returns the user handle. -### `POST /api/cli/upload-url` +### `POST /api/v1/skills` -Returns a Convex upload URL for a single file upload. +Publishes a new version. -Response: +- Preferred: `multipart/form-data` with `payload` JSON + `files[]` blobs. +- JSON body with `files` (storageId-based) is also accepted. -```json -{ "uploadUrl": "https://..." } -``` - -### `POST /api/cli/publish` - -Publishes a new version from uploaded files. - -- Validates semver, slug, size limits, text-only files, and `SKILL.md`. -- Generates embeddings (requires `OPENAI_API_KEY` server-side). - -### `POST /api/cli/telemetry/sync` - -Used by `clawdhub sync` to report install telemetry. - -Details: `docs/telemetry.md`. - -### `POST /api/cli/skill/delete` / `POST /api/cli/skill/undelete` +### `DELETE /api/v1/skills/{slug}` / `POST /api/v1/skills/{slug}/undelete` Soft-delete / restore a skill (owner/admin only). +## Legacy CLI endpoints (deprecated) + +Still supported for older CLI versions: + +- `GET /api/cli/whoami` +- `POST /api/cli/upload-url` +- `POST /api/cli/publish` +- `POST /api/cli/telemetry/sync` +- `POST /api/cli/skill/delete` +- `POST /api/cli/skill/undelete` + +See `DEPRECATIONS.md` for removal plan. + ## Registry discovery (`/.well-known/clawdhub.json`) The CLI can discover registry/auth settings from the site: diff --git a/docs/manual-testing.md b/docs/manual-testing.md index 743aeffe..3262e43a 100644 --- a/docs/manual-testing.md +++ b/docs/manual-testing.md @@ -40,7 +40,7 @@ read_when: ## Delete / undelete (owner/admin) - `bun clawdhub delete clawdhub-manual- --yes` - Verify hidden: - - `curl -i "https://clawdhub.com/api/skill?slug=clawdhub-manual-"` +- `curl -i "https://clawdhub.com/api/v1/skills/clawdhub-manual-"` - Restore: - `bun clawdhub undelete clawdhub-manual- --yes` - Cleanup: diff --git a/e2e/clawdhub.e2e.test.ts b/e2e/clawdhub.e2e.test.ts index 40d0ee83..ea75d88c 100644 --- a/e2e/clawdhub.e2e.test.ts +++ b/e2e/clawdhub.e2e.test.ts @@ -5,9 +5,9 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { - ApiCliWhoamiResponseSchema, ApiRoutes, - ApiSearchResponseSchema, + ApiV1SearchResponseSchema, + ApiV1WhoamiResponseSchema, parseArk, } from 'clawdhub-schema' import { unzipSync } from 'fflate' @@ -50,7 +50,7 @@ describe('clawdhub e2e', () => { const response = await fetch(url.toString(), { headers: { Accept: 'application/json' } }) expect(response.ok).toBe(true) const json = (await response.json()) as unknown - const parsed = parseArk(ApiSearchResponseSchema, json, 'API response') + const parsed = parseArk(ApiV1SearchResponseSchema, json, 'API response') expect(Array.isArray(parsed.results)).toBe(true) }) @@ -102,13 +102,13 @@ describe('clawdhub e2e', () => { const cfg = await makeTempConfig(registry, token) try { - const whoamiUrl = new URL(ApiRoutes.cliWhoami, registry) + const whoamiUrl = new URL(ApiRoutes.whoami, registry) const whoamiRes = await fetch(whoamiUrl.toString(), { headers: { Accept: 'application/json', Authorization: `Bearer ${token}` }, }) expect(whoamiRes.ok).toBe(true) const whoami = parseArk( - ApiCliWhoamiResponseSchema, + ApiV1WhoamiResponseSchema, (await whoamiRes.json()) as unknown, 'Whoami', ) @@ -319,9 +319,9 @@ describe('clawdhub e2e', () => { ) expect(update.status).toBe(0) - const metaUrl = new URL(ApiRoutes.skill, registry) - metaUrl.searchParams.set('slug', slug) - const metaRes = await fetch(metaUrl.toString(), { headers: { Accept: 'application/json' } }) + const metaRes = await fetch(`${registry}${ApiRoutes.skills}/${slug}`, { + headers: { Accept: 'application/json' }, + }) expect(metaRes.status).toBe(200) const del = spawnSync( diff --git a/packages/clawdhub/src/cli/commands/auth.ts b/packages/clawdhub/src/cli/commands/auth.ts index 297db985..27fef8ef 100644 --- a/packages/clawdhub/src/cli/commands/auth.ts +++ b/packages/clawdhub/src/cli/commands/auth.ts @@ -2,7 +2,7 @@ import { buildCliAuthUrl, startLoopbackAuthServer } from '../../browserAuth.js' import { readGlobalConfig, writeGlobalConfig } from '../../config.js' import { discoverRegistryFromSite } from '../../discovery.js' import { apiRequest } from '../../http.js' -import { ApiCliWhoamiResponseSchema, ApiRoutes } from '../../schema/index.js' +import { ApiRoutes, ApiV1WhoamiResponseSchema } from '../../schema/index.js' import { getRegistry } from '../registry.js' import type { GlobalOpts } from '../types.js' import { createSpinner, fail, formatError, openInBrowser, promptHidden } from '../ui.js' @@ -55,8 +55,8 @@ export async function cmdLogin( try { const whoami = await apiRequest( registry, - { method: 'GET', path: ApiRoutes.cliWhoami, token }, - ApiCliWhoamiResponseSchema, + { method: 'GET', path: ApiRoutes.whoami, token }, + ApiV1WhoamiResponseSchema, ) if (!whoami.user) fail('Login failed') @@ -86,8 +86,8 @@ export async function cmdWhoami(opts: GlobalOpts) { try { const whoami = await apiRequest( registry, - { method: 'GET', path: ApiRoutes.cliWhoami, token }, - ApiCliWhoamiResponseSchema, + { method: 'GET', path: ApiRoutes.whoami, token }, + ApiV1WhoamiResponseSchema, ) spinner.succeed(whoami.user.handle ?? 'unknown') } catch (error) { diff --git a/packages/clawdhub/src/cli/commands/delete.test.ts b/packages/clawdhub/src/cli/commands/delete.test.ts index 6b81ca79..01141090 100644 --- a/packages/clawdhub/src/cli/commands/delete.test.ts +++ b/packages/clawdhub/src/cli/commands/delete.test.ts @@ -56,7 +56,7 @@ describe('delete/undelete', () => { await cmdDeleteSkill(makeOpts(), 'demo', { yes: true }, false) expect(mockApiRequest).toHaveBeenCalledWith( expect.anything(), - expect.objectContaining({ method: 'POST', path: '/api/cli/skill/delete' }), + expect.objectContaining({ method: 'DELETE', path: '/api/v1/skills/demo' }), expect.anything(), ) }) @@ -66,7 +66,7 @@ describe('delete/undelete', () => { await cmdUndeleteSkill(makeOpts(), 'demo', { yes: true }, false) expect(mockApiRequest).toHaveBeenCalledWith( expect.anything(), - expect.objectContaining({ method: 'POST', path: '/api/cli/skill/undelete' }), + expect.objectContaining({ method: 'POST', path: '/api/v1/skills/demo/undelete' }), expect.anything(), ) }) diff --git a/packages/clawdhub/src/cli/commands/delete.ts b/packages/clawdhub/src/cli/commands/delete.ts index 6491ef17..dcda531e 100644 --- a/packages/clawdhub/src/cli/commands/delete.ts +++ b/packages/clawdhub/src/cli/commands/delete.ts @@ -1,6 +1,6 @@ import { readGlobalConfig } from '../../config.js' import { apiRequest } from '../../http.js' -import { ApiCliSkillDeleteResponseSchema, ApiRoutes, parseArk } from '../../schema/index.js' +import { ApiRoutes, ApiV1DeleteResponseSchema, parseArk } from '../../schema/index.js' import { getRegistry } from '../registry.js' import type { GlobalOpts } from '../types.js' import { createSpinner, fail, formatError, isInteractive, promptConfirm } from '../ui.js' @@ -32,14 +32,13 @@ export async function cmdDeleteSkill( const registry = await getRegistry(opts, { cache: true }) const spinner = createSpinner(`Deleting ${slug}`) try { - const body = { slug } const result = await apiRequest( registry, - { method: 'POST', path: ApiRoutes.cliSkillDelete, token, body }, - ApiCliSkillDeleteResponseSchema, + { method: 'DELETE', path: `${ApiRoutes.skills}/${encodeURIComponent(slug)}`, token }, + ApiV1DeleteResponseSchema, ) spinner.succeed(`OK. Deleted ${slug}`) - return parseArk(ApiCliSkillDeleteResponseSchema, result, 'Delete response') + return parseArk(ApiV1DeleteResponseSchema, result, 'Delete response') } catch (error) { spinner.fail(formatError(error)) throw error @@ -66,14 +65,17 @@ export async function cmdUndeleteSkill( const registry = await getRegistry(opts, { cache: true }) const spinner = createSpinner(`Undeleting ${slug}`) try { - const body = { slug } const result = await apiRequest( registry, - { method: 'POST', path: ApiRoutes.cliSkillUndelete, token, body }, - ApiCliSkillDeleteResponseSchema, + { + method: 'POST', + path: `${ApiRoutes.skills}/${encodeURIComponent(slug)}/undelete`, + token, + }, + ApiV1DeleteResponseSchema, ) spinner.succeed(`OK. Undeleted ${slug}`) - return parseArk(ApiCliSkillDeleteResponseSchema, result, 'Undelete response') + return parseArk(ApiV1DeleteResponseSchema, result, 'Undelete response') } catch (error) { spinner.fail(formatError(error)) throw error diff --git a/packages/clawdhub/src/cli/commands/publish.test.ts b/packages/clawdhub/src/cli/commands/publish.test.ts index b8bb1f41..09dbc85a 100644 --- a/packages/clawdhub/src/cli/commands/publish.test.ts +++ b/packages/clawdhub/src/cli/commands/publish.test.ts @@ -4,7 +4,6 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { sha256Hex } from '../../skills' import type { GlobalOpts } from '../types' vi.mock('../../config.js', () => ({ @@ -16,10 +15,10 @@ vi.mock('../registry.js', () => ({ getRegistry: (opts: unknown, params?: unknown) => mockGetRegistry(opts, params), })) -const mockApiRequest = vi.fn() +const mockApiRequestForm = vi.fn() vi.mock('../../http.js', () => ({ - apiRequest: (registry: unknown, args: unknown, schema?: unknown) => - mockApiRequest(registry, args, schema), + apiRequestForm: (registry: unknown, args: unknown, schema?: unknown) => + mockApiRequestForm(registry, args, schema), })) const mockFail = vi.fn((message: string) => { @@ -65,37 +64,7 @@ describe('cmdPublish', () => { await writeFile(join(folder, 'SKILL.md'), skillContent, 'utf8') await writeFile(join(folder, 'notes.md'), notesContent, 'utf8') - let uploadIndex = 0 - mockApiRequest.mockImplementation( - async (_registry: string, args: { method: string; path: string }) => { - if (args.method === 'GET' && args.path.startsWith('/api/skill?slug=')) { - return { skill: null, latestVersion: { version: '9.9.9' } } - } - if (args.method === 'POST' && args.path === '/api/cli/upload-url') { - uploadIndex += 1 - return { uploadUrl: `https://upload.example/${uploadIndex}` } - } - if (args.method === 'POST' && args.path === '/api/cli/publish') { - return { ok: true, skillId: 'skill_1', versionId: 'ver_1' } - } - throw new Error(`Unexpected apiRequest: ${args.method} ${args.path}`) - }, - ) - - vi.stubGlobal( - 'fetch', - vi.fn(async (url: string, init?: RequestInit) => { - expect(url).toMatch(/^https:\/\/upload\.example\/\d+$/) - expect(init?.method).toBe('POST') - expect((init?.headers as Record)?.['Content-Type']).toMatch( - /text\/(markdown|plain)/, - ) - return new Response(JSON.stringify({ storageId: `st_${String(url).split('/').pop()}` }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - }) as unknown as typeof fetch, - ) + mockApiRequestForm.mockResolvedValueOnce({ ok: true, skillId: 'skill_1', versionId: 'ver_1' }) await cmdPublish(makeOpts(workdir), 'my-skill', { slug: 'my-skill', @@ -105,30 +74,20 @@ describe('cmdPublish', () => { tags: 'latest', }) - const publishCall = mockApiRequest.mock.calls.find((call) => { + const publishCall = mockApiRequestForm.mock.calls.find((call) => { const req = call[1] as { path?: string } | undefined - return req?.path === '/api/cli/publish' + return req?.path === '/api/v1/skills' }) if (!publishCall) throw new Error('Missing publish call') - const publishBody = (publishCall[1] as { body?: unknown }).body as { - slug: string - displayName: string - version: string - changelog: string - tags: string[] - files: Array<{ path: string; sha256: string; storageId: string }> - } - - expect(publishBody.slug).toBe('my-skill') - expect(publishBody.displayName).toBe('My Skill') - expect(publishBody.version).toBe('1.0.0') - expect(publishBody.changelog).toBe('') - expect(publishBody.tags).toEqual(['latest']) - - const byPath = Object.fromEntries(publishBody.files.map((f) => [f.path, f])) - expect(Object.keys(byPath).sort()).toEqual(['SKILL.md', 'notes.md']) - expect(byPath['SKILL.md']?.sha256).toBe(sha256Hex(new TextEncoder().encode(skillContent))) - expect(byPath['notes.md']?.sha256).toBe(sha256Hex(new TextEncoder().encode(notesContent))) + const publishForm = (publishCall[1] as { form?: FormData }).form as FormData + const payload = JSON.parse(String(publishForm.get('payload'))) + expect(payload.slug).toBe('my-skill') + expect(payload.displayName).toBe('My Skill') + expect(payload.version).toBe('1.0.0') + expect(payload.changelog).toBe('') + expect(payload.tags).toEqual(['latest']) + const files = publishForm.getAll('files') as Array + expect(files.map((file) => String(file.name ?? '')).sort()).toEqual(['SKILL.md', 'notes.md']) } finally { await rm(workdir, { recursive: true, force: true }) } @@ -141,28 +100,7 @@ describe('cmdPublish', () => { await mkdir(folder, { recursive: true }) await writeFile(join(folder, 'SKILL.md'), '# Skill\n', 'utf8') - let uploadIndex = 0 - mockApiRequest.mockImplementation( - async (_registry: string, args: { method: string; path: string }) => { - if (args.method === 'GET' && args.path.startsWith('/api/skill?slug=')) { - return { skill: { slug: 'existing-skill' }, latestVersion: { version: '1.0.0' } } - } - if (args.method === 'POST' && args.path === '/api/cli/upload-url') { - uploadIndex += 1 - return { uploadUrl: `https://upload.example/${uploadIndex}` } - } - if (args.method === 'POST' && args.path === '/api/cli/publish') { - return { ok: true, skillId: 'skill_1', versionId: 'ver_2' } - } - throw new Error(`Unexpected apiRequest: ${args.method} ${args.path}`) - }, - ) - vi.stubGlobal( - 'fetch', - vi.fn( - async () => new Response(JSON.stringify({ storageId: 'st_1' }), { status: 200 }), - ) as unknown as typeof fetch, - ) + mockApiRequestForm.mockResolvedValueOnce({ ok: true, skillId: 'skill_1', versionId: 'ver_2' }) await cmdPublish(makeOpts(workdir), 'existing-skill', { version: '1.0.1', @@ -170,9 +108,9 @@ describe('cmdPublish', () => { tags: 'latest', }) - expect(mockApiRequest).toHaveBeenCalledWith( + expect(mockApiRequestForm).toHaveBeenCalledWith( expect.anything(), - expect.objectContaining({ path: '/api/cli/publish', method: 'POST' }), + expect.objectContaining({ path: '/api/v1/skills', method: 'POST' }), expect.anything(), ) } finally { diff --git a/packages/clawdhub/src/cli/commands/publish.ts b/packages/clawdhub/src/cli/commands/publish.ts index 92bdcd8a..499e8b36 100644 --- a/packages/clawdhub/src/cli/commands/publish.ts +++ b/packages/clawdhub/src/cli/commands/publish.ts @@ -2,16 +2,9 @@ import { stat } from 'node:fs/promises' import { basename, resolve } from 'node:path' import semver from 'semver' import { readGlobalConfig } from '../../config.js' -import { apiRequest } from '../../http.js' -import { - ApiCliPublishResponseSchema, - ApiCliUploadUrlResponseSchema, - ApiRoutes, - ApiUploadFileResponseSchema, - CliPublishRequestSchema, - parseArk, -} from '../../schema/index.js' -import { listTextFiles, sha256Hex } from '../../skills.js' +import { apiRequestForm } from '../../http.js' +import { ApiRoutes, ApiV1PublishResponseSchema } from '../../schema/index.js' +import { listTextFiles } from '../../skills.js' import { getRegistry } from '../registry.js' import { sanitizeSlug, titleCase } from '../slug.js' import type { GlobalOpts } from '../types.js' @@ -69,50 +62,32 @@ export async function cmdPublish( fail('SKILL.md required') } - const uploaded: Array<{ - path: string - size: number - storageId: string - sha256: string - contentType?: string - }> = [] + const form = new FormData() + form.set( + 'payload', + JSON.stringify({ + slug, + displayName, + version, + changelog, + tags, + ...(forkOf ? { forkOf } : {}), + }), + ) let index = 0 for (const file of filesOnDisk) { index += 1 spinner.text = `Uploading ${file.relPath} (${index}/${filesOnDisk.length})` - const { uploadUrl } = await apiRequest( - registry, - { method: 'POST', path: ApiRoutes.cliUploadUrl, token }, - ApiCliUploadUrlResponseSchema, - ) - - const storageId = await uploadFile(uploadUrl, file.bytes, file.contentType ?? 'text/plain') - const sha256 = sha256Hex(file.bytes) - uploaded.push({ - path: file.relPath, - size: file.bytes.byteLength, - storageId, - sha256, - contentType: file.contentType ?? undefined, - }) + const blob = new Blob([Buffer.from(file.bytes)], { type: file.contentType ?? 'text/plain' }) + form.append('files', blob, file.relPath) } spinner.text = `Publishing ${slug}@${version}` - const publishPayload = { - slug, - displayName, - version, - changelog, - tags, - files: uploaded, - ...(forkOf ? { forkOf } : {}), - } - const body = parseArk(CliPublishRequestSchema, publishPayload, 'Publish payload') - const result = await apiRequest( + const result = await apiRequestForm( registry, - { method: 'POST', path: ApiRoutes.cliPublish, token, body }, - ApiCliPublishResponseSchema, + { method: 'POST', path: ApiRoutes.skills, token, form }, + ApiV1PublishResponseSchema, ) spinner.succeed(`OK. Published ${slug}@${version} (${result.versionId})`) @@ -131,20 +106,3 @@ function parseForkOf(value: string) { if (version && !semver.valid(version)) fail('--fork-of version must be valid semver') return { slug, version: version || undefined } } - -async function uploadFile(uploadUrl: string, bytes: Uint8Array, contentType: string) { - const response = await fetch(uploadUrl, { - method: 'POST', - headers: { 'Content-Type': contentType || 'application/octet-stream' }, - body: Buffer.from(bytes), - }) - if (!response.ok) { - throw new Error(`Upload failed: ${await response.text()}`) - } - const payload = parseArk( - ApiUploadFileResponseSchema, - (await response.json()) as unknown, - 'Upload response', - ) - return payload.storageId -} diff --git a/packages/clawdhub/src/cli/commands/skills.ts b/packages/clawdhub/src/cli/commands/skills.ts index 6249f879..76532725 100644 --- a/packages/clawdhub/src/cli/commands/skills.ts +++ b/packages/clawdhub/src/cli/commands/skills.ts @@ -4,9 +4,9 @@ import semver from 'semver' import { apiRequest, downloadZip } from '../../http.js' import { ApiRoutes, - ApiSearchResponseSchema, - ApiSkillMetaResponseSchema, - ApiSkillResolveResponseSchema, + ApiV1SearchResponseSchema, + ApiV1SkillResolveResponseSchema, + ApiV1SkillResponseSchema, } from '../../schema/index.js' import { extractZipToDir, @@ -35,7 +35,7 @@ export async function cmdSearch(opts: GlobalOpts, query: string, limit?: number) const result = await apiRequest( registry, { method: 'GET', url: url.toString() }, - ApiSearchResponseSchema, + ApiV1SearchResponseSchema, ) spinner.stop() @@ -77,8 +77,8 @@ export async function cmdInstall( ( await apiRequest( registry, - { method: 'GET', path: `/api/skill?slug=${encodeURIComponent(trimmed)}` }, - ApiSkillMetaResponseSchema, + { method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}` }, + ApiV1SkillResponseSchema, ) ).latestVersion?.version ?? null @@ -150,12 +150,10 @@ export async function cmdUpdate( if (localFingerprint) { resolveResult = await resolveSkillVersion(registry, entry, localFingerprint) } else { - const url = new URL(ApiRoutes.skill, registry) - url.searchParams.set('slug', entry) const meta = await apiRequest( registry, - { method: 'GET', url: url.toString() }, - ApiSkillMetaResponseSchema, + { method: 'GET', url: `${ApiRoutes.skills}/${encodeURIComponent(entry)}` }, + ApiV1SkillResponseSchema, ) resolveResult = { match: null, latestVersion: meta.latestVersion ?? null } } @@ -244,10 +242,14 @@ export async function cmdList(opts: GlobalOpts) { } async function resolveSkillVersion(registry: string, slug: string, hash: string) { - const url = new URL(ApiRoutes.skillResolve, registry) + const url = new URL(ApiRoutes.resolve, registry) url.searchParams.set('slug', slug) url.searchParams.set('hash', hash) - return apiRequest(registry, { method: 'GET', url: url.toString() }, ApiSkillResolveResponseSchema) + return apiRequest( + registry, + { method: 'GET', url: url.toString() }, + ApiV1SkillResolveResponseSchema, + ) } async function fileExists(path: string) { diff --git a/packages/clawdhub/src/cli/commands/sync.test.ts b/packages/clawdhub/src/cli/commands/sync.test.ts index bf2c8a11..78962ba8 100644 --- a/packages/clawdhub/src/cli/commands/sync.test.ts +++ b/packages/clawdhub/src/cli/commands/sync.test.ts @@ -98,9 +98,9 @@ describe('cmdSync', () => { it('classifies skills as new/update/synced (dry-run, mocked HTTP)', async () => { interactive = false mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => { - if (args.path === '/api/cli/whoami') return { user: { handle: 'steipete' } } + if (args.path === '/api/v1/whoami') return { user: { handle: 'steipete' } } if (args.path === '/api/cli/telemetry/sync') return { ok: true } - if (args.path.startsWith('/api/skill/resolve?')) { + if (args.path.startsWith('/api/v1/resolve?')) { const u = new URL(`https://x.test${args.path}`) const slug = u.searchParams.get('slug') if (slug === 'new-skill') { @@ -135,9 +135,9 @@ describe('cmdSync', () => { return initialValues }) mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => { - if (args.path === '/api/cli/whoami') return { user: { handle: 'steipete' } } + if (args.path === '/api/v1/whoami') return { user: { handle: 'steipete' } } if (args.path === '/api/cli/telemetry/sync') return { ok: true } - if (args.path.startsWith('/api/skill/resolve?')) { + if (args.path.startsWith('/api/v1/resolve?')) { const u = new URL(`https://x.test${args.path}`) const slug = u.searchParams.get('slug') if (slug === 'new-skill') { @@ -171,9 +171,9 @@ describe('cmdSync', () => { it('shows condensed synced list when nothing to sync', async () => { interactive = false mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => { - if (args.path === '/api/cli/whoami') return { user: { handle: 'steipete' } } + if (args.path === '/api/v1/whoami') return { user: { handle: 'steipete' } } if (args.path === '/api/cli/telemetry/sync') return { ok: true } - if (args.path.startsWith('/api/skill/resolve?')) { + if (args.path.startsWith('/api/v1/resolve?')) { return { match: { version: '1.0.0' }, latestVersion: { version: '1.0.0' } } } throw new Error(`Unexpected apiRequest: ${args.path}`) @@ -203,9 +203,9 @@ describe('cmdSync', () => { }) mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => { - if (args.path === '/api/cli/whoami') return { user: { handle: 'steipete' } } + if (args.path === '/api/v1/whoami') return { user: { handle: 'steipete' } } if (args.path === '/api/cli/telemetry/sync') return { ok: true } - if (args.path.startsWith('/api/skill/resolve?')) { + if (args.path.startsWith('/api/v1/resolve?')) { return { match: null, latestVersion: null } } throw new Error(`Unexpected apiRequest: ${args.path}`) @@ -222,9 +222,9 @@ describe('cmdSync', () => { it('allows empty changelog for updates (interactive)', async () => { interactive = true mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => { - if (args.path === '/api/cli/whoami') return { user: { handle: 'steipete' } } + if (args.path === '/api/v1/whoami') return { user: { handle: 'steipete' } } if (args.path === '/api/cli/telemetry/sync') return { ok: true } - if (args.path.startsWith('/api/skill/resolve?')) { + if (args.path.startsWith('/api/v1/resolve?')) { const u = new URL(`https://x.test${args.path}`) const slug = u.searchParams.get('slug') if (slug === 'new-skill') { @@ -254,8 +254,8 @@ describe('cmdSync', () => { interactive = false process.env.CLAWDHUB_DISABLE_TELEMETRY = '1' mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => { - if (args.path === '/api/cli/whoami') return { user: { handle: 'steipete' } } - if (args.path.startsWith('/api/skill/resolve?')) { + if (args.path === '/api/v1/whoami') return { user: { handle: 'steipete' } } + if (args.path.startsWith('/api/v1/resolve?')) { return { match: { version: '1.0.0' }, latestVersion: { version: '1.0.0' } } } throw new Error(`Unexpected apiRequest: ${args.path}`) diff --git a/packages/clawdhub/src/cli/commands/syncHelpers.ts b/packages/clawdhub/src/cli/commands/syncHelpers.ts index e2113d0a..a1c19905 100644 --- a/packages/clawdhub/src/cli/commands/syncHelpers.ts +++ b/packages/clawdhub/src/cli/commands/syncHelpers.ts @@ -7,10 +7,11 @@ import semver from 'semver' import { apiRequest, downloadZip } from '../../http.js' import { ApiCliTelemetrySyncResponseSchema, - ApiCliWhoamiResponseSchema, ApiRoutes, - ApiSkillMetaResponseSchema, - ApiSkillResolveResponseSchema, + ApiV1SkillResolveResponseSchema, + ApiV1SkillResponseSchema, + ApiV1WhoamiResponseSchema, + LegacyApiRoutes, } from '../../schema/index.js' import { hashSkillZip } from '../../skills.js' import { getRegistry } from '../registry.js' @@ -45,7 +46,7 @@ export async function reportTelemetryIfEnabled(params: { params.registry, { method: 'POST', - path: ApiRoutes.cliTelemetrySync, + path: LegacyApiRoutes.cliTelemetrySync, token: params.token, body: { roots }, }, @@ -106,9 +107,9 @@ export async function checkRegistrySyncState( registry, { method: 'GET', - path: `${ApiRoutes.skillResolve}?slug=${encodeURIComponent(skill.slug)}&hash=${encodeURIComponent(skill.fingerprint)}`, + path: `${ApiRoutes.resolve}?slug=${encodeURIComponent(skill.slug)}&hash=${encodeURIComponent(skill.fingerprint)}`, }, - ApiSkillResolveResponseSchema, + ApiV1SkillResolveResponseSchema, ) resolveSupport.value = true const latestVersion = resolved.latestVersion?.version ?? null @@ -148,8 +149,8 @@ export async function checkRegistrySyncState( const meta = await apiRequest( registry, - { method: 'GET', path: `${ApiRoutes.skill}?slug=${encodeURIComponent(skill.slug)}` }, - ApiSkillMetaResponseSchema, + { method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(skill.slug)}` }, + ApiV1SkillResponseSchema, ).catch(() => null) const latestVersion = meta?.latestVersion?.version ?? null @@ -290,8 +291,8 @@ export async function getRegistryWithAuth(opts: GlobalOpts, token: string) { const registry = await getRegistry(opts, { cache: true }) await apiRequest( registry, - { method: 'GET', path: ApiRoutes.cliWhoami, token }, - ApiCliWhoamiResponseSchema, + { method: 'GET', path: ApiRoutes.whoami, token }, + ApiV1WhoamiResponseSchema, ) return registry } diff --git a/packages/clawdhub/src/http.test.ts b/packages/clawdhub/src/http.test.ts index 4e00d700..684a3cc5 100644 --- a/packages/clawdhub/src/http.test.ts +++ b/packages/clawdhub/src/http.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { apiRequest, downloadZip } from './http' -import { ApiCliWhoamiResponseSchema } from './schema/index.js' +import { ApiV1WhoamiResponseSchema } from './schema/index.js' describe('apiRequest', () => { it('adds bearer token and parses json', async () => { @@ -14,7 +14,7 @@ describe('apiRequest', () => { const result = await apiRequest( 'https://example.com', { method: 'GET', path: '/x', token: 'clh_token' }, - ApiCliWhoamiResponseSchema, + ApiV1WhoamiResponseSchema, ) expect(result.user.handle).toBeNull() expect(fetchMock).toHaveBeenCalledTimes(1) diff --git a/packages/clawdhub/src/http.ts b/packages/clawdhub/src/http.ts index c8067d82..83315de1 100644 --- a/packages/clawdhub/src/http.ts +++ b/packages/clawdhub/src/http.ts @@ -3,8 +3,8 @@ import type { ArkValidator } from './schema/index.js' import { ApiRoutes, parseArk } from './schema/index.js' type RequestArgs = - | { method: 'GET' | 'POST'; path: string; token?: string; body?: unknown } - | { method: 'GET' | 'POST'; url: string; token?: string; body?: unknown } + | { method: 'GET' | 'POST' | 'DELETE'; path: string; token?: string; body?: unknown } + | { method: 'GET' | 'POST' | 'DELETE'; url: string; token?: string; body?: unknown } export async function apiRequest(registry: string, args: RequestArgs): Promise export async function apiRequest( @@ -44,6 +44,43 @@ export async function apiRequest( return json as T } +type FormRequestArgs = + | { method: 'POST'; path: string; token?: string; form: FormData } + | { method: 'POST'; url: string; token?: string; form: FormData } + +export async function apiRequestForm(registry: string, args: FormRequestArgs): Promise +export async function apiRequestForm( + registry: string, + args: FormRequestArgs, + schema: ArkValidator, +): Promise +export async function apiRequestForm( + registry: string, + args: FormRequestArgs, + schema?: ArkValidator, +): Promise { + const url = 'url' in args ? args.url : new URL(args.path, registry).toString() + const json = await pRetry( + async () => { + const headers: Record = { Accept: 'application/json' } + if (args.token) headers.Authorization = `Bearer ${args.token}` + const response = await fetch(url, { method: args.method, headers, body: args.form }) + if (!response.ok) { + const text = await response.text().catch(() => '') + const message = text || `HTTP ${response.status}` + if (response.status === 429 || response.status >= 500) { + throw new Error(message) + } + throw new AbortError(message) + } + return (await response.json()) as unknown + }, + { retries: 2 }, + ) + if (schema) return parseArk(schema, json, 'API response') + return json as T +} + export async function downloadZip(registry: string, args: { slug: string; version?: string }) { const url = new URL(ApiRoutes.download, registry) url.searchParams.set('slug', args.slug) diff --git a/packages/clawdhub/src/schema/index.ts b/packages/clawdhub/src/schema/index.ts index dd3471aa..0f332151 100644 --- a/packages/clawdhub/src/schema/index.ts +++ b/packages/clawdhub/src/schema/index.ts @@ -1,5 +1,5 @@ export type { ArkValidator } from './ark.js' export { formatArkErrors, parseArk } from './ark.js' -export { ApiRoutes } from './routes.js' +export { ApiRoutes, LegacyApiRoutes } from './routes.js' export * from './schemas.js' export * from './textFiles.js' diff --git a/packages/clawdhub/src/schema/routes.ts b/packages/clawdhub/src/schema/routes.ts index 23f9525a..e6e3079e 100644 --- a/packages/clawdhub/src/schema/routes.ts +++ b/packages/clawdhub/src/schema/routes.ts @@ -1,4 +1,4 @@ -export const ApiRoutes = { +export const LegacyApiRoutes = { download: '/api/download', search: '/api/search', skill: '/api/skill', @@ -10,3 +10,11 @@ export const ApiRoutes = { cliSkillDelete: '/api/cli/skill/delete', cliSkillUndelete: '/api/cli/skill/undelete', } as const + +export const ApiRoutes = { + search: '/api/v1/search', + resolve: '/api/v1/resolve', + download: '/api/v1/download', + skills: '/api/v1/skills', + whoami: '/api/v1/whoami', +} as const diff --git a/packages/clawdhub/src/schema/schemas.ts b/packages/clawdhub/src/schema/schemas.ts index ae25339f..02895c49 100644 --- a/packages/clawdhub/src/schema/schemas.ts +++ b/packages/clawdhub/src/schema/schemas.ts @@ -117,6 +117,104 @@ export const ApiCliTelemetrySyncResponseSchema = type({ ok: 'true', }) +export const ApiV1WhoamiResponseSchema = type({ + user: { + handle: 'string|null', + displayName: 'string|null?', + image: 'string|null?', + }, +}) + +export const ApiV1SearchResponseSchema = type({ + results: type({ + slug: 'string?', + displayName: 'string?', + summary: 'string|null?', + version: 'string|null?', + score: 'number', + updatedAt: 'number?', + }).array(), +}) + +export const ApiV1SkillListResponseSchema = type({ + items: type({ + slug: 'string', + displayName: 'string', + summary: 'string|null?', + tags: 'unknown', + stats: 'unknown', + createdAt: 'number', + updatedAt: 'number', + latestVersion: type({ + version: 'string', + createdAt: 'number', + changelog: 'string', + }).optional(), + }).array(), + nextCursor: 'string|null', +}) + +export const ApiV1SkillResponseSchema = type({ + skill: type({ + slug: 'string', + displayName: 'string', + summary: 'string|null?', + tags: 'unknown', + stats: 'unknown', + createdAt: 'number', + updatedAt: 'number', + }).or('null'), + latestVersion: type({ + version: 'string', + createdAt: 'number', + changelog: 'string', + }).or('null'), + owner: type({ + handle: 'string|null', + displayName: 'string|null?', + image: 'string|null?', + }).or('null'), +}) + +export const ApiV1SkillVersionListResponseSchema = type({ + items: type({ + version: 'string', + createdAt: 'number', + changelog: 'string', + changelogSource: '"auto"|"user"|null?', + }).array(), + nextCursor: 'string|null', +}) + +export const ApiV1SkillVersionResponseSchema = type({ + version: type({ + version: 'string', + createdAt: 'number', + changelog: 'string', + changelogSource: '"auto"|"user"|null?', + files: 'unknown?', + }).or('null'), + skill: type({ + slug: 'string', + displayName: 'string', + }).or('null'), +}) + +export const ApiV1SkillResolveResponseSchema = type({ + match: type({ version: 'string' }).or('null'), + latestVersion: type({ version: 'string' }).or('null'), +}) + +export const ApiV1PublishResponseSchema = type({ + ok: 'true', + skillId: 'string', + versionId: 'string', +}) + +export const ApiV1DeleteResponseSchema = type({ + ok: 'true', +}) + export const SkillInstallSpecSchema = type({ id: 'string?', kind: '"brew"|"node"|"go"|"uv"', diff --git a/packages/schema/dist/index.d.ts b/packages/schema/dist/index.d.ts index 07f07b68..bc7a324b 100644 --- a/packages/schema/dist/index.d.ts +++ b/packages/schema/dist/index.d.ts @@ -1,5 +1,5 @@ export type { ArkValidator } from './ark.js'; export { formatArkErrors, parseArk } from './ark.js'; -export { ApiRoutes } from './routes.js'; +export { ApiRoutes, LegacyApiRoutes } from './routes.js'; export * from './schemas.js'; export * from './textFiles.js'; diff --git a/packages/schema/dist/index.js b/packages/schema/dist/index.js index 3b86c20e..0faa9674 100644 --- a/packages/schema/dist/index.js +++ b/packages/schema/dist/index.js @@ -1,5 +1,5 @@ export { formatArkErrors, parseArk } from './ark.js'; -export { ApiRoutes } from './routes.js'; +export { ApiRoutes, LegacyApiRoutes } from './routes.js'; export * from './schemas.js'; export * from './textFiles.js'; //# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/packages/schema/dist/index.js.map b/packages/schema/dist/index.js.map index 97ea8e3a..58d26f48 100644 --- a/packages/schema/dist/index.js.map +++ b/packages/schema/dist/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAA;AACpD,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AACvC,cAAc,cAAc,CAAA;AAC5B,cAAc,gBAAgB,CAAA"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAA;AACpD,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AACxD,cAAc,cAAc,CAAA;AAC5B,cAAc,gBAAgB,CAAA"} \ No newline at end of file diff --git a/packages/schema/dist/routes.d.ts b/packages/schema/dist/routes.d.ts index 62b5fd22..153ed6d1 100644 --- a/packages/schema/dist/routes.d.ts +++ b/packages/schema/dist/routes.d.ts @@ -1,4 +1,4 @@ -export declare const ApiRoutes: { +export declare const LegacyApiRoutes: { readonly download: "/api/download"; readonly search: "/api/search"; readonly skill: "/api/skill"; @@ -10,3 +10,10 @@ export declare const ApiRoutes: { readonly cliSkillDelete: "/api/cli/skill/delete"; readonly cliSkillUndelete: "/api/cli/skill/undelete"; }; +export declare const ApiRoutes: { + readonly search: "/api/v1/search"; + readonly resolve: "/api/v1/resolve"; + readonly download: "/api/v1/download"; + readonly skills: "/api/v1/skills"; + readonly whoami: "/api/v1/whoami"; +}; diff --git a/packages/schema/dist/routes.js b/packages/schema/dist/routes.js index c0cc1378..5b1e82ce 100644 --- a/packages/schema/dist/routes.js +++ b/packages/schema/dist/routes.js @@ -1,4 +1,4 @@ -export const ApiRoutes = { +export const LegacyApiRoutes = { download: '/api/download', search: '/api/search', skill: '/api/skill', @@ -10,4 +10,11 @@ export const ApiRoutes = { cliSkillDelete: '/api/cli/skill/delete', cliSkillUndelete: '/api/cli/skill/undelete', }; +export const ApiRoutes = { + search: '/api/v1/search', + resolve: '/api/v1/resolve', + download: '/api/v1/download', + skills: '/api/v1/skills', + whoami: '/api/v1/whoami', +}; //# sourceMappingURL=routes.js.map \ No newline at end of file diff --git a/packages/schema/dist/routes.js.map b/packages/schema/dist/routes.js.map index 9fd9ee7f..8dfb3ce8 100644 --- a/packages/schema/dist/routes.js.map +++ b/packages/schema/dist/routes.js.map @@ -1 +1 @@ -{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;IAC9B,gBAAgB,EAAE,yBAAyB;IAC3C,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAA"} \ No newline at end of file +{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;IAC9B,gBAAgB,EAAE,yBAAyB;IAC3C,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAA;AAEV,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,MAAM,EAAE,gBAAgB;IACxB,MAAM,EAAE,gBAAgB;CAChB,CAAA"} \ No newline at end of file diff --git a/packages/schema/dist/schemas.d.ts b/packages/schema/dist/schemas.d.ts index f60cbe22..302058dd 100644 --- a/packages/schema/dist/schemas.d.ts +++ b/packages/schema/dist/schemas.d.ts @@ -110,6 +110,99 @@ export type CliTelemetrySyncRequest = (typeof CliTelemetrySyncRequestSchema)[inf export declare const ApiCliTelemetrySyncResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{ ok: true; }, {}>; +export declare const ApiV1WhoamiResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{ + user: { + handle: string | null; + displayName?: string | null | undefined; + image?: string | null | undefined; + }; +}, {}>; +export declare const ApiV1SearchResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{ + results: { + score: number; + slug?: string | undefined; + displayName?: string | undefined; + summary?: string | null | undefined; + version?: string | null | undefined; + updatedAt?: number | undefined; + }[]; +}, {}>; +export declare const ApiV1SkillListResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{ + items: { + slug: string; + displayName: string; + tags: unknown; + stats: unknown; + createdAt: number; + updatedAt: number; + summary?: string | null | undefined; + latestVersion?: { + version: string; + createdAt: number; + changelog: string; + } | undefined; + }[]; + nextCursor: string | null; +}, {}>; +export declare const ApiV1SkillResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{ + skill: { + slug: string; + displayName: string; + tags: unknown; + stats: unknown; + createdAt: number; + updatedAt: number; + summary?: string | null | undefined; + } | null; + latestVersion: { + version: string; + createdAt: number; + changelog: string; + } | null; + owner: { + handle: string | null; + displayName?: string | null | undefined; + image?: string | null | undefined; + } | null; +}, {}>; +export declare const ApiV1SkillVersionListResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{ + items: { + version: string; + createdAt: number; + changelog: string; + changelogSource?: "user" | "auto" | null | undefined; + }[]; + nextCursor: string | null; +}, {}>; +export declare const ApiV1SkillVersionResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{ + version: { + version: string; + createdAt: number; + changelog: string; + changelogSource?: "user" | "auto" | null | undefined; + files?: unknown; + } | null; + skill: { + slug: string; + displayName: string; + } | null; +}, {}>; +export declare const ApiV1SkillResolveResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{ + match: { + version: string; + } | null; + latestVersion: { + version: string; + } | null; +}, {}>; +export declare const ApiV1PublishResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{ + ok: true; + skillId: string; + versionId: string; +}, {}>; +export declare const ApiV1DeleteResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{ + ok: true; +}, {}>; export declare const SkillInstallSpecSchema: import("arktype/internal/variants/object.ts").ObjectType<{ kind: "brew" | "node" | "go" | "uv"; id?: string | undefined; diff --git a/packages/schema/dist/schemas.js b/packages/schema/dist/schemas.js index eaca69c3..eaf8d08b 100644 --- a/packages/schema/dist/schemas.js +++ b/packages/schema/dist/schemas.js @@ -93,6 +93,95 @@ export const CliTelemetrySyncRequestSchema = type({ export const ApiCliTelemetrySyncResponseSchema = type({ ok: 'true', }); +export const ApiV1WhoamiResponseSchema = type({ + user: { + handle: 'string|null', + displayName: 'string|null?', + image: 'string|null?', + }, +}); +export const ApiV1SearchResponseSchema = type({ + results: type({ + slug: 'string?', + displayName: 'string?', + summary: 'string|null?', + version: 'string|null?', + score: 'number', + updatedAt: 'number?', + }).array(), +}); +export const ApiV1SkillListResponseSchema = type({ + items: type({ + slug: 'string', + displayName: 'string', + summary: 'string|null?', + tags: 'unknown', + stats: 'unknown', + createdAt: 'number', + updatedAt: 'number', + latestVersion: type({ + version: 'string', + createdAt: 'number', + changelog: 'string', + }).optional(), + }).array(), + nextCursor: 'string|null', +}); +export const ApiV1SkillResponseSchema = type({ + skill: type({ + slug: 'string', + displayName: 'string', + summary: 'string|null?', + tags: 'unknown', + stats: 'unknown', + createdAt: 'number', + updatedAt: 'number', + }).or('null'), + latestVersion: type({ + version: 'string', + createdAt: 'number', + changelog: 'string', + }).or('null'), + owner: type({ + handle: 'string|null', + displayName: 'string|null?', + image: 'string|null?', + }).or('null'), +}); +export const ApiV1SkillVersionListResponseSchema = type({ + items: type({ + version: 'string', + createdAt: 'number', + changelog: 'string', + changelogSource: '"auto"|"user"|null?', + }).array(), + nextCursor: 'string|null', +}); +export const ApiV1SkillVersionResponseSchema = type({ + version: type({ + version: 'string', + createdAt: 'number', + changelog: 'string', + changelogSource: '"auto"|"user"|null?', + files: 'unknown?', + }).or('null'), + skill: type({ + slug: 'string', + displayName: 'string', + }).or('null'), +}); +export const ApiV1SkillResolveResponseSchema = type({ + match: type({ version: 'string' }).or('null'), + latestVersion: type({ version: 'string' }).or('null'), +}); +export const ApiV1PublishResponseSchema = type({ + ok: 'true', + skillId: 'string', + versionId: 'string', +}); +export const ApiV1DeleteResponseSchema = type({ + ok: 'true', +}); export const SkillInstallSpecSchema = type({ id: 'string?', kind: '"brew"|"node"|"go"|"uv"', diff --git a/packages/schema/dist/schemas.js.map b/packages/schema/dist/schemas.js.map index cf375ac4..873c87b5 100644 --- a/packages/schema/dist/schemas.js.map +++ b/packages/schema/dist/schemas.js.map @@ -1 +1 @@ -{"version":3,"file":"schemas.js","sourceRoot":"","sources":["../src/schemas.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"schemas.js","sourceRoot":"","sources":["../src/schemas.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index dd3471aa..0f332151 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -1,5 +1,5 @@ export type { ArkValidator } from './ark.js' export { formatArkErrors, parseArk } from './ark.js' -export { ApiRoutes } from './routes.js' +export { ApiRoutes, LegacyApiRoutes } from './routes.js' export * from './schemas.js' export * from './textFiles.js' diff --git a/packages/schema/src/routes.ts b/packages/schema/src/routes.ts index 23f9525a..e6e3079e 100644 --- a/packages/schema/src/routes.ts +++ b/packages/schema/src/routes.ts @@ -1,4 +1,4 @@ -export const ApiRoutes = { +export const LegacyApiRoutes = { download: '/api/download', search: '/api/search', skill: '/api/skill', @@ -10,3 +10,11 @@ export const ApiRoutes = { cliSkillDelete: '/api/cli/skill/delete', cliSkillUndelete: '/api/cli/skill/undelete', } as const + +export const ApiRoutes = { + search: '/api/v1/search', + resolve: '/api/v1/resolve', + download: '/api/v1/download', + skills: '/api/v1/skills', + whoami: '/api/v1/whoami', +} as const diff --git a/packages/schema/src/schemas.ts b/packages/schema/src/schemas.ts index ae25339f..02895c49 100644 --- a/packages/schema/src/schemas.ts +++ b/packages/schema/src/schemas.ts @@ -117,6 +117,104 @@ export const ApiCliTelemetrySyncResponseSchema = type({ ok: 'true', }) +export const ApiV1WhoamiResponseSchema = type({ + user: { + handle: 'string|null', + displayName: 'string|null?', + image: 'string|null?', + }, +}) + +export const ApiV1SearchResponseSchema = type({ + results: type({ + slug: 'string?', + displayName: 'string?', + summary: 'string|null?', + version: 'string|null?', + score: 'number', + updatedAt: 'number?', + }).array(), +}) + +export const ApiV1SkillListResponseSchema = type({ + items: type({ + slug: 'string', + displayName: 'string', + summary: 'string|null?', + tags: 'unknown', + stats: 'unknown', + createdAt: 'number', + updatedAt: 'number', + latestVersion: type({ + version: 'string', + createdAt: 'number', + changelog: 'string', + }).optional(), + }).array(), + nextCursor: 'string|null', +}) + +export const ApiV1SkillResponseSchema = type({ + skill: type({ + slug: 'string', + displayName: 'string', + summary: 'string|null?', + tags: 'unknown', + stats: 'unknown', + createdAt: 'number', + updatedAt: 'number', + }).or('null'), + latestVersion: type({ + version: 'string', + createdAt: 'number', + changelog: 'string', + }).or('null'), + owner: type({ + handle: 'string|null', + displayName: 'string|null?', + image: 'string|null?', + }).or('null'), +}) + +export const ApiV1SkillVersionListResponseSchema = type({ + items: type({ + version: 'string', + createdAt: 'number', + changelog: 'string', + changelogSource: '"auto"|"user"|null?', + }).array(), + nextCursor: 'string|null', +}) + +export const ApiV1SkillVersionResponseSchema = type({ + version: type({ + version: 'string', + createdAt: 'number', + changelog: 'string', + changelogSource: '"auto"|"user"|null?', + files: 'unknown?', + }).or('null'), + skill: type({ + slug: 'string', + displayName: 'string', + }).or('null'), +}) + +export const ApiV1SkillResolveResponseSchema = type({ + match: type({ version: 'string' }).or('null'), + latestVersion: type({ version: 'string' }).or('null'), +}) + +export const ApiV1PublishResponseSchema = type({ + ok: 'true', + skillId: 'string', + versionId: 'string', +}) + +export const ApiV1DeleteResponseSchema = type({ + ok: 'true', +}) + export const SkillInstallSpecSchema = type({ id: 'string?', kind: '"brew"|"node"|"go"|"uv"', diff --git a/public/api/v1/openapi.json b/public/api/v1/openapi.json new file mode 100644 index 00000000..7bc5a463 --- /dev/null +++ b/public/api/v1/openapi.json @@ -0,0 +1,379 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "ClawdHub API", + "version": "1.0.0", + "description": "Public REST API for skills. Rate limits: read 120/min per IP + 600/min per key; write 30/min per IP + 120/min per key." + }, + "servers": [{ "url": "https://clawdhub.com" }], + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "API token" + } + }, + "schemas": { + "SearchResult": { + "type": "object", + "properties": { + "score": { "type": "number" }, + "slug": { "type": ["string", "null"] }, + "displayName": { "type": ["string", "null"] }, + "summary": { "type": ["string", "null"] }, + "version": { "type": ["string", "null"] }, + "updatedAt": { "type": ["number", "null"] } + } + }, + "SearchResponse": { + "type": "object", + "properties": { + "results": { "type": "array", "items": { "$ref": "#/components/schemas/SearchResult" } } + } + }, + "TagMap": { + "type": "object", + "additionalProperties": { "type": "string" } + }, + "Stats": { + "type": "object", + "additionalProperties": true + }, + "SkillListItem": { + "type": "object", + "properties": { + "slug": { "type": "string" }, + "displayName": { "type": "string" }, + "summary": { "type": ["string", "null"] }, + "tags": { "$ref": "#/components/schemas/TagMap" }, + "stats": { "$ref": "#/components/schemas/Stats" }, + "createdAt": { "type": "number" }, + "updatedAt": { "type": "number" }, + "latestVersion": { + "type": ["object", "null"], + "properties": { + "version": { "type": "string" }, + "createdAt": { "type": "number" }, + "changelog": { "type": "string" } + } + } + } + }, + "SkillListResponse": { + "type": "object", + "properties": { + "items": { "type": "array", "items": { "$ref": "#/components/schemas/SkillListItem" } }, + "nextCursor": { "type": ["string", "null"] } + } + }, + "Owner": { + "type": ["object", "null"], + "properties": { + "handle": { "type": ["string", "null"] }, + "displayName": { "type": ["string", "null"] }, + "image": { "type": ["string", "null"] } + } + }, + "Skill": { + "type": ["object", "null"], + "properties": { + "slug": { "type": "string" }, + "displayName": { "type": "string" }, + "summary": { "type": ["string", "null"] }, + "tags": { "$ref": "#/components/schemas/TagMap" }, + "stats": { "$ref": "#/components/schemas/Stats" }, + "createdAt": { "type": "number" }, + "updatedAt": { "type": "number" } + } + }, + "SkillResponse": { + "type": "object", + "properties": { + "skill": { "$ref": "#/components/schemas/Skill" }, + "latestVersion": { + "type": ["object", "null"], + "properties": { + "version": { "type": "string" }, + "createdAt": { "type": "number" }, + "changelog": { "type": "string" } + } + }, + "owner": { "$ref": "#/components/schemas/Owner" } + } + }, + "SkillVersion": { + "type": "object", + "properties": { + "version": { "type": "string" }, + "createdAt": { "type": "number" }, + "changelog": { "type": "string" }, + "changelogSource": { "type": ["string", "null"], "enum": ["auto", "user", null] } + } + }, + "SkillVersionListResponse": { + "type": "object", + "properties": { + "items": { "type": "array", "items": { "$ref": "#/components/schemas/SkillVersion" } }, + "nextCursor": { "type": ["string", "null"] } + } + }, + "SkillVersionResponse": { + "type": "object", + "properties": { + "skill": { + "type": ["object", "null"], + "properties": { + "slug": { "type": "string" }, + "displayName": { "type": "string" } + } + }, + "version": { + "type": ["object", "null"], + "properties": { + "version": { "type": "string" }, + "createdAt": { "type": "number" }, + "changelog": { "type": "string" }, + "changelogSource": { "type": ["string", "null"], "enum": ["auto", "user", null] }, + "files": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { "type": "string" }, + "size": { "type": "number" }, + "sha256": { "type": "string" }, + "contentType": { "type": ["string", "null"] } + } + } + } + } + } + } + }, + "ResolveResponse": { + "type": "object", + "properties": { + "match": { "type": ["object", "null"], "properties": { "version": { "type": "string" } } }, + "latestVersion": { "type": ["object", "null"], "properties": { "version": { "type": "string" } } } + } + }, + "PublishResponse": { + "type": "object", + "properties": { + "ok": { "type": "boolean" }, + "skillId": { "type": "string" }, + "versionId": { "type": "string" } + } + }, + "DeleteResponse": { + "type": "object", + "properties": { "ok": { "type": "boolean" } } + }, + "WhoamiResponse": { + "type": "object", + "properties": { + "user": { + "type": "object", + "properties": { + "handle": { "type": ["string", "null"] }, + "displayName": { "type": ["string", "null"] }, + "image": { "type": ["string", "null"] } + } + } + } + } + } + }, + "paths": { + "/api/v1/search": { + "get": { + "summary": "Search skills", + "parameters": [ + { "name": "q", "in": "query", "required": true, "schema": { "type": "string" } }, + { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer" } }, + { "name": "highlightedOnly", "in": "query", "required": false, "schema": { "type": "boolean" } } + ], + "responses": { + "200": { + "description": "Search results", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SearchResponse" } } } + } + } + } + }, + "/api/v1/resolve": { + "get": { + "summary": "Resolve version by hash", + "parameters": [ + { "name": "slug", "in": "query", "required": true, "schema": { "type": "string" } }, + { "name": "hash", "in": "query", "required": true, "schema": { "type": "string" } } + ], + "responses": { + "200": { "description": "Resolved", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ResolveResponse" } } } }, + "404": { "description": "Not found" } + } + } + }, + "/api/v1/skills": { + "get": { + "summary": "List skills", + "parameters": [ + { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer" } }, + { "name": "cursor", "in": "query", "required": false, "schema": { "type": "string" } } + ], + "responses": { + "200": { "description": "Skills", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SkillListResponse" } } } } + } + }, + "post": { + "summary": "Publish a skill version", + "security": [{ "bearerAuth": [] }], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "payload": { "type": "string" }, + "files": { "type": "array", "items": { "type": "string", "format": "binary" } } + }, + "required": ["payload", "files"] + } + }, + "application/json": { + "schema": { + "type": "object", + "properties": { + "slug": { "type": "string" }, + "displayName": { "type": "string" }, + "version": { "type": "string" }, + "changelog": { "type": "string" }, + "tags": { "type": "array", "items": { "type": "string" } }, + "forkOf": { + "type": "object", + "properties": { "slug": { "type": "string" }, "version": { "type": "string" } } + }, + "files": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { "type": "string" }, + "size": { "type": "number" }, + "storageId": { "type": "string" }, + "sha256": { "type": "string" }, + "contentType": { "type": "string" } + } + } + } + }, + "required": ["slug", "displayName", "version", "changelog", "files"] + } + } + } + }, + "responses": { + "200": { "description": "Published", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PublishResponse" } } } } + } + } + }, + "/api/v1/skills/{slug}": { + "get": { + "summary": "Get skill", + "parameters": [ + { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { + "200": { "description": "Skill", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SkillResponse" } } } }, + "404": { "description": "Not found" } + } + }, + "delete": { + "summary": "Soft delete skill", + "security": [{ "bearerAuth": [] }], + "parameters": [ + { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { + "200": { "description": "Deleted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeleteResponse" } } } } + } + } + }, + "/api/v1/skills/{slug}/undelete": { + "post": { + "summary": "Undelete skill", + "security": [{ "bearerAuth": [] }], + "parameters": [ + { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { + "200": { "description": "Undeleted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeleteResponse" } } } } + } + } + }, + "/api/v1/skills/{slug}/versions": { + "get": { + "summary": "List versions", + "parameters": [ + { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer" } }, + { "name": "cursor", "in": "query", "required": false, "schema": { "type": "string" } } + ], + "responses": { + "200": { "description": "Versions", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SkillVersionListResponse" } } } } + } + } + }, + "/api/v1/skills/{slug}/versions/{version}": { + "get": { + "summary": "Get version", + "parameters": [ + { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "version", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { + "200": { "description": "Version", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SkillVersionResponse" } } } } + } + } + }, + "/api/v1/skills/{slug}/file": { + "get": { + "summary": "Fetch raw file", + "parameters": [ + { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "path", "in": "query", "required": true, "schema": { "type": "string" } }, + { "name": "version", "in": "query", "required": false, "schema": { "type": "string" } }, + { "name": "tag", "in": "query", "required": false, "schema": { "type": "string" } } + ], + "responses": { + "200": { "description": "File contents", "content": { "text/plain": { "schema": { "type": "string" } } } }, + "404": { "description": "Not found" } + } + } + }, + "/api/v1/download": { + "get": { + "summary": "Download zip", + "parameters": [ + { "name": "slug", "in": "query", "required": true, "schema": { "type": "string" } }, + { "name": "version", "in": "query", "required": false, "schema": { "type": "string" } }, + { "name": "tag", "in": "query", "required": false, "schema": { "type": "string" } } + ], + "responses": { + "200": { "description": "Zip", "content": { "application/zip": { "schema": { "type": "string", "format": "binary" } } } } + } + } + }, + "/api/v1/whoami": { + "get": { + "summary": "Current user", + "security": [{ "bearerAuth": [] }], + "responses": { + "200": { "description": "User", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WhoamiResponse" } } } } + } + } + } + } +} diff --git a/src/components/SkillDetailPage.tsx b/src/components/SkillDetailPage.tsx index 370723c6..bcb796d2 100644 --- a/src/components/SkillDetailPage.tsx +++ b/src/components/SkillDetailPage.tsx @@ -220,7 +220,7 @@ export function SkillDetailPage({ Download zip @@ -445,7 +445,7 @@ export function SkillDetailPage({
Zip diff --git a/src/lib/og.ts b/src/lib/og.ts index 5369d896..dd2a8d7a 100644 --- a/src/lib/og.ts +++ b/src/lib/og.ts @@ -27,13 +27,12 @@ export function getApiBase() { export async function fetchSkillMeta(slug: string) { try { const apiBase = getApiBase() - const url = new URL('/api/skill', apiBase) - url.searchParams.set('slug', slug) + const url = new URL(`/api/v1/skills/${encodeURIComponent(slug)}`, apiBase) const response = await fetch(url.toString(), { headers: { Accept: 'application/json' } }) if (!response.ok) return null const payload = (await response.json()) as { - skill?: { displayName?: string; summary?: string | null } - owner?: { handle?: string | null } + skill?: { displayName?: string; summary?: string | null } | null + owner?: { handle?: string | null } | null } return { displayName: payload.skill?.displayName ?? null,