From ff63761829d95ea0303480202b0fd7e0ed71dbf6 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Sun, 31 May 2026 11:11:19 -0700 Subject: [PATCH 1/2] feat: expose ClawHub catalog feed lanes --- convex/feeds.ts | 149 ++++++++++ convex/http.ts | 37 ++- convex/httpApiV1.handlers.test.ts | 281 +++++++++++++++++++ convex/httpApiV1.ts | 17 ++ convex/httpApiV1/feedsV1.ts | 429 +++++++++++++++++++++++++++++ packages/schema/dist/routes.d.ts | 5 + packages/schema/dist/routes.js | 5 + packages/schema/dist/routes.js.map | 2 +- packages/schema/src/routes.ts | 5 + 9 files changed, 928 insertions(+), 2 deletions(-) create mode 100644 convex/feeds.ts create mode 100644 convex/httpApiV1/feedsV1.ts diff --git a/convex/feeds.ts b/convex/feeds.ts new file mode 100644 index 0000000000..efe42d26f2 --- /dev/null +++ b/convex/feeds.ts @@ -0,0 +1,149 @@ +import { v } from "convex/values"; +import { query } from "./functions"; + +const MAX_ROOT_FEED_ITEMS = 5_000; +const FEED_KINDS = ["all", "official", "community", "reviewed"] as const; + +function clampLimit(limit: number | undefined) { + if (!limit || !Number.isFinite(limit)) return 500; + return Math.max(1, Math.min(Math.floor(limit), MAX_ROOT_FEED_ITEMS)); +} + +function matchesSkillFeed( + skill: { + badges?: { official?: unknown; deprecated?: unknown }; + softDeletedAt?: number; + moderationStatus?: string; + moderationFlags?: string[]; + isSuspicious?: boolean; + }, + feed: (typeof FEED_KINDS)[number], +) { + if (skill.softDeletedAt) return false; + if (skill.moderationStatus && skill.moderationStatus !== "active") return false; + if (skill.moderationFlags?.includes("blocked.malware")) return false; + + const isOfficial = Boolean(skill.badges?.official); + if (feed === "official") return isOfficial; + if (feed === "community") return !isOfficial; + if (feed === "reviewed") return !skill.badges?.deprecated && skill.isSuspicious !== true; + return true; +} + +function matchesPackageFeed( + pkg: { + family?: string; + channel?: string; + isOfficial?: boolean; + scanStatus?: string; + verificationTier?: string; + }, + feed: (typeof FEED_KINDS)[number], +) { + if (pkg.family === "skill") return false; + if (pkg.channel === "private") return false; + if (pkg.scanStatus === "malicious") return false; + + if (feed === "official") return pkg.isOfficial || pkg.channel === "official"; + if (feed === "community") return !pkg.isOfficial && pkg.channel === "community"; + if (feed === "reviewed") { + return ( + pkg.scanStatus === "clean" && + typeof pkg.verificationTier === "string" && + pkg.verificationTier.length > 0 + ); + } + return true; +} + +export const rootFeed = query({ + args: { + feed: v.optional(v.union(...FEED_KINDS.map((feed) => v.literal(feed)))), + limit: v.optional(v.number()), + includeSkills: v.optional(v.boolean()), + includePlugins: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const feed = args.feed ?? "all"; + const limit = clampLimit(args.limit); + const includeSkills = args.includeSkills !== false; + const includePlugins = args.includePlugins !== false; + + const [skills, packageResult] = await Promise.all([ + includeSkills + ? ctx.db + .query("skillSearchDigest") + .withIndex("by_active_updated", (q) => q.eq("softDeletedAt", undefined)) + .order("desc") + .take(MAX_ROOT_FEED_ITEMS) + : Promise.resolve([]), + includePlugins + ? (async () => { + const pluginFamilies = await Promise.all([ + ctx.db + .query("packageSearchDigest") + .withIndex("by_active_family_updated", (q) => + q.eq("softDeletedAt", undefined).eq("family", "code-plugin"), + ) + .order("desc") + .take(MAX_ROOT_FEED_ITEMS), + ctx.db + .query("packageSearchDigest") + .withIndex("by_active_family_updated", (q) => + q.eq("softDeletedAt", undefined).eq("family", "bundle-plugin"), + ) + .order("desc") + .take(MAX_ROOT_FEED_ITEMS), + ]); + return { + hitCap: pluginFamilies.some((family) => family.length >= MAX_ROOT_FEED_ITEMS), + items: pluginFamilies + .flat() + .sort((left, right) => right.updatedAt - left.updatedAt) + .slice(0, MAX_ROOT_FEED_ITEMS), + }; + })() + : Promise.resolve({ hitCap: false, items: [] }), + ]); + const packages = packageResult.items; + const hitQueryCap = + (includeSkills && skills.length >= MAX_ROOT_FEED_ITEMS) || packageResult.hitCap; + const matchedSkills = skills.filter((skill) => matchesSkillFeed(skill, feed)); + const matchedPackages = packages.filter((pkg) => matchesPackageFeed(pkg, feed)); + const combined = [ + ...matchedSkills.map((skill) => ({ + kind: "skill" as const, + updatedAt: skill.updatedAt, + value: skill, + })), + ...matchedPackages.map((pkg) => ({ + kind: "package" as const, + updatedAt: pkg.updatedAt, + value: pkg, + })), + ].sort((left, right) => right.updatedAt - left.updatedAt); + + const selected = combined.slice(0, limit); + const filteredSkills: typeof skills = []; + const filteredPackages: typeof packages = []; + for (const entry of selected) { + if (entry.kind === "skill") { + filteredSkills.push(entry.value); + } else { + filteredPackages.push(entry.value); + } + } + + return { + generatedAtMs: Math.max( + 0, + ...filteredSkills.map((skill) => skill.updatedAt), + ...filteredPackages.map((pkg) => pkg.updatedAt), + ), + skills: filteredSkills, + packages: filteredPackages, + truncated: hitQueryCap || combined.length > selected.length, + limit, + }; + }, +}); diff --git a/convex/http.ts b/convex/http.ts index ae550f20fa..5b08fa21d8 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -16,7 +16,11 @@ import { searchSkillsHttp, } from "./httpApi"; import { + allFeedV1Http, + communityFeedV1Http, + createPublisherV1Http, exportSkillsV1Http, + feedsIndexV1Http, listBundlePluginsV1Http, listCodePluginsV1Http, listPackagesV1Http, @@ -25,15 +29,16 @@ import { listSoulsV1Http, mintPublishTokenV1Http, npmMirrorGetHttp, + officialFeedV1Http, packagesDeleteRouterV1Http, packagesGetRouterV1Http, packagesPostRouterV1Http, pluginsGetRouterV1Http, - createPublisherV1Http, publishPackageV1Http, publishSkillV1Http, publishSoulV1Http, resolveSkillVersionV1Http, + reviewedFeedV1Http, searchSkillsV1Http, skillSecurityVerdictsV1Http, skillsDeleteRouterV1Http, @@ -93,6 +98,36 @@ http.route({ handler: listPackagesV1Http, }); +http.route({ + path: ApiRoutes.feeds, + method: "GET", + handler: feedsIndexV1Http, +}); + +http.route({ + path: ApiRoutes.feedsAll, + method: "GET", + handler: allFeedV1Http, +}); + +http.route({ + path: ApiRoutes.feedsOfficial, + method: "GET", + handler: officialFeedV1Http, +}); + +http.route({ + path: ApiRoutes.feedsCommunity, + method: "GET", + handler: communityFeedV1Http, +}); + +http.route({ + path: ApiRoutes.feedsReviewed, + method: "GET", + handler: reviewedFeedV1Http, +}); + http.route({ path: ApiRoutes.plugins, method: "GET", diff --git a/convex/httpApiV1.handlers.test.ts b/convex/httpApiV1.handlers.test.ts index d75acea54f..a162d0bae2 100644 --- a/convex/httpApiV1.handlers.test.ts +++ b/convex/httpApiV1.handlers.test.ts @@ -191,6 +191,287 @@ const okRate = () => ({ resetAt: Date.now() + 60_000, }); +describe("feed handlers", () => { + it("lists selectable ClawHub feeds", async () => { + const response = await __handlers.feedsIndexV1Handler( + makeCtx({}), + new Request("https://example.com/api/v1/feeds"), + ); + + expect(response.status).toBe(200); + const index = await response.json(); + expect(index.schemaVersion).toBe(1); + expect(index.feeds).toEqual([ + expect.objectContaining({ + id: "clawhub-all", + url: "https://example.com/api/v1/feeds/all", + types: ["skill", "plugin"], + }), + expect.objectContaining({ + id: "clawhub-official", + url: "https://example.com/api/v1/feeds/official", + types: ["skill", "plugin"], + }), + expect.objectContaining({ + id: "clawhub-community", + url: "https://example.com/api/v1/feeds/community", + types: ["skill", "plugin"], + }), + expect.objectContaining({ + id: "clawhub-reviewed", + url: "https://example.com/api/v1/feeds/reviewed", + types: ["skill", "plugin"], + criteria: expect.any(String), + }), + ]); + }); + + it("emits a Scout-compatible ClawHub all feed", async () => { + const runQuery = vi.fn().mockResolvedValue({ + generatedAtMs: Date.parse("2026-05-29T12:00:00Z"), + limit: 20, + truncated: false, + skills: [ + { + slug: "calendar-helper", + displayName: "Calendar Helper", + summary: "Calendar assistance", + ownerUserId: "users:platform", + ownerHandle: "platform", + latestVersionSummary: { version: "1.2.0" }, + capabilityTags: ["calendar"], + moderationStatus: "active", + isSuspicious: false, + statsDownloads: 27, + statsStars: 8, + updatedAt: Date.parse("2026-05-29T12:00:00Z"), + }, + ], + packages: [ + { + name: "@openclaw/native-plugin", + displayName: "Native Plugin", + summary: "Native plugin", + family: "code-plugin", + channel: "official", + isOfficial: true, + ownerHandle: "openclaw", + latestVersion: "2.0.0", + runtimeId: "native-plugin", + capabilityTags: ["runtime"], + executesCode: true, + verificationTier: "source-linked", + scanStatus: "clean", + updatedAt: Date.parse("2026-05-28T12:00:00Z"), + }, + ], + }); + + const response = await __handlers.allFeedV1Handler( + makeCtx({ runQuery }), + new Request("https://example.com/api/v1/feeds/all?limit=20&type=all"), + ); + + expect(response.status).toBe(200); + expect(runQuery).toHaveBeenCalledWith(expect.anything(), { + feed: "all", + limit: 20, + includeSkills: true, + includePlugins: true, + }); + + const feed = await response.json(); + expect(feed.schemaVersion).toBe(1); + expect(feed.feedId).toBe("clawhub-all"); + expect(feed.scope).toEqual({ kind: "root" }); + expect(feed.generatedAt).toBe("2026-05-29T12:00:00Z"); + expect(feed.attestation.algorithm).toBe("sha256"); + expect(feed.attestation.hash).toMatch(/^[a-f0-9]{64}$/); + expect(feed.entries).toHaveLength(2); + expect(feed.entries[0]).toMatchObject({ + id: "@openclaw/native-plugin", + type: "plugin", + version: "2.0.0", + source: { + registry: "clawhub", + package: "@openclaw/native-plugin", + url: "/plugins/@openclaw/native-plugin", + }, + }); + expect(feed.entries[1]).toMatchObject({ + id: "calendar-helper", + type: "skill", + version: "1.2.0", + source: { + registry: "clawhub", + package: "calendar-helper", + url: "/platform/calendar-helper", + }, + }); + expect(feed.entries[1].metadata["clawhub.statsDownloads"]).toBe("27"); + }); + + it("uses the shared stable canonical feed attestation hash", async () => { + const runQuery = vi.fn().mockResolvedValue({ + generatedAtMs: Date.parse("2026-05-29T12:00:00Z"), + limit: 1, + truncated: false, + skills: [], + packages: [ + { + name: "calendar", + displayName: "Calendar", + family: "code-plugin", + channel: "official", + isOfficial: true, + latestVersion: "1.0.0", + updatedAt: undefined, + }, + ], + }); + + const response = await __handlers.reviewedFeedV1Handler( + makeCtx({ runQuery }), + new Request("https://example.com/api/v1/feeds/reviewed?type=plugin&limit=1"), + ); + + const feed = await response.json(); + expect(feed).toMatchObject({ + schemaVersion: 1, + feedId: "clawhub-reviewed", + scope: { kind: "root" }, + generatedAt: "2026-05-29T12:00:00Z", + sourceRevision: "clawhub-digest:feed=clawhub-reviewed;limit=1;truncated=false", + }); + expect(feed.entries).toEqual([ + { + id: "calendar", + type: "plugin", + version: "1.0.0", + title: "Calendar", + source: { + registry: "clawhub", + package: "calendar", + url: "/plugins/calendar", + }, + updatedAt: "1970-01-01T00:00:00Z", + metadata: { + "clawhub.channel": "official", + "clawhub.family": "code-plugin", + "clawhub.isOfficial": "true", + }, + }, + ]); + expect(feed.attestation.hash).toBe( + "edb394a535f8685086649bd607dcf226f58a0765d2a2e59f9f465ff9091febd0", + ); + }); + + it("supports type filters inside feed lanes", async () => { + const runQuery = vi.fn().mockResolvedValue({ + generatedAtMs: 0, + limit: 50, + truncated: false, + skills: [], + packages: [], + }); + + await __handlers.allFeedV1Handler( + makeCtx({ runQuery }), + new Request("https://example.com/api/v1/feeds/all?type=skill"), + ); + + expect(runQuery).toHaveBeenCalledWith(expect.anything(), { + feed: "all", + limit: undefined, + includeSkills: true, + includePlugins: false, + }); + }); + + it("emits a named official feed", async () => { + const runQuery = vi.fn().mockResolvedValue({ + generatedAtMs: 0, + limit: 50, + truncated: false, + skills: [], + packages: [], + }); + + const response = await __handlers.officialFeedV1Handler( + makeCtx({ runQuery }), + new Request("https://example.com/api/v1/feeds/official"), + ); + + expect(response.status).toBe(200); + expect(runQuery).toHaveBeenCalledWith(expect.anything(), { + feed: "official", + limit: undefined, + includeSkills: true, + includePlugins: true, + }); + expect((await response.json()).feedId).toBe("clawhub-official"); + }); + + it("emits a named community feed", async () => { + const runQuery = vi.fn().mockResolvedValue({ + generatedAtMs: 0, + limit: 50, + truncated: false, + skills: [], + packages: [], + }); + + const response = await __handlers.communityFeedV1Handler( + makeCtx({ runQuery }), + new Request("https://example.com/api/v1/feeds/community"), + ); + + expect(response.status).toBe(200); + expect(runQuery).toHaveBeenCalledWith(expect.anything(), { + feed: "community", + limit: undefined, + includeSkills: true, + includePlugins: true, + }); + expect((await response.json()).feedId).toBe("clawhub-community"); + }); + + it("emits a named reviewed feed", async () => { + const runQuery = vi.fn().mockResolvedValue({ + generatedAtMs: 0, + limit: 50, + truncated: false, + skills: [], + packages: [], + }); + + const response = await __handlers.reviewedFeedV1Handler( + makeCtx({ runQuery }), + new Request("https://example.com/api/v1/feeds/reviewed?type=plugin"), + ); + + expect(response.status).toBe(200); + expect(runQuery).toHaveBeenCalledWith(expect.anything(), { + feed: "reviewed", + limit: undefined, + includeSkills: false, + includePlugins: true, + }); + expect((await response.json()).feedId).toBe("clawhub-reviewed"); + }); + + it("rejects unknown feed type filters", async () => { + const response = await __handlers.allFeedV1Handler( + makeCtx({ runQuery: vi.fn() }), + new Request("https://example.com/api/v1/feeds/all?type=soul"), + ); + + expect(response.status).toBe(400); + expect(await response.text()).toBe("invalid feed type"); + }); +}); + const blockedRate = () => ({ allowed: false, remaining: 0, diff --git a/convex/httpApiV1.ts b/convex/httpApiV1.ts index 54b60737d7..54b9be6ec5 100644 --- a/convex/httpApiV1.ts +++ b/convex/httpApiV1.ts @@ -1,5 +1,12 @@ import { httpAction } from "./functions"; import { verifyDocsSessionV1Handler } from "./httpApiV1/docsSessionV1"; +import { + allFeedV1Handler, + communityFeedV1Handler, + feedsIndexV1Handler, + officialFeedV1Handler, + reviewedFeedV1Handler, +} from "./httpApiV1/feedsV1"; import { listBundlePluginsV1Handler, listCodePluginsV1Handler, @@ -42,6 +49,11 @@ import { import { whoamiV1Handler } from "./httpApiV1/whoamiV1"; export const listPackagesV1Http = httpAction(listPackagesV1Handler); +export const feedsIndexV1Http = httpAction(feedsIndexV1Handler); +export const allFeedV1Http = httpAction(allFeedV1Handler); +export const officialFeedV1Http = httpAction(officialFeedV1Handler); +export const communityFeedV1Http = httpAction(communityFeedV1Handler); +export const reviewedFeedV1Http = httpAction(reviewedFeedV1Handler); export const listPluginsV1Http = httpAction(listPluginsV1Handler); export const packagesGetRouterV1Http = httpAction(packagesGetRouterV1Handler); export const packagesPostRouterV1Http = httpAction(packagesPostRouterV1Handler); @@ -82,6 +94,11 @@ export const banAppealContextV1Http = httpAction(banAppealContextV1Handler); export const __handlers = { listPackagesV1Handler, + feedsIndexV1Handler, + allFeedV1Handler, + officialFeedV1Handler, + communityFeedV1Handler, + reviewedFeedV1Handler, listPluginsV1Handler, packagesGetRouterV1Handler, packagesPostRouterV1Handler, diff --git a/convex/httpApiV1/feedsV1.ts b/convex/httpApiV1/feedsV1.ts new file mode 100644 index 0000000000..dc2497eb60 --- /dev/null +++ b/convex/httpApiV1/feedsV1.ts @@ -0,0 +1,429 @@ +import { api } from "../_generated/api"; +import type { ActionCtx } from "../_generated/server"; +import { json, text } from "./shared"; + +const FEED_SCHEMA_VERSION = 1; +const FEED_HASH_ALGORITHM = "sha256"; +const MAX_ROOT_FEED_LIMIT = 5_000; + +const FEED_DEFINITIONS = { + all: { + id: "clawhub-all", + path: "/api/v1/feeds/all", + title: "All ClawHub catalog entries", + description: "All public ClawHub skills and installable plugins.", + types: ["skill", "plugin"], + }, + official: { + id: "clawhub-official", + path: "/api/v1/feeds/official", + title: "Official ClawHub catalog entries", + description: "Catalog entries marked official by ClawHub/OpenClaw.", + types: ["skill", "plugin"], + }, + community: { + id: "clawhub-community", + path: "/api/v1/feeds/community", + title: "Community ClawHub catalog entries", + description: "Public non-official ClawHub catalog entries.", + types: ["skill", "plugin"], + }, + reviewed: { + id: "clawhub-reviewed", + path: "/api/v1/feeds/reviewed", + title: "Reviewed ClawHub catalog entries", + description: "Public entries that meet ClawHub's current review criteria.", + types: ["skill", "plugin"], + criteria: + "clean scans or moderation signals plus available verification metadata where applicable", + }, +} as const; + +const TYPE_FILTERS = { + all: { + includeSkills: true, + includePlugins: true, + types: ["skill", "plugin"], + }, + skill: { + includeSkills: true, + includePlugins: false, + types: ["skill"], + }, + skills: { + includeSkills: true, + includePlugins: false, + types: ["skill"], + }, + plugin: { + includeSkills: false, + includePlugins: true, + types: ["plugin"], + }, + plugins: { + includeSkills: false, + includePlugins: true, + types: ["plugin"], + }, +} as const; + +const apiRefs = api as unknown as { + feeds: { + rootFeed: unknown; + }; +}; + +type RootFeedResult = { + generatedAtMs: number; + limit: number; + truncated: boolean; + skills: SkillDigest[]; + packages: PackageDigest[]; +}; + +type SkillDigest = { + slug: string; + displayName: string; + ownerUserId: string; + summary?: string; + ownerHandle?: string; + ownerDisplayName?: string; + latestVersionSummary?: { version: string }; + capabilityTags?: string[]; + moderationStatus?: string; + isSuspicious?: boolean; + statsDownloads?: number; + statsStars?: number; + statsInstallsCurrent?: number; + statsInstallsAllTime?: number; + updatedAt: number; +}; + +type PackageDigest = { + name: string; + displayName: string; + summary?: string; + family: "skill" | "code-plugin" | "bundle-plugin"; + channel: "official" | "community" | "private"; + isOfficial: boolean; + ownerHandle?: string; + latestVersion?: string; + runtimeId?: string; + capabilityTags?: string[]; + executesCode?: boolean; + verificationTier?: string; + scanStatus?: string; + updatedAt: number; +}; + +type FeedPackageType = "skill" | "plugin" | "connector"; + +type FeedDocument = { + schemaVersion: 1; + feedId: string; + scope: { kind: "root" }; + generatedAt: string; + sourceRevision?: string; + entries: FeedPackageEntry[]; + attestation?: { + algorithm: "sha256"; + hash: string; + }; +}; + +type FeedDefinition = (typeof FEED_DEFINITIONS)[keyof typeof FEED_DEFINITIONS]; + +type FeedPackageEntry = { + id: string; + type: FeedPackageType; + version: string; + title: string; + description?: string; + publisher?: string; + source: { + registry: "clawhub"; + package: string; + url?: string; + }; + artifactSha256?: string; + updatedAt?: string; + metadata?: Record; +}; + +function parsePositiveInt(value: string | null) { + if (!value) return undefined; + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) return undefined; + return Math.min(parsed, MAX_ROOT_FEED_LIMIT); +} + +function parseTypeFilter(value: string | null) { + if (!value) return TYPE_FILTERS.all; + return value in TYPE_FILTERS ? TYPE_FILTERS[value as keyof typeof TYPE_FILTERS] : null; +} + +function isoFromMillis(value: number | undefined) { + if (!value || !Number.isFinite(value)) return "1970-01-01T00:00:00Z"; + return new Date(value).toISOString().replace(".000Z", "Z"); +} + +function maybeSet(metadata: Record, key: string, value: unknown) { + if (value === undefined || value === null) return; + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + metadata[key] = String(value); + } +} + +function sortedMetadata(metadata: Record) { + return Object.fromEntries( + Object.entries(metadata).sort(([left], [right]) => left.localeCompare(right)), + ); +} + +function skillUrl(skill: SkillDigest) { + const ownerSegment = skill.ownerHandle?.trim() || skill.ownerUserId; + return `/${encodeURIComponent(ownerSegment)}/${encodeURIComponent(skill.slug)}`; +} + +function skillEntry(skill: SkillDigest): FeedPackageEntry { + const metadata: Record = {}; + maybeSet(metadata, "clawhub.moderationStatus", skill.moderationStatus); + maybeSet(metadata, "clawhub.isSuspicious", skill.isSuspicious); + maybeSet(metadata, "clawhub.statsDownloads", skill.statsDownloads); + maybeSet(metadata, "clawhub.statsStars", skill.statsStars); + maybeSet(metadata, "clawhub.statsInstallsCurrent", skill.statsInstallsCurrent); + maybeSet(metadata, "clawhub.statsInstallsAllTime", skill.statsInstallsAllTime); + maybeSet(metadata, "clawhub.capabilityTags", skill.capabilityTags?.join(",")); + + return compactEntry({ + id: skill.slug, + type: "skill", + version: skill.latestVersionSummary?.version ?? "latest", + title: skill.displayName, + description: skill.summary, + publisher: skill.ownerHandle ?? skill.ownerDisplayName, + source: { + registry: "clawhub", + package: skill.slug, + url: skillUrl(skill), + }, + updatedAt: isoFromMillis(skill.updatedAt), + metadata: sortedMetadata(metadata), + }); +} + +function pluginUrl(pkg: PackageDigest) { + if (pkg.name.startsWith("@")) { + const slashIndex = pkg.name.indexOf("/"); + if ( + slashIndex > 1 && + slashIndex < pkg.name.length - 1 && + pkg.name.indexOf("/", slashIndex + 1) === -1 + ) { + return `/plugins/@${encodeURIComponent(pkg.name.slice(1, slashIndex))}/${encodeURIComponent( + pkg.name.slice(slashIndex + 1), + )}`; + } + } + return `/plugins/${encodeURIComponent(pkg.name)}`; +} + +function packageEntry(pkg: PackageDigest): FeedPackageEntry { + const metadata: Record = {}; + maybeSet(metadata, "clawhub.family", pkg.family); + maybeSet(metadata, "clawhub.channel", pkg.channel); + maybeSet(metadata, "clawhub.isOfficial", pkg.isOfficial); + maybeSet(metadata, "clawhub.runtimeId", pkg.runtimeId); + maybeSet(metadata, "clawhub.executesCode", pkg.executesCode); + maybeSet(metadata, "clawhub.verificationTier", pkg.verificationTier); + maybeSet(metadata, "clawhub.scanStatus", pkg.scanStatus); + maybeSet(metadata, "clawhub.capabilityTags", pkg.capabilityTags?.join(",")); + + return compactEntry({ + id: pkg.name, + type: "plugin", + version: pkg.latestVersion ?? "latest", + title: pkg.displayName, + description: pkg.summary, + publisher: pkg.ownerHandle, + source: { + registry: "clawhub", + package: pkg.name, + url: pluginUrl(pkg), + }, + updatedAt: isoFromMillis(pkg.updatedAt), + metadata: sortedMetadata(metadata), + }); +} + +function compactEntry(entry: FeedPackageEntry): FeedPackageEntry { + const metadata = + entry.metadata && Object.keys(entry.metadata).length > 0 ? entry.metadata : undefined; + return { + id: entry.id, + type: entry.type, + version: entry.version, + title: entry.title, + ...(entry.description ? { description: entry.description } : {}), + ...(entry.publisher ? { publisher: entry.publisher } : {}), + source: entry.source, + ...(entry.artifactSha256 ? { artifactSha256: entry.artifactSha256 } : {}), + ...(entry.updatedAt ? { updatedAt: entry.updatedAt } : {}), + ...(metadata ? { metadata } : {}), + }; +} + +function canonicalBody(feed: FeedDocument) { + const entries = [...feed.entries].sort((left, right) => + [ + left.type, + left.id, + left.version, + left.source.registry, + left.source.package, + left.source.url ?? "", + left.title ?? "", + left.description ?? "", + left.publisher ?? "", + left.artifactSha256 ?? "", + left.updatedAt ?? "", + JSON.stringify(left.metadata ?? {}), + ] + .join("\u0000") + .localeCompare( + [ + right.type, + right.id, + right.version, + right.source.registry, + right.source.package, + right.source.url ?? "", + right.title ?? "", + right.description ?? "", + right.publisher ?? "", + right.artifactSha256 ?? "", + right.updatedAt ?? "", + JSON.stringify(right.metadata ?? {}), + ].join("\u0000"), + ), + ); + return { + schemaVersion: feed.schemaVersion, + feedId: feed.feedId, + scope: feed.scope, + generatedAt: feed.generatedAt, + ...(feed.sourceRevision ? { sourceRevision: feed.sourceRevision } : {}), + entries, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stableCanonicalize(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(stableCanonicalize); + } + if (!isRecord(value)) { + return value; + } + return Object.fromEntries( + Object.entries(value) + .filter(([, entryValue]) => entryValue !== undefined) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entryValue]) => [key, stableCanonicalize(entryValue)]), + ); +} + +async function sha256Hex(contents: string) { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(contents)); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +export async function buildRootFeedDocument( + result: RootFeedResult, + definition: FeedDefinition = FEED_DEFINITIONS.all, +): Promise { + const entries = [...result.skills.map(skillEntry), ...result.packages.map(packageEntry)]; + const feed: FeedDocument = { + schemaVersion: FEED_SCHEMA_VERSION, + feedId: definition.id, + scope: { kind: "root" }, + generatedAt: isoFromMillis(result.generatedAtMs), + sourceRevision: `clawhub-digest:feed=${definition.id};limit=${result.limit};truncated=${result.truncated}`, + entries, + }; + const hash = await sha256Hex(JSON.stringify(stableCanonicalize(canonicalBody(feed)))); + return { + ...feed, + entries: canonicalBody(feed).entries, + attestation: { + algorithm: FEED_HASH_ALGORITHM, + hash, + }, + }; +} + +export async function feedsIndexV1Handler(_ctx: ActionCtx, request: Request) { + const origin = new URL(request.url).origin; + return json( + { + schemaVersion: 1, + feeds: Object.values(FEED_DEFINITIONS).map((feed) => ({ + id: feed.id, + title: feed.title, + description: feed.description, + url: `${origin}${feed.path}`, + types: feed.types, + schemaVersion: FEED_SCHEMA_VERSION, + ...("criteria" in feed ? { criteria: feed.criteria } : {}), + attestation: { + algorithm: FEED_HASH_ALGORITHM, + required: true, + }, + })), + }, + 200, + { + "Cache-Control": "public, max-age=300", + }, + ); +} + +async function feedV1Handler(ctx: ActionCtx, request: Request, definition: FeedDefinition) { + const url = new URL(request.url); + const typeFilter = parseTypeFilter(url.searchParams.get("type")); + if (!typeFilter) return text("invalid feed type", 400); + + const queryArgs = { + feed: definition.id.replace(/^clawhub-/, ""), + limit: parsePositiveInt(url.searchParams.get("limit")), + includeSkills: typeFilter.includeSkills, + includePlugins: typeFilter.includePlugins, + }; + const result = (await ctx.runQuery( + apiRefs.feeds.rootFeed as never, + queryArgs as never, + )) as RootFeedResult; + + return json(await buildRootFeedDocument(result, definition), 200, { + "Cache-Control": "public, max-age=300", + }); +} + +export async function allFeedV1Handler(ctx: ActionCtx, request: Request) { + return await feedV1Handler(ctx, request, FEED_DEFINITIONS.all); +} + +export async function officialFeedV1Handler(ctx: ActionCtx, request: Request) { + return await feedV1Handler(ctx, request, FEED_DEFINITIONS.official); +} + +export async function communityFeedV1Handler(ctx: ActionCtx, request: Request) { + return await feedV1Handler(ctx, request, FEED_DEFINITIONS.community); +} + +export async function reviewedFeedV1Handler(ctx: ActionCtx, request: Request) { + return await feedV1Handler(ctx, request, FEED_DEFINITIONS.reviewed); +} diff --git a/packages/schema/dist/routes.d.ts b/packages/schema/dist/routes.d.ts index 0281e62c1f..3454f6d334 100644 --- a/packages/schema/dist/routes.d.ts +++ b/packages/schema/dist/routes.d.ts @@ -18,6 +18,11 @@ export declare const ApiRoutes: { readonly skills: "/api/v1/skills"; readonly plugins: "/api/v1/plugins"; readonly packages: "/api/v1/packages"; + readonly feeds: "/api/v1/feeds"; + readonly feedsAll: "/api/v1/feeds/all"; + readonly feedsOfficial: "/api/v1/feeds/official"; + readonly feedsCommunity: "/api/v1/feeds/community"; + readonly feedsReviewed: "/api/v1/feeds/reviewed"; readonly codePlugins: "/api/v1/code-plugins"; readonly bundlePlugins: "/api/v1/bundle-plugins"; readonly stars: "/api/v1/stars"; diff --git a/packages/schema/dist/routes.js b/packages/schema/dist/routes.js index 03fe9d9f34..5349d8c613 100644 --- a/packages/schema/dist/routes.js +++ b/packages/schema/dist/routes.js @@ -18,6 +18,11 @@ export const ApiRoutes = { skills: "/api/v1/skills", plugins: "/api/v1/plugins", packages: "/api/v1/packages", + feeds: "/api/v1/feeds", + feedsAll: "/api/v1/feeds/all", + feedsOfficial: "/api/v1/feeds/official", + feedsCommunity: "/api/v1/feeds/community", + feedsReviewed: "/api/v1/feeds/reviewed", codePlugins: "/api/v1/code-plugins", bundlePlugins: "/api/v1/bundle-plugins", stars: "/api/v1/stars", diff --git a/packages/schema/dist/routes.js.map b/packages/schema/dist/routes.js.map index 04a49622d6..7c12dc7511 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,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,CAAC;AAEX,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,gBAAgB,EAAE,4BAA4B;IAC9C,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,WAAW,EAAE,sBAAsB;IACnC,aAAa,EAAE,wBAAwB;IACvC,KAAK,EAAE,eAAe;IACtB,SAAS,EAAE,mBAAmB;IAC9B,UAAU,EAAE,oBAAoB;IAChC,KAAK,EAAE,eAAe;IACtB,KAAK,EAAE,eAAe;IACtB,MAAM,EAAE,gBAAgB;IACxB,YAAY,EAAE,uBAAuB;CAC7B,CAAC"} \ 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,CAAC;AAEX,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,gBAAgB,EAAE,4BAA4B;IAC9C,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,KAAK,EAAE,eAAe;IACtB,QAAQ,EAAE,mBAAmB;IAC7B,aAAa,EAAE,wBAAwB;IACvC,cAAc,EAAE,yBAAyB;IACzC,cAAc,EAAE,yBAAyB;IACzC,WAAW,EAAE,sBAAsB;IACnC,aAAa,EAAE,wBAAwB;IACvC,KAAK,EAAE,eAAe;IACtB,SAAS,EAAE,mBAAmB;IAC9B,KAAK,EAAE,eAAe;IACtB,KAAK,EAAE,eAAe;IACtB,MAAM,EAAE,gBAAgB;CAChB,CAAC"} \ No newline at end of file diff --git a/packages/schema/src/routes.ts b/packages/schema/src/routes.ts index a263a6622e..5af4bbbb63 100644 --- a/packages/schema/src/routes.ts +++ b/packages/schema/src/routes.ts @@ -19,6 +19,11 @@ export const ApiRoutes = { skills: "/api/v1/skills", plugins: "/api/v1/plugins", packages: "/api/v1/packages", + feeds: "/api/v1/feeds", + feedsAll: "/api/v1/feeds/all", + feedsOfficial: "/api/v1/feeds/official", + feedsCommunity: "/api/v1/feeds/community", + feedsReviewed: "/api/v1/feeds/reviewed", codePlugins: "/api/v1/code-plugins", bundlePlugins: "/api/v1/bundle-plugins", stars: "/api/v1/stars", From 02d11208469fcf79b81e3f6bcd478495ea119382 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Sun, 31 May 2026 13:18:30 -0700 Subject: [PATCH 2/2] docs(api): document root feeds --- docs/http-api.md | 125 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/docs/http-api.md b/docs/http-api.md index dabd8b3b68..02f346104d 100644 --- a/docs/http-api.md +++ b/docs/http-api.md @@ -136,6 +136,131 @@ Publisher discoverability guidance: - Rename aliases preserve resolution for old URLs and installs that resolve through the registry, but search ranking is based on the canonical skill metadata after the rename has indexed. Existing stats stay with the skill. - If a skill is unexpectedly invisible, check moderation state first with `clawhub inspect ` while logged in before changing ranking-related metadata. +### `GET /api/v1/feeds` + +Lists the public ClawHub feed documents that clients can subscribe to. + +Response: + +```json +{ + "schemaVersion": 1, + "feeds": [ + { + "id": "clawhub-reviewed", + "title": "Reviewed ClawHub catalog entries", + "description": "Public entries that meet ClawHub's current review criteria.", + "url": "https://clawhub.ai/api/v1/feeds/reviewed", + "types": ["skill", "plugin"], + "schemaVersion": 1, + "criteria": "clean scans or moderation signals plus available verification metadata where applicable", + "attestation": { + "algorithm": "sha256", + "required": true + } + } + ] +} +``` + +Current root feeds: + +- `/api/v1/feeds/all`: all public ClawHub skills and installable plugins. +- `/api/v1/feeds/official`: entries marked official by ClawHub/OpenClaw. +- `/api/v1/feeds/community`: public non-official entries. +- `/api/v1/feeds/reviewed`: public entries that meet ClawHub's current review criteria. + +### `GET /api/v1/feeds/{all|official|community|reviewed}` + +Returns a feed document that OpenClaw clients and enterprise catalog publishers +can consume without using ClawHub-specific search APIs. + +Query params: + +- `type` (optional): `all`, `skill`, `skills`, `plugin`, or `plugins`. + Defaults to `all`. +- `limit` (optional): positive integer. Defaults to 500 and is capped at 5000. + +Response: + +```json +{ + "schemaVersion": 1, + "feedId": "clawhub-reviewed", + "scope": { + "kind": "root" + }, + "generatedAt": "2026-05-31T00:00:00Z", + "sourceRevision": "clawhub-digest:feed=clawhub-reviewed;limit=500;truncated=false", + "entries": [ + { + "id": "@openclaw/calendar-helper", + "type": "plugin", + "version": "1.2.0", + "title": "Calendar Helper", + "description": "Calendar workflow helpers.", + "publisher": "openclaw", + "source": { + "registry": "clawhub", + "package": "@openclaw/calendar-helper", + "url": "/plugins/@openclaw/calendar-helper" + }, + "updatedAt": "2026-05-31T00:00:00Z", + "metadata": { + "clawhub.channel": "official", + "clawhub.family": "code-plugin", + "clawhub.isOfficial": "true" + } + } + ], + "attestation": { + "algorithm": "sha256", + "hash": "..." + } +} +``` + +Notes: + +- Feed entries use `type: "skill"` for skill cards and `type: "plugin"` for + installable code-plugin or bundle-plugin packages. +- `attestation.hash` is computed over the canonical feed body before the + attestation field is attached. Clients can use it for drift detection and + OpenClaw feed source pinning. +- `source.url` is a canonical ClawHub relative URL suitable for display or + linking back to the catalog entry. +- Invalid `type` values return `400`. Unknown query parameters are ignored. +- Responses are cacheable for five minutes. + +OpenClaw clients can subscribe to a ClawHub root feed by configuring the bundled +Feeds plugin: + +```jsonc +{ + "plugins": { + "entries": { + "feeds": { + "enabled": true, + "config": { + "sources": [ + { + "id": "clawhub-reviewed", + "url": "https://clawhub.ai/api/v1/feeds/reviewed?limit=5000", + "trust": "pinned", + "integrity": "sha256:...", + }, + ], + "search": { + "default": true, + "sources": ["clawhub-reviewed"], + }, + }, + }, + }, + }, +} +``` + ### `GET /api/v1/skills` Query params: