Compare commits

..
Author SHA1 Message Date
momothemage ef8d53fa5f fix: drop unrelated conflict carryover from PR #1879 2026-05-07 10:42:51 +08:00
momothemage f6661a7a4c fix(publish): allow grandfathered slugs to update in insertVersion mutations 2026-04-29 12:13:40 +08:00
momothemage d2c2e7b509 style: re-apply oxfmt to packages.public.test.ts after upstream merge 2026-04-29 11:57:34 +08:00
momothemage 70f6a435e2 Merge remote-tracking branch 'upstream/main' into feature/fix_slug_limit
# Conflicts:
#	src/__tests__/header.test.tsx
2026-04-29 11:56:28 +08:00
momothemage 929c9de50e style: format files touched in CI format-check scope
CI computes the PR format-check scope using two-dot diff between the
pull request base SHA (captured at PR open time) and the head SHA, so
any file changed in upstream/main after the PR was opened but before
the PR was merged falls into scope and gets oxfmt --check'd. The three
src/ files below were introduced by upstream PR #1873 (skill upload)
without oxfmt formatting, so they fail CI format:check on this branch
even though they are not logically part of the slug validation fix.

Run oxfmt --write on them to satisfy CI. No behaviour change.

Affected files:
- src/__tests__/header.test.tsx
- src/components/Header.tsx
- src/routes/settings.tsx
2026-04-29 11:53:57 +08:00
momothemage ea67295794 fix(slug): keep read & update paths working for grandfathered slugs 2026-04-29 11:44:39 +08:00
momothemage f5a3cba962 fix(search): restore exact-slug lookup for legacy short slugs 2026-04-29 11:30:15 +08:00
momothemage b2a173ab75 style: apply oxfmt formatting to slug validation PR files 2026-04-29 11:19:21 +08:00
momothemage 2e74c9bad2 fix(slug): tighten skill/soul slug validation with length limits and reserved-word blocklist 2026-04-29 10:56:19 +08:00
23 changed files with 866 additions and 1211 deletions
+1 -4
View File
@@ -73,9 +73,6 @@ on:
description: Published release id when dry_run is false.
value: ${{ jobs.publish.outputs.release_id }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
publish:
runs-on: ubuntu-latest
@@ -337,7 +334,7 @@ jobs:
PY
- name: Upload publish JSON artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: clawhub-package-publish-json
path: ${{ runner.temp }}/package-publish.json
+5 -9
View File
@@ -15,7 +15,6 @@
- `bun run preview` — preview built app.
- `bunx convex dev` — Convex dev deployment + function watcher.
- `bunx convex codegen` — regenerate `convex/_generated`.
- `bun run format:check` — formatting check.
- `bun run lint` — Biome + oxlint (type-aware).
- `bun run test` — Vitest (unit tests).
- `bun run coverage` — coverage run; keep global >= 80%.
@@ -38,7 +37,6 @@
- Commit messages: Conventional Commits (`feat:`, `fix:`, `chore:`, `docs:`…).
- Keep changes scoped; avoid repo-wide search/replace.
- Before commit/PR handoff, run `bun run format:check` and `bun run lint`; include commands run in the PR summary.
- PRs: include summary + test commands run. Add screenshots for UI changes.
- Before merging any PR, verify TypeScript cleanly with `bunx tsc -p packages/schema/tsconfig.json --noEmit` and `bunx tsc -p packages/clawhub/tsconfig.json --noEmit`; if Convex code changed, also run the repo typecheck path used by deploy so `bunx convex deploy` will not fail on `tsc`.
- GitHub comments: for multiline `gh` comments/close messages, use `--body-file`, `--input`, or stdin/heredoc with real newlines; never pass literal `\\n` in shell strings.
@@ -92,13 +90,11 @@
- **Before writing or reviewing Convex queries, check deployment health.** Run `bunx convex insights` to check for OCC conflicts, `bytesReadLimit`, and `documentsReadLimit` errors. Run `bunx convex logs --failure` to see individual error messages and stack traces. This helps identify which functions are causing bandwidth issues so you can prioritize fixes.
<!-- convex-ai-start -->
This project uses [Convex](https://convex.dev) as its backend.
When working on Convex code, **always read `convex/_generated/ai/guidelines.md` first** for important guidelines on how to correctly use Convex APIs and patterns. The file contains rules that override what you may have learned about Convex from training data.
Convex agent skills for common tasks can be installed by running `npx convex ai-files install`.
<!-- convex-ai-end -->
## Stat Field Migration Rules
@@ -106,11 +102,11 @@ Convex agent skills for common tasks can be installed by running `npx convex ai-
The `skills` table maintains two parallel sets of stat fields as part of an in-progress field migration:
| Legacy (nested, `@deprecated`) | Top-level (source of truth, indexable) |
| ------------------------------ | -------------------------------------- |
| `stats.downloads` | `statsDownloads` |
| `stats.stars` | `statsStars` |
| `stats.installsCurrent` | `statsInstallsCurrent` |
| `stats.installsAllTime` | `statsInstallsAllTime` |
|---|---|
| `stats.downloads` | `statsDownloads` |
| `stats.stars` | `statsStars` |
| `stats.installsCurrent` | `statsInstallsCurrent` |
| `stats.installsAllTime` | `statsInstallsAllTime` |
**Rules:**
+15 -393
View File
@@ -20,19 +20,6 @@ type SeedSkillSpec = {
rawSkillMd: string;
};
type SeedPluginSpec = {
name: string;
displayName: string;
summary: string;
version: string;
runtimeId: string;
sourceRepo: string;
isOfficial: boolean;
capabilityTags: string[];
stats: { downloads: number; installs: number; stars: number; versions: number };
readme: string;
};
type SeedActionArgs = {
reset?: boolean;
};
@@ -69,134 +56,6 @@ This seeded plugin is public and intentionally has completed scan results so loc
preview plugin scanner detail pages without owner-only visibility.
`;
const FEATURED_PLUGIN_SEEDS: SeedPluginSpec[] = [
{
name: "@apify/apify-openclaw-plugin",
displayName: "Apify",
summary:
"Scrape websites through Apify actors and make structured web data available to agents.",
version: "1.0.0",
runtimeId: "apify",
sourceRepo: "apify/apify-openclaw-plugin",
isOfficial: false,
capabilityTags: ["web", "scraping", "automation"],
stats: { downloads: 1200, installs: 320, stars: 45, versions: 1 },
readme: "# Apify\n\nScrape websites through Apify actors from OpenClaw.",
},
{
name: "openclaw-codex-app-server",
displayName: "Codex App Server Bridge",
summary: "Bind OpenClaw chats to Codex App Server conversations and control threads from chat.",
version: "1.0.0",
runtimeId: "codex-app-server",
sourceRepo: "pwrdrvr/openclaw-codex-app-server",
isOfficial: false,
capabilityTags: ["codex", "chat", "bridge"],
stats: { downloads: 980, installs: 280, stars: 37, versions: 1 },
readme: "# Codex App Server Bridge\n\nBridge OpenClaw chat sessions to Codex App Server.",
},
{
name: "@largezhou/ddingtalk",
displayName: "DingTalk",
summary: "Connect OpenClaw to DingTalk enterprise robots with text, image, and file messages.",
version: "1.0.0",
runtimeId: "dingtalk",
sourceRepo: "largezhou/openclaw-dingtalk",
isOfficial: false,
capabilityTags: ["channel", "dingtalk", "enterprise"],
stats: { downloads: 930, installs: 250, stars: 32, versions: 1 },
readme: "# DingTalk\n\nDingTalk enterprise robot plugin for OpenClaw.",
},
{
name: "kudosity-openclaw-sms",
displayName: "Kudosity SMS",
summary: "Send and receive SMS through Kudosity as an OpenClaw plugin.",
version: "1.0.0",
runtimeId: "kudosity-sms",
sourceRepo: "kudosity/openclaw-sms",
isOfficial: false,
capabilityTags: ["channel", "sms", "kudosity"],
stats: { downloads: 860, installs: 210, stars: 29, versions: 1 },
readme: "# Kudosity SMS\n\nKudosity SMS channel plugin for OpenClaw.",
},
{
name: "@martian-engineering/lossless-claw",
displayName: "Lossless Claw",
summary:
"Preserve conversation context with DAG-based summarization and incremental compaction.",
version: "1.0.0",
runtimeId: "lossless-claw",
sourceRepo: "Martian-Engineering/lossless-claw",
isOfficial: false,
capabilityTags: ["memory", "context", "summarization"],
stats: { downloads: 820, installs: 190, stars: 28, versions: 1 },
readme: "# Lossless Claw\n\nLossless context management plugin for OpenClaw.",
},
{
name: "@opik/opik-openclaw",
displayName: "Opik",
summary: "Export OpenClaw traces to Opik for monitoring, costs, token usage, and debugging.",
version: "1.0.0",
runtimeId: "opik",
sourceRepo: "comet-ml/opik-openclaw",
isOfficial: true,
capabilityTags: ["observability", "tracing", "monitoring"],
stats: { downloads: 760, installs: 180, stars: 25, versions: 1 },
readme: "# Opik\n\nTrace OpenClaw agents with Opik.",
},
{
name: "@prometheusavatar/openclaw-plugin",
displayName: "Prometheus Avatar",
summary: "Give OpenClaw agents a Live2D avatar with lip-sync, expressions, and speech.",
version: "1.0.0",
runtimeId: "prometheus-avatar",
sourceRepo: "myths-labs/prometheus-avatar",
isOfficial: false,
capabilityTags: ["avatar", "tts", "live2d"],
stats: { downloads: 690, installs: 150, stars: 22, versions: 1 },
readme: "# Prometheus Avatar\n\nLive2D avatar plugin for OpenClaw.",
},
{
name: "@tencent-connect/openclaw-qqbot",
displayName: "QQbot",
summary:
"Connect OpenClaw to QQ private chats, group mentions, channel messages, and rich media.",
version: "1.0.0",
runtimeId: "qqbot",
sourceRepo: "tencent-connect/openclaw-qqbot",
isOfficial: true,
capabilityTags: ["channel", "qq", "messaging"],
stats: { downloads: 640, installs: 140, stars: 20, versions: 1 },
readme: "# QQbot\n\nQQ Bot plugin for OpenClaw.",
},
{
name: "@wecom/wecom-openclaw-plugin",
displayName: "wecom",
summary:
"Use WeCom Bot WebSocket connections for direct messages, group chats, and proactive messaging.",
version: "1.0.0",
runtimeId: "wecom",
sourceRepo: "WecomTeam/wecom-openclaw-plugin",
isOfficial: true,
capabilityTags: ["channel", "wecom", "enterprise"],
stats: { downloads: 610, installs: 130, stars: 18, versions: 1 },
readme: "# wecom\n\nWeCom channel plugin for OpenClaw.",
},
{
name: "openclaw-plugin-yuanbao",
displayName: "Yuanbao",
summary:
"Connect OpenClaw to Yuanbao with direct messages, group chats, media, and slash commands.",
version: "1.0.0",
runtimeId: "yuanbao",
sourceRepo: "yb-claw/openclaw-plugin-yuanbao",
isOfficial: false,
capabilityTags: ["channel", "yuanbao", "messaging"],
stats: { downloads: 580, installs: 125, stars: 17, versions: 1 },
readme: "# Yuanbao\n\nYuanbao channel plugin for OpenClaw.",
},
];
type RoleHelpFixtureUser = {
handle: string;
displayName: string;
@@ -523,13 +382,12 @@ async function seedNixSkillsHandler(
results.push({ slug: spec.slug, ...result });
}
const [flaggedSkillStorageId, flaggedPluginStorageId, scannedPluginStorageId] = await Promise.all(
[
ctx.storage.store(new Blob([FLAGGED_SKILL_MD], { type: "text/markdown" })),
ctx.storage.store(new Blob([FLAGGED_PLUGIN_README], { type: "text/markdown" })),
ctx.storage.store(new Blob([SCANNED_PLUGIN_README], { type: "text/markdown" })),
],
);
const [flaggedSkillStorageId, flaggedPluginStorageId, scannedPluginStorageId] =
await Promise.all([
ctx.storage.store(new Blob([FLAGGED_SKILL_MD], { type: "text/markdown" })),
ctx.storage.store(new Blob([FLAGGED_PLUGIN_README], { type: "text/markdown" })),
ctx.storage.store(new Blob([SCANNED_PLUGIN_README], { type: "text/markdown" })),
]);
const fixtureResult: SeedMutationResult = await ctx.runMutation(
internal.devSeed.seedRescanUxFixturesMutation,
{
@@ -544,32 +402,6 @@ async function seedNixSkillsHandler(
);
results.push({ slug: FLAGGED_SKILL_SLUG, ...fixtureResult });
const featuredPluginStorageIds = await Promise.all(
FEATURED_PLUGIN_SEEDS.map(async (spec) =>
ctx.storage.store(new Blob([spec.readme], { type: "text/markdown" })),
),
);
const featuredResult: SeedMutationResult = await ctx.runMutation(
internal.devSeed.seedFeaturedPluginPackagesMutation,
{
reset: args.reset,
packages: FEATURED_PLUGIN_SEEDS.map((spec, index) => ({
name: spec.name,
displayName: spec.displayName,
summary: spec.summary,
version: spec.version,
runtimeId: spec.runtimeId,
sourceRepo: spec.sourceRepo,
isOfficial: spec.isOfficial,
capabilityTags: spec.capabilityTags,
stats: spec.stats,
storageId: featuredPluginStorageIds[index],
readmeSize: spec.readme.length,
})),
},
);
results.push({ slug: "featured-plugins", ...featuredResult });
return { ok: true, results };
}
@@ -730,59 +562,6 @@ async function findScannedPluginFixture(ctx: MutationCtx) {
return await findSeedPluginFixtureByName(ctx, SCANNED_PLUGIN_NAME);
}
async function ensureHighlightedSkillBadge(
ctx: MutationCtx,
skillId: Id<"skills">,
userId: Id<"users">,
at: number,
) {
const existing = await ctx.db
.query("skillBadges")
.withIndex("by_skill_kind", (q) => q.eq("skillId", skillId).eq("kind", "highlighted"))
.unique();
if (existing) {
await ctx.db.patch(existing._id, { byUserId: userId, at });
} else {
await ctx.db.insert("skillBadges", {
skillId,
kind: "highlighted",
byUserId: userId,
at,
});
}
const skill = await ctx.db.get(skillId);
if (skill) {
await ctx.db.patch(skillId, {
badges: {
...(skill.badges as Record<string, unknown> | undefined),
highlighted: { byUserId: userId, at },
},
});
}
}
async function ensureHighlightedPackageBadge(
ctx: MutationCtx,
packageId: Id<"packages">,
userId: Id<"users">,
at: number,
) {
const existing = await ctx.db
.query("packageBadges")
.withIndex("by_package_kind", (q) => q.eq("packageId", packageId).eq("kind", "highlighted"))
.unique();
if (existing) {
await ctx.db.patch(existing._id, { byUserId: userId, at });
} else {
await ctx.db.insert("packageBadges", {
packageId,
kind: "highlighted",
byUserId: userId,
at,
});
}
}
function staticMaliciousScan(now: number) {
return {
status: "malicious" as const,
@@ -1304,166 +1083,6 @@ export const seedRescanUxFixturesMutation = internalMutation({
handler: seedRescanUxFixturesHandler,
});
export const seedFeaturedPluginPackagesMutation = internalMutation({
args: {
reset: v.optional(v.boolean()),
packages: v.array(
v.object({
name: v.string(),
displayName: v.string(),
summary: v.string(),
version: v.string(),
runtimeId: v.string(),
sourceRepo: v.string(),
isOfficial: v.boolean(),
capabilityTags: v.array(v.string()),
stats: v.object({
downloads: v.number(),
installs: v.number(),
stars: v.number(),
versions: v.number(),
}),
storageId: v.id("_storage"),
readmeSize: v.number(),
}),
),
},
handler: async (ctx, args) => {
const now = Date.now();
const { userId, publisherId } = await ensureLocalSeedOwner(ctx);
const seeded: string[] = [];
const skipped: string[] = [];
for (const spec of args.packages) {
const existing = await findSeedPluginFixtureByName(ctx, spec.name);
if (existing && !args.reset) {
await ensureHighlightedPackageBadge(ctx, existing._id, userId, now);
skipped.push(spec.name);
continue;
}
if (existing && args.reset) {
await deleteSeedPluginFixtureByName(ctx, spec.name);
}
const compatibility = { pluginApiRange: ">=0.1.0" };
const capabilities = {
executesCode: true,
runtimeId: spec.runtimeId,
pluginKind: "runtime" as const,
capabilityTags: spec.capabilityTags,
};
const verification = {
tier: "source-linked" as const,
scope: "artifact-only" as const,
summary: "Local dev featured plugin fixture linked to source metadata.",
sourceRepo: spec.sourceRepo,
scanStatus: "clean" as const,
};
const normalizedName = normalizePackageName(spec.name);
const packageId = await ctx.db.insert("packages", {
name: spec.name,
normalizedName,
displayName: spec.displayName,
summary: spec.summary,
ownerUserId: userId,
ownerPublisherId: publisherId,
family: "code-plugin",
channel: "community",
isOfficial: spec.isOfficial,
runtimeId: spec.runtimeId,
sourceRepo: spec.sourceRepo,
latestReleaseId: undefined,
latestVersionSummary: undefined,
tags: {},
capabilityTags: spec.capabilityTags,
executesCode: true,
compatibility,
capabilities,
verification,
scanStatus: "clean",
stats: { ...spec.stats, versions: 0 },
softDeletedAt: undefined,
createdAt: now,
updatedAt: now,
});
const releaseId = await ctx.db.insert("packageReleases", {
packageId,
version: spec.version,
changelog: "Seeded local featured plugin release.",
summary: spec.summary,
distTags: ["latest"],
files: [
{
path: "README.md",
size: spec.readmeSize,
storageId: spec.storageId,
sha256: `seeded-featured-plugin-${normalizedName}`,
contentType: "text/markdown",
},
],
integritySha256: `seeded-featured-plugin-integrity-${normalizedName}`,
extractedPackageJson: {
name: spec.name,
version: spec.version,
description: spec.summary,
},
compatibility,
capabilities,
verification,
sha256hash: `seeded-featured-plugin-hash-${normalizedName}`,
vtAnalysis: {
status: "clean",
verdict: "clean",
analysis: "Local featured plugin fixture scanned clean.",
source: "local-dev-seed",
checkedAt: now,
},
llmAnalysis: {
status: "clean",
verdict: "clean",
confidence: "high",
summary: "Local featured plugin fixture is safe sample content.",
model: "local-dev-seed",
checkedAt: now,
},
staticScan: {
status: "clean",
reasonCodes: [],
findings: [],
summary: "Local featured plugin fixture static scan clean.",
engineVersion: "local-dev-fixture",
checkedAt: now,
},
source: { kind: "github", repo: spec.sourceRepo, path: "." },
createdBy: userId,
publishActor: { kind: "user", userId },
createdAt: now,
softDeletedAt: undefined,
});
await ctx.db.patch(packageId, {
latestReleaseId: releaseId,
latestVersionSummary: {
version: spec.version,
createdAt: now,
changelog: "Seeded local featured plugin release.",
compatibility,
capabilities,
verification,
},
tags: { latest: releaseId },
stats: { ...spec.stats, versions: 1 },
updatedAt: now,
});
await ensureHighlightedPackageBadge(ctx, packageId, userId, now);
seeded.push(spec.name);
}
return { ok: true, seeded, skipped };
},
});
export const seedCliRoleHelpFixtures = rawInternalMutation({
args: {},
handler: async (ctx) => {
@@ -1516,7 +1135,11 @@ async function upsertRoleHelpFixtureUser(ctx: MutationCtx, user: RoleHelpFixture
return created;
}
async function replaceRoleHelpFixtureToken(ctx: MutationCtx, userId: Id<"users">, now: number) {
async function replaceRoleHelpFixtureToken(
ctx: MutationCtx,
userId: Id<"users">,
now: number,
) {
const existingTokens = await ctx.db
.query("apiTokens")
.withIndex("by_user", (q) => q.eq("userId", userId))
@@ -1554,15 +1177,12 @@ export const seedSkillMutation = internalMutation({
version: v.string(),
},
handler: async (ctx, args) => {
const now = Date.now();
const { userId, publisherId } = await ensureLocalSeedOwner(ctx);
const existing = await ctx.db
.query("skills")
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
.unique();
if (existing && !args.reset) {
await ensureHighlightedSkillBadge(ctx, existing._id, userId, now);
return { ok: true, skipped: true, skillId: existing._id };
}
@@ -1584,6 +1204,9 @@ export const seedSkillMutation = internalMutation({
await ctx.db.delete(existing._id);
}
const now = Date.now();
const { userId, publisherId } = await ensureLocalSeedOwner(ctx);
const skillId = await ctx.db.insert("skills", {
slug: args.slug,
displayName: args.displayName,
@@ -1593,7 +1216,7 @@ export const seedSkillMutation = internalMutation({
latestVersionId: undefined,
tags: {},
softDeletedAt: undefined,
badges: { highlighted: { byUserId: userId, at: now }, redactionApproved: undefined },
badges: { redactionApproved: undefined },
statsDownloads: 0,
statsStars: 0,
statsInstallsCurrent: 0,
@@ -1609,7 +1232,6 @@ export const seedSkillMutation = internalMutation({
createdAt: now,
updatedAt: now,
});
await ensureHighlightedSkillBadge(ctx, skillId, userId, now);
const versionId = await ctx.db.insert("skillVersions", {
skillId,
version: args.version,
+1 -24
View File
@@ -7,8 +7,8 @@ import {
import { api, internal } from "../_generated/api";
import type { Doc, Id } from "../_generated/dataModel";
import type { ActionCtx } from "../_generated/server";
import { getOptionalActiveAuthUserIdFromAction } from "../lib/access";
import { getOptionalApiTokenUserId } from "../lib/apiTokenAuth";
import { getOptionalActiveAuthUserIdFromAction } from "../lib/access";
import {
fetchGitHubRepositoryIdentity,
verifyGitHubActionsTrustedPublishJwt,
@@ -104,7 +104,6 @@ type PackageListQueryArgs = {
family?: "skill" | "code-plugin" | "bundle-plugin";
channel?: "official" | "community" | "private";
isOfficial?: boolean;
highlightedOnly?: boolean;
executesCode?: boolean;
capabilityTag?: string;
viewerUserId?: Id<"users">;
@@ -459,7 +458,6 @@ async function searchPackageCatalogByListing(
family?: "skill" | "code-plugin" | "bundle-plugin";
channel?: "official" | "community" | "private";
isOfficial?: boolean;
highlightedOnly?: boolean;
executesCode?: boolean;
capabilityTag?: string;
viewerUserId?: Id<"users">;
@@ -484,7 +482,6 @@ async function searchPackageCatalogByListing(
family: args.family,
channel: args.channel,
isOfficial: args.isOfficial,
highlightedOnly: args.highlightedOnly,
executesCode: args.executesCode,
capabilityTag: args.capabilityTag,
viewerUserId: args.viewerUserId,
@@ -653,11 +650,6 @@ async function listPackages(
const channelRaw = url.searchParams.get("channel")?.trim();
const capabilityTag = url.searchParams.get("capabilityTag")?.trim() || undefined;
const isOfficialRaw = url.searchParams.get("isOfficial");
const highlightedOnly =
url.searchParams.get("featured") === "true" ||
url.searchParams.get("featured") === "1" ||
url.searchParams.get("highlightedOnly") === "true" ||
url.searchParams.get("highlightedOnly") === "1";
const executesCodeRaw = url.searchParams.get("executesCode");
const effectiveFamily =
family ??
@@ -682,7 +674,6 @@ async function listPackages(
}>(ctx, apiRefs.skills.listPackageCatalogPage, {
channel,
isOfficial,
highlightedOnly: highlightedOnly || undefined,
executesCode,
capabilityTag,
paginationOpts: { cursor, numItems: limit },
@@ -710,7 +701,6 @@ async function listPackages(
}>(ctx, internalRefs.packages.listPageForViewerInternal, {
channel,
isOfficial,
highlightedOnly: highlightedOnly || undefined,
executesCode,
capabilityTag,
viewerUserId: viewerUserId ?? undefined,
@@ -730,7 +720,6 @@ async function listPackages(
}>(ctx, apiRefs.skills.listPackageCatalogPage, {
channel,
isOfficial,
highlightedOnly: highlightedOnly || undefined,
executesCode,
capabilityTag,
paginationOpts: { cursor: pageCursor, numItems },
@@ -794,7 +783,6 @@ async function listPackages(
family: pluginFamily,
channel,
isOfficial,
highlightedOnly: highlightedOnly || undefined,
executesCode,
capabilityTag,
viewerUserId: viewerUserId ?? undefined,
@@ -862,7 +850,6 @@ async function listPackages(
family: effectiveFamily,
channel,
isOfficial,
highlightedOnly: highlightedOnly || undefined,
executesCode,
capabilityTag,
viewerUserId: viewerUserId ?? undefined,
@@ -1292,11 +1279,6 @@ async function searchPackages(
const familyRaw = url.searchParams.get("family");
const channelRaw = url.searchParams.get("channel");
const isOfficialRaw = url.searchParams.get("isOfficial");
const highlightedOnly =
url.searchParams.get("featured") === "true" ||
url.searchParams.get("featured") === "1" ||
url.searchParams.get("highlightedOnly") === "true" ||
url.searchParams.get("highlightedOnly") === "1";
const executesCodeRaw = url.searchParams.get("executesCode");
const capabilityTag = url.searchParams.get("capabilityTag")?.trim() || undefined;
const family =
@@ -1323,7 +1305,6 @@ async function searchPackages(
limit,
channel,
isOfficial,
highlightedOnly: highlightedOnly || undefined,
executesCode,
capabilityTag,
},
@@ -1338,7 +1319,6 @@ async function searchPackages(
family: pluginFamily,
channel,
isOfficial,
highlightedOnly: highlightedOnly || undefined,
executesCode,
capabilityTag,
viewerUserId: viewerUserId ?? undefined,
@@ -1363,7 +1343,6 @@ async function searchPackages(
family,
channel,
isOfficial,
highlightedOnly: highlightedOnly || undefined,
executesCode,
capabilityTag,
viewerUserId: viewerUserId ?? undefined,
@@ -1376,7 +1355,6 @@ async function searchPackages(
limit,
channel,
isOfficial,
highlightedOnly: highlightedOnly || undefined,
executesCode,
capabilityTag,
viewerUserId: viewerUserId ?? undefined,
@@ -1386,7 +1364,6 @@ async function searchPackages(
limit,
channel,
isOfficial,
highlightedOnly: highlightedOnly || undefined,
executesCode,
capabilityTag,
}),
+21 -7
View File
@@ -1,5 +1,5 @@
import { ConvexError } from "convex/values";
import { normalizeTextContentType } from "clawhub-schema";
import { ConvexError } from "convex/values";
import semver from "semver";
import { api, internal } from "../_generated/api";
import type { Doc, Id } from "../_generated/dataModel";
@@ -34,6 +34,7 @@ import {
parseFrontmatter,
sanitizePath,
} from "./skills";
import { assertValidSkillSlug, normalizeSkillSlug } from "./skillSlugValidator";
import { generateSkillSummary } from "./skillSummary";
import { runStaticPublishScan } from "./staticPublishScan";
import type { WebhookSkillPayload } from "./webhooks";
@@ -90,12 +91,16 @@ export async function publishVersionForUser(
options: PublishOptions = {},
): Promise<PublishResult> {
const version = args.version.trim();
const slug = args.slug.trim().toLowerCase();
// Normalize first so we can look up the existing skill before deciding
// how strictly to validate. The reserved-word blocklist and length floor
// are only enforced for brand-new skills; owners of grandfathered slugs
// (reserved, <3 chars, or >48 chars) must still be able to publish new
// versions without being blocked by the write-path validator.
const normalizedSlug = normalizeSkillSlug(args.slug);
if (!normalizedSlug) throw new ConvexError("Slug is required.");
const displayName = args.displayName.trim();
if (!slug || !displayName) throw new ConvexError("Slug and display name required");
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) {
throw new ConvexError("Slug must be lowercase and url-safe");
}
if (!displayName) throw new ConvexError("Display name required");
if (!semver.valid(version)) {
throw new ConvexError("Version must be valid semver");
}
@@ -104,10 +109,19 @@ export async function publishVersionForUser(
await requireGitHubAccountAge(ctx, userId);
}
const existingSkill = (await ctx.runQuery(internal.skills.getSkillBySlugInternal, {
slug,
slug: normalizedSlug,
})) as Doc<"skills"> | null;
const isNewSkill = !existingSkill;
// For new skills, enforce the full write-path rules (length, pattern,
// reserved-word blocklist). For existing skills the slug is already
// persisted and grandfathered — re-validating it would block legitimate
// version publishes on legacy rows.
if (isNewSkill) {
assertValidSkillSlug(normalizedSlug);
}
const slug = normalizedSlug;
const suppliedChangelog = args.changelog.trim();
const changelogSource = suppliedChangelog ? ("user" as const) : ("auto" as const);
+186
View File
@@ -0,0 +1,186 @@
import { describe, expect, it } from "vitest";
import {
assertValidSkillSlug,
isReservedSkillSlug,
isSearchableSkillSlugShape,
isValidSkillSlugShape,
normalizeSkillSlug,
normalizeSkillSlugOrNull,
SKILL_SLUG_CONSTRAINTS,
} from "./skillSlugValidator";
describe("normalizeSkillSlug", () => {
it("trims and lowercases", () => {
expect(normalizeSkillSlug(" Hello-World ")).toBe("hello-world");
});
it("returns empty string for nullish", () => {
expect(normalizeSkillSlug(undefined)).toBe("");
expect(normalizeSkillSlug(null)).toBe("");
});
});
describe("normalizeSkillSlugOrNull", () => {
it("returns null for empty input", () => {
expect(normalizeSkillSlugOrNull(" ")).toBeNull();
expect(normalizeSkillSlugOrNull(null)).toBeNull();
});
it("returns normalized slug for non-empty input", () => {
expect(normalizeSkillSlugOrNull(" MySkill ")).toBe("myskill");
});
});
describe("assertValidSkillSlug", () => {
it.each([
"abc",
"my-cool-skill",
"skill-123",
"a1b",
"123",
"abc-def-ghi",
"z".repeat(SKILL_SLUG_CONSTRAINTS.maxLength),
])("accepts valid slug %s", (slug) => {
expect(() => assertValidSkillSlug(slug)).not.toThrow();
expect(assertValidSkillSlug(slug)).toBe(slug.toLowerCase());
});
it("normalizes mixed case and whitespace before validating", () => {
expect(assertValidSkillSlug(" My-Cool-Skill ")).toBe("my-cool-skill");
});
it("silently lowercases uppercase input (legacy-compatible)", () => {
// Historically, write paths did `args.slug.trim().toLowerCase()` before
// validating. We preserve that behaviour: uppercase input is normalized,
// not rejected outright.
expect(assertValidSkillSlug("A-B-C")).toBe("a-b-c");
});
it.each([
["", "required"],
[" ", "required"],
["ab", "at least"],
["a".repeat(SKILL_SLUG_CONSTRAINTS.maxLength + 1), "at most"],
["-abc", "start and end"],
["abc-", "start and end"],
["a--b", "start and end"],
["a---b", "start and end"],
["a_b", "start and end"],
["a.b", "start and end"],
["a b", "start and end"],
["a/b", "start and end"],
])("rejects invalid slug %s", (slug, hint) => {
expect(() => assertValidSkillSlug(slug)).toThrow(new RegExp(hint, "i"));
});
it.each(["admin", "settings", "api", "openclaw", "clawhub", "souls", "packages"])(
"rejects reserved slug %s",
(slug) => {
// Some short reserved entries (e.g. "u") are also blocked by the
// length rule; we only assert that a throw happens for every entry.
expect(() => assertValidSkillSlug(slug)).toThrow();
},
);
it("emits the reserved-specific error for long reserved slugs", () => {
expect(() => assertValidSkillSlug("openclaw")).toThrow(/reserved/i);
});
it("allows reserved slugs when allowReserved is set", () => {
expect(() => assertValidSkillSlug("admin", { allowReserved: true })).not.toThrow();
expect(assertValidSkillSlug("admin", { allowReserved: true })).toBe("admin");
});
});
describe("isValidSkillSlugShape", () => {
it("returns true for well-formed slugs", () => {
expect(isValidSkillSlugShape("abc")).toBe(true);
expect(isValidSkillSlugShape("my-skill-1")).toBe(true);
});
it("returns true for reserved slugs (shape only)", () => {
// The reserved-word blocklist is intentionally NOT consulted here so
// that legacy rows carrying reserved slugs remain lookup-able.
expect(isValidSkillSlugShape("admin")).toBe(true);
});
it("is case-insensitive (normalizes before checking)", () => {
// Matches legacy read-path behaviour: search queries like "My-Skill"
// should still resolve to the slug row.
expect(isValidSkillSlugShape("A-B")).toBe(true);
});
it("returns false for malformed slugs", () => {
expect(isValidSkillSlugShape("a")).toBe(false);
expect(isValidSkillSlugShape("ab")).toBe(false);
expect(isValidSkillSlugShape("a--b")).toBe(false);
expect(isValidSkillSlugShape("-abc")).toBe(false);
expect(isValidSkillSlugShape("abc-")).toBe(false);
expect(isValidSkillSlugShape("a_b")).toBe(false);
expect(isValidSkillSlugShape("")).toBe(false);
expect(isValidSkillSlugShape("a".repeat(SKILL_SLUG_CONSTRAINTS.maxLength + 1))).toBe(false);
});
});
describe("isReservedSkillSlug", () => {
it("identifies reserved slugs case-insensitively", () => {
expect(isReservedSkillSlug("admin")).toBe(true);
expect(isReservedSkillSlug(" ADMIN ")).toBe(true);
expect(isReservedSkillSlug("openclaw")).toBe(true);
});
it("returns false for non-reserved slugs", () => {
expect(isReservedSkillSlug("my-skill")).toBe(false);
expect(isReservedSkillSlug("")).toBe(false);
expect(isReservedSkillSlug(null)).toBe(false);
});
});
describe("isSearchableSkillSlugShape", () => {
it("accepts slugs shorter than the write-path minimum (legacy rows)", () => {
// Single- and two-character slugs predate the min-length floor but may
// still exist in the skills table. Search must surface them via the
// exact-slug fast path.
expect(isSearchableSkillSlugShape("a")).toBe(true);
expect(isSearchableSkillSlugShape("ab")).toBe(true);
expect(isSearchableSkillSlugShape("a1")).toBe(true);
});
it("accepts regular well-formed slugs", () => {
expect(isSearchableSkillSlugShape("abc")).toBe(true);
expect(isSearchableSkillSlugShape("my-skill-1")).toBe(true);
});
it("accepts reserved slugs (read path ignores the blocklist)", () => {
// Legacy data may still carry reserved slugs; they must remain
// searchable even though the write path would now reject them.
expect(isSearchableSkillSlugShape("admin")).toBe(true);
expect(isSearchableSkillSlugShape("u")).toBe(true);
});
it("is case-insensitive", () => {
expect(isSearchableSkillSlugShape("A-B")).toBe(true);
expect(isSearchableSkillSlugShape(" My-Skill ")).toBe(true);
});
it("still rejects malformed shapes", () => {
expect(isSearchableSkillSlugShape("")).toBe(false);
expect(isSearchableSkillSlugShape(" ")).toBe(false);
expect(isSearchableSkillSlugShape("-abc")).toBe(false);
expect(isSearchableSkillSlugShape("abc-")).toBe(false);
expect(isSearchableSkillSlugShape("a--b")).toBe(false);
expect(isSearchableSkillSlugShape("a_b")).toBe(false);
expect(isSearchableSkillSlugShape("a b")).toBe(false);
expect(isSearchableSkillSlugShape("-")).toBe(false);
});
it("accepts slugs longer than the write-path upper bound (legacy rows)", () => {
// Legacy rows predate MAX_SLUG_LENGTH and may exceed 48 chars. The read
// path must still resolve them via the by_slug fast path; otherwise
// searchSkills falls back to scanning only the most recent digests and
// can miss older records entirely.
expect(isSearchableSkillSlugShape("a".repeat(SKILL_SLUG_CONSTRAINTS.maxLength))).toBe(true);
expect(isSearchableSkillSlugShape("a".repeat(SKILL_SLUG_CONSTRAINTS.maxLength + 1))).toBe(true);
expect(isSearchableSkillSlugShape("a".repeat(200))).toBe(true);
});
});
+231
View File
@@ -0,0 +1,231 @@
import { ConvexError } from "convex/values";
// Slug shape rules:
// - Lowercase letters, digits, and single hyphens only.
// - Must start and end with a letter or digit.
// - No consecutive hyphens ("--", "---", ...).
// - Length 3..48 (URL/SEO friendly, aligned with publisher handle).
//
// The pattern enforces first/last char class and forbids consecutive hyphens
// via a negative lookahead. Length bounds are checked separately so we can
// emit precise error messages.
const SLUG_PATTERN = /^[a-z0-9](?:(?!--)[a-z0-9-])*[a-z0-9]$/;
const MIN_SLUG_LENGTH = 3;
const MAX_SLUG_LENGTH = 48;
// Reserved slugs. These are blocked because they would:
// 1. Clash semantically with top-level routes under src/routes/*.
// 2. Allow brand/role impersonation (e.g. "official", "clawhub").
// 3. Lock future route expansion (e.g. "api", "auth", "oauth").
//
// Keep this list in sync with:
// - src/routes/*.tsx top-level segments
// - brand names shipped in README.md
const RESERVED_SKILL_SLUGS: ReadonlySet<string> = new Set([
// Current top-level route segments under src/routes/.
"about",
"admin",
"cli",
"dashboard",
"import",
"management",
"orgs",
"packages",
"plugins",
"publish",
"publish-plugin",
"publish-skill",
"search",
"settings",
"skills",
"souls",
"stars",
"u",
"upload",
"users",
// Reserved for likely future additions.
"api",
"auth",
"oauth",
"callback",
"login",
"logout",
"signin",
"signout",
"signup",
"register",
"docs",
"doc",
"help",
"support",
"status",
"health",
"blog",
"news",
"pricing",
"terms",
"privacy",
"legal",
"contact",
"home",
"explore",
// Brand and project names.
"openclaw",
"clawhub",
"clawd",
"clawdbot",
"onlycrabs",
"soulhub",
// Generic identity / role words.
"me",
"self",
"system",
"root",
"owner",
"official",
"staff",
"team",
"mod",
"moderator",
// Reserved CRUD/action words that would make URLs ambiguous.
"new",
"edit",
"delete",
"create",
"update",
"remove",
"public",
"private",
"internal",
// Literals that would be confusing in URLs.
"null",
"undefined",
"true",
"false",
]);
export interface ValidateSlugOptions {
/**
* Bypass the reserved-word blocklist.
* Intended for admin migrations / internal seeding only.
*/
allowReserved?: boolean;
}
export const SKILL_SLUG_CONSTRAINTS = {
minLength: MIN_SLUG_LENGTH,
maxLength: MAX_SLUG_LENGTH,
pattern: SLUG_PATTERN,
reserved: RESERVED_SKILL_SLUGS,
} as const;
/**
* Lowercase and trim a slug. Does not throw.
*
* Safe to call on any read-path input (query by slug, redirect lookup, ...)
* without rejecting legacy data.
*/
export function normalizeSkillSlug(raw: string | undefined | null): string {
return (raw ?? "").trim().toLowerCase();
}
/**
* Variant that returns null when the input normalizes to an empty string.
* Useful at read-path call sites that want to short-circuit lookup.
*/
export function normalizeSkillSlugOrNull(raw: string | undefined | null): string | null {
const normalized = normalizeSkillSlug(raw);
return normalized.length ? normalized : null;
}
/**
* Check whether a string already matches the full slug shape rules.
* Returns true only when the value is a plausible slug (length, pattern).
*
* Note: this intentionally does NOT consult the reserved-word blocklist
* because legacy rows may still carry reserved slugs and we want to
* keep them readable. It DOES enforce the current min-length floor
* (MIN_SLUG_LENGTH) and is therefore only appropriate for call sites
* that treat a value as a "newly-shaped" slug. For read-only lookups
* (search, redirect) that must stay discoverable for pre-existing
* short slugs, use isSearchableSkillSlugShape instead.
*/
export function isValidSkillSlugShape(value: string | undefined | null): boolean {
const normalized = normalizeSkillSlug(value);
if (normalized.length < MIN_SLUG_LENGTH || normalized.length > MAX_SLUG_LENGTH) {
return false;
}
return SLUG_PATTERN.test(normalized);
}
/**
* Lenient shape check used by read paths (search exact-slug optimization,
* redirect lookups, etc.).
*
* Unlike isValidSkillSlugShape, this predicate intentionally omits:
* - the min-length floor (legacy rows with 1- or 2-char slugs must stay
* retrievable via the by_slug fast path),
* - the max-length cap (rows persisted before MAX_SLUG_LENGTH was
* introduced may exceed 48 chars and must remain lookup-able; the
* indexed point lookup for a missing key is cheap, and upstream
* request-body limits bound the practical query length),
* - the reserved-word blocklist (grandfathered data must stay readable).
*
* Write paths MUST continue to use assertValidSkillSlug, which enforces
* the full validation surface (length floor + length cap + pattern +
* reserved blocklist).
*/
export function isSearchableSkillSlugShape(value: string | undefined | null): boolean {
const normalized = normalizeSkillSlug(value);
if (normalized.length === 0) {
return false;
}
// Single-character legacy slug: a bare [a-z0-9] is searchable. The full
// SLUG_PATTERN requires >=2 chars (separate first/last classes), so we
// handle the single-char case explicitly before delegating to it.
if (normalized.length === 1) {
return /^[a-z0-9]$/.test(normalized);
}
return SLUG_PATTERN.test(normalized);
}
/**
* Returns a normalized slug or throws ConvexError describing the first
* violation encountered. Use this on every write path (publish/rename).
*/
export function assertValidSkillSlug(
rawSlug: string | undefined | null,
options: ValidateSlugOptions = {},
): string {
const normalized = normalizeSkillSlug(rawSlug);
if (!normalized) {
throw new ConvexError("Slug is required.");
}
if (normalized.length < MIN_SLUG_LENGTH) {
throw new ConvexError(`Slug must be at least ${MIN_SLUG_LENGTH} characters.`);
}
if (normalized.length > MAX_SLUG_LENGTH) {
throw new ConvexError(`Slug must be at most ${MAX_SLUG_LENGTH} characters.`);
}
if (!SLUG_PATTERN.test(normalized)) {
throw new ConvexError(
"Slug must start and end with a letter or digit, contain only lowercase letters, " +
"digits, and single hyphens, and not contain consecutive hyphens.",
);
}
if (!options.allowReserved && RESERVED_SKILL_SLUGS.has(normalized)) {
throw new ConvexError(`"${normalized}" is reserved and cannot be used as a slug.`);
}
return normalized;
}
/**
* Convenience predicate: is the slug on the reserved blocklist?
* Exposed so callers (e.g. admin tooling) can pre-check without a throw.
*/
export function isReservedSkillSlug(slug: string | undefined | null): boolean {
const normalized = normalizeSkillSlug(slug);
return RESERVED_SKILL_SLUGS.has(normalized);
}
+20 -6
View File
@@ -1,5 +1,5 @@
import { ConvexError } from "convex/values";
import { normalizeTextContentType } from "clawhub-schema";
import { ConvexError } from "convex/values";
import semver from "semver";
import { internal } from "../_generated/api";
import type { Doc, Id } from "../_generated/dataModel";
@@ -16,6 +16,7 @@ import {
parseFrontmatter,
sanitizePath,
} from "./skills";
import { assertValidSkillSlug, normalizeSkillSlug } from "./skillSlugValidator";
import { generateSoulChangelogForPublish } from "./soulChangelog";
const MAX_TOTAL_BYTES = 50 * 1024 * 1024;
@@ -84,18 +85,31 @@ export async function publishSoulVersionForUser(
args: PublishVersionArgs,
): Promise<PublishResult> {
const version = args.version.trim();
const slug = args.slug.trim().toLowerCase();
// Normalize first so we can look up the existing soul before deciding how
// strictly to validate. Owners of grandfathered slugs (reserved, <3 chars,
// or >48 chars) must still be able to publish new versions; the strict
// write-path rules only apply when creating a brand-new soul.
const normalizedSlug = normalizeSkillSlug(args.slug);
if (!normalizedSlug) throw new ConvexError("Slug is required.");
const displayName = args.displayName.trim();
if (!slug || !displayName) throw new ConvexError("Slug and display name required");
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) {
throw new ConvexError("Slug must be lowercase and url-safe");
}
if (!displayName) throw new ConvexError("Display name required");
if (!semver.valid(version)) {
throw new ConvexError("Version must be valid semver");
}
await requireGitHubAccountAge(ctx, userId);
// Resolve existing soul before enforcing slug rules so grandfathered rows
// are not blocked. Full validation is only applied on the create path.
const existingSoul = (await ctx.runQuery(internal.souls.getSoulBySlugInternal, {
slug: normalizedSlug,
})) as Doc<"souls"> | null;
if (!existingSoul) {
assertValidSkillSlug(normalizedSlug);
}
const slug = normalizedSlug;
const suppliedChangelog = args.changelog.trim();
const changelogSource = suppliedChangelog ? ("user" as const) : ("auto" as const);
-183
View File
@@ -167,8 +167,6 @@ type PublicPackageListItem = {
executesCode: boolean;
verificationTier: PackageVerificationTier | null;
};
type PackageBadgeKind = Doc<"packageBadges">["kind"];
type PackageDigestLike = Pick<
Doc<"packageSearchDigest">,
| "packageId"
@@ -411,36 +409,6 @@ function digestMatchesSearchFilters(
return digestMatchesFilters(digest, args);
}
async function upsertPackageBadge(
ctx: MutationCtx,
packageId: Id<"packages">,
kind: PackageBadgeKind,
userId: Id<"users">,
at: number,
) {
const existing = await ctx.db
.query("packageBadges")
.withIndex("by_package_kind", (q) => q.eq("packageId", packageId).eq("kind", kind))
.unique();
if (existing) {
await ctx.db.patch(existing._id, { byUserId: userId, at });
return;
}
await ctx.db.insert("packageBadges", { packageId, kind, byUserId: userId, at });
}
async function removePackageBadge(
ctx: MutationCtx,
packageId: Id<"packages">,
kind: PackageBadgeKind,
) {
const existing = await ctx.db
.query("packageBadges")
.withIndex("by_package_kind", (q) => q.eq("packageId", packageId).eq("kind", kind))
.unique();
if (existing) await ctx.db.delete(existing._id);
}
function toPublicPackageListItem(digest: PackageDigestLike): PublicPackageListItem {
return {
name: digest.name,
@@ -967,62 +935,6 @@ function buildPackageCapabilityDigestQuery(
);
}
async function fetchHighlightedPackageDigests(
ctx: DbReaderCtx,
args: {
family?: PackageFamily;
channel?: PackageChannel;
isOfficial?: boolean;
executesCode?: boolean;
capabilityTag?: string;
viewerUserId?: Id<"users">;
},
) {
const viewerUserId = args.viewerUserId;
const membershipCache = new Map<string, Promise<boolean>>();
const badges = await ctx.db
.query("packageBadges")
.withIndex("by_kind_at", (q) => q.eq("kind", "highlighted"))
.order("desc")
.take(MAX_PUBLIC_LIST_PAGE_SIZE);
const digests: PackageDigestLike[] = [];
for (const badge of badges) {
const digest = await ctx.db
.query("packageSearchDigest")
.withIndex("by_package", (q) => q.eq("packageId", badge.packageId))
.unique();
if (!digest || digest.softDeletedAt) continue;
if (!(await canViewerReadPackage(ctx, digest, viewerUserId, membershipCache))) continue;
if (!digestMatchesSearchFilters(digest, args)) continue;
digests.push(digest);
}
return digests;
}
async function fetchHighlightedPackagePage(
ctx: DbReaderCtx,
args: {
family?: PackageFamily;
channel?: PackageChannel;
isOfficial?: boolean;
executesCode?: boolean;
capabilityTag?: string;
viewerUserId?: Id<"users">;
numItems: number;
},
) {
const digests = await fetchHighlightedPackageDigests(ctx, args);
return digests
.sort(
(a, b) =>
Number(b.isOfficial) - Number(a.isOfficial) ||
b.updatedAt - a.updatedAt ||
a.name.localeCompare(b.name),
)
.slice(0, args.numItems)
.map(toPublicPackageListItem);
}
async function getPackageByNormalizedName(ctx: DbReaderCtx, normalizedName: string) {
return (await ctx.db
.query("packages")
@@ -1099,41 +1011,6 @@ export const getByName = query({
},
});
export const getByNameForStaff = query({
args: { name: v.string() },
handler: async (ctx, args) => {
const { user } = await requireUser(ctx);
assertModerator(user);
const pkg = await getPackageByNormalizedName(ctx, normalizePackageName(args.name));
if (!pkg || pkg.softDeletedAt || pkg.family === "skill") return null;
const highlighted = await ctx.db
.query("packageBadges")
.withIndex("by_package_kind", (q) => q.eq("packageId", pkg._id).eq("kind", "highlighted"))
.unique();
const latestRelease = pkg.latestReleaseId ? await ctx.db.get(pkg.latestReleaseId) : null;
const owner = toPublicPublisher(
await getOwnerPublisher(ctx, {
ownerPublisherId: pkg.ownerPublisherId,
ownerUserId: pkg.ownerUserId,
}),
);
return {
package: pkg,
latestRelease: latestRelease && !latestRelease.softDeletedAt ? latestRelease : null,
owner,
highlighted: highlighted
? {
byUserId: highlighted.byUserId,
at: highlighted.at,
}
: null,
};
},
});
export const getByNameForViewerInternal = internalQuery({
args: {
name: v.string(),
@@ -1281,7 +1158,6 @@ export const listPublicPage = query({
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
),
isOfficial: v.optional(v.boolean()),
highlightedOnly: v.optional(v.boolean()),
executesCode: v.optional(v.boolean()),
capabilityTag: v.optional(v.string()),
paginationOpts: paginationOptsValidator,
@@ -1300,7 +1176,6 @@ export const listPageForViewerInternal = internalQuery({
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
),
isOfficial: v.optional(v.boolean()),
highlightedOnly: v.optional(v.boolean()),
executesCode: v.optional(v.boolean()),
capabilityTag: v.optional(v.string()),
viewerUserId: v.optional(v.id("users")),
@@ -1317,7 +1192,6 @@ async function listPackagePageImpl(
family?: PackageFamily;
channel?: PackageChannel;
isOfficial?: boolean;
highlightedOnly?: boolean;
executesCode?: boolean;
capabilityTag?: string;
viewerUserId?: Id<"users">;
@@ -1332,15 +1206,6 @@ async function listPackagePageImpl(
const canViewPackage = async (digest: PackageDigestLike) =>
await canViewerReadPackage(ctx, digest, viewerUserId, membershipCache);
const targetCount = args.paginationOpts.numItems;
if (args.highlightedOnly) {
const page = await fetchHighlightedPackagePage(ctx, {
...args,
numItems: targetCount,
});
return { page, isDone: true, continueCursor: "" };
}
const collected: PublicPackageListItem[] = [];
const decodedCursor = decodePublicPageCursor(args.paginationOpts.cursor);
if (decodedCursor.done && decodedCursor.offset === 0) {
@@ -1435,7 +1300,6 @@ export const searchPublic = query({
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
),
isOfficial: v.optional(v.boolean()),
highlightedOnly: v.optional(v.boolean()),
executesCode: v.optional(v.boolean()),
capabilityTag: v.optional(v.string()),
},
@@ -1455,7 +1319,6 @@ export const searchForViewerInternal = internalQuery({
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
),
isOfficial: v.optional(v.boolean()),
highlightedOnly: v.optional(v.boolean()),
executesCode: v.optional(v.boolean()),
capabilityTag: v.optional(v.string()),
viewerUserId: v.optional(v.id("users")),
@@ -1473,7 +1336,6 @@ async function searchPackagesImpl(
family?: PackageFamily;
channel?: PackageChannel;
isOfficial?: boolean;
highlightedOnly?: boolean;
executesCode?: boolean;
capabilityTag?: string;
viewerUserId?: Id<"users">;
@@ -1487,21 +1349,6 @@ async function searchPackagesImpl(
const membershipCache = new Map<string, Promise<boolean>>();
const canViewPackage = async (digest: PackageDigestLike) =>
await canViewerReadPackage(ctx, digest, viewerUserId, membershipCache);
if (args.highlightedOnly) {
const digests = await fetchHighlightedPackageDigests(ctx, args);
return digests
.map((digest) => ({ score: packageSearchScore(digest, queryText), package: digest }))
.filter((entry) => entry.score > 0)
.sort(
(a, b) =>
b.score - a.score ||
Number(b.package.isOfficial) - Number(a.package.isOfficial) ||
b.package.updatedAt - a.package.updatedAt,
)
.slice(0, targetCount)
.map((entry) => ({ score: entry.score, package: toPublicPackageListItem(entry.package) }));
}
const builder = args.capabilityTag
? buildPackageCapabilityDigestQuery(ctx, {
capabilityTag: args.capabilityTag,
@@ -2950,36 +2797,6 @@ export const requestRescan = mutation({
},
});
export const setBatch = mutation({
args: { packageId: v.id("packages"), batch: v.optional(v.string()) },
handler: async (ctx, args) => {
const { user } = await requireUser(ctx);
assertModerator(user);
const pkg = await ctx.db.get(args.packageId);
if (!pkg || pkg.softDeletedAt || pkg.family === "skill") {
throw new ConvexError("Plugin not found");
}
const nextBatch = args.batch?.trim() || undefined;
const nextHighlighted = nextBatch === "highlighted";
const now = Date.now();
if (nextHighlighted) {
await upsertPackageBadge(ctx, pkg._id, "highlighted", user._id, now);
} else {
await removePackageBadge(ctx, pkg._id, "highlighted");
}
await ctx.db.insert("auditLogs", {
actorUserId: user._id,
action: "package.badge.highlighted",
targetType: "package",
targetId: pkg._id,
metadata: { highlighted: nextHighlighted },
createdAt: now,
});
},
});
export const requestRescanForApiTokenInternal = internalMutation({
args: {
actorUserId: v.id("users"),
+6 -12
View File
@@ -547,16 +547,6 @@ const skillBadges = defineTable({
.index("by_skill_kind", ["skillId", "kind"])
.index("by_kind_at", ["kind", "at"]);
const packageBadges = defineTable({
packageId: v.id("packages"),
kind: v.union(v.literal("highlighted")),
byUserId: v.id("users"),
at: v.number(),
})
.index("by_package", ["packageId"])
.index("by_package_kind", ["packageId", "kind"])
.index("by_kind_at", ["kind", "at"]);
const soulVersionFingerprints = defineTable({
soulId: v.id("souls"),
versionId: v.id("soulVersions"),
@@ -1229,7 +1219,12 @@ const rescanRequests = defineTable({
.index("by_skill_version", ["targetKind", "skillVersionId", "createdAt"])
.index("by_skill_version_status", ["targetKind", "skillVersionId", "status", "createdAt"])
.index("by_package_release", ["targetKind", "packageReleaseId", "createdAt"])
.index("by_package_release_status", ["targetKind", "packageReleaseId", "status", "createdAt"])
.index("by_package_release_status", [
"targetKind",
"packageReleaseId",
"status",
"createdAt",
])
.index("by_requester", ["requestedByUserId", "createdAt"]);
const apiTokens = defineTable({
@@ -1367,7 +1362,6 @@ export default defineSchema({
packageReleases,
packageTrustedPublishers,
packagePublishTokens,
packageBadges,
packageSearchDigest,
packageCapabilitySearchDigest,
souls,
+12 -5
View File
@@ -12,6 +12,7 @@ import { matchesExactTokens, tokenize } from "./lib/searchText";
import { SKILL_CAPABILITY_TAGS } from "./lib/skillCapabilityTags";
import { isSkillSuspicious } from "./lib/skillSafety";
import { digestToHydratableSkill, digestToOwnerInfo } from "./lib/skillSearchDigest";
import { isSearchableSkillSlugShape, normalizeSkillSlug } from "./lib/skillSlugValidator";
type OwnerInfo = { ownerHandle: string | null; owner: PublicPublisher | null };
@@ -123,7 +124,11 @@ function mergeUniqueBySkillId(primary: SkillSearchEntry[], fallback: SkillSearch
}
function isSlugLikeQuery(query: string) {
return /^[a-z0-9][a-z0-9-]*$/.test(query.trim().toLowerCase());
// Lenient shape check used by the read path: pattern + upper length cap only.
// The min-length floor and reserved-word blocklist are intentionally omitted
// so legacy rows (grandfathered short/reserved slugs) remain discoverable via
// the exact-slug fast path. Write paths still go through assertValidSkillSlug.
return isSearchableSkillSlugShape(query);
}
function matchesCapabilityTag(
@@ -183,8 +188,7 @@ export const searchSkills: ReturnType<typeof action> = action({
const results = await ctx.vectorSearch("skillEmbeddings", "by_embedding", {
vector,
limit: candidateLimit,
filter: (q) =>
q.or(q.eq("visibility", "latest"), q.eq("visibility", "latest-approved")),
filter: (q) => q.or(q.eq("visibility", "latest"), q.eq("visibility", "latest-approved")),
});
// Only hydrate embedding IDs we haven't seen yet (incremental).
@@ -371,8 +375,11 @@ export const lexicalFallbackSkills = internalQuery({
>();
// Exact slug match via the skills table (only one row, cheap).
const slugQuery = args.query.trim().toLowerCase();
if (!args.skipExactSlugLookup && /^[a-z0-9][a-z0-9-]*$/.test(slugQuery)) {
// Use the lenient shape predicate so legacy rows with sub-min-length
// slugs stay discoverable; the caller in searchSkills already passes
// skipExactSlugLookup=true after running its own exact-slug lookup.
const slugQuery = normalizeSkillSlug(args.query);
if (!args.skipExactSlugLookup && isSearchableSkillSlugShape(slugQuery)) {
const exactSlugSkill = await ctx.db
.query("skills")
.withIndex("by_slug", (q) => q.eq("slug", slugQuery))
+28 -18
View File
@@ -96,6 +96,7 @@ import {
extractDigestFields,
upsertSkillSearchDigest,
} from "./lib/skillSearchDigest";
import { assertValidSkillSlug, normalizeSkillSlug } from "./lib/skillSlugValidator";
import { readCanonicalStat } from "./lib/skillStats";
import { runStaticPublishScan } from "./lib/staticPublishScan";
import { adjustUserSkillStatsForSkillChange } from "./lib/userSkillStats";
@@ -551,7 +552,10 @@ function buildAliasTakenErrorMessage(skill: Doc<"skills">, owner: SkillOwnerRef)
}
function normalizeSkillSlugKey(slug: string) {
return slug.trim().toLowerCase();
// Read-path normalization: lowercase + trim only. Intentionally lenient so
// that legacy rows (pre-validator) remain lookup-able. Write paths must
// use `normalizeSkillSlugForWrite` / `assertValidSkillSlug` instead.
return normalizeSkillSlug(slug);
}
type SkillOwnerRef =
@@ -565,11 +569,9 @@ type SkillOwnerRef =
| undefined;
function normalizeSkillSlugForWrite(slug: string) {
const normalized = normalizeSkillSlugKey(slug);
if (!normalized || !/^[a-z0-9][a-z0-9-]*$/.test(normalized)) {
throw new ConvexError("Slug must be lowercase and url-safe");
}
return normalized;
// Write-path: full validation (length, pattern, reserved words,
// no consecutive hyphens). See `lib/skillSlugValidator.ts`.
return assertValidSkillSlug(slug);
}
async function getSkillSlugAliasBySlug(ctx: Pick<QueryCtx | MutationCtx, "db">, slug: string) {
@@ -3055,7 +3057,6 @@ function skillCatalogMatchesFilters(
args: {
channel?: "official" | "community" | "private";
isOfficial?: boolean;
highlightedOnly?: boolean;
executesCode?: boolean;
capabilityTag?: string;
},
@@ -3066,7 +3067,6 @@ function skillCatalogMatchesFilters(
const isOfficial = isSkillCatalogOfficial(digest);
const channel = getSkillCatalogChannel(digest);
if (typeof args.isOfficial === "boolean" && isOfficial !== args.isOfficial) return false;
if (args.highlightedOnly && !isSkillHighlighted(digest)) return false;
if (args.channel && channel !== args.channel) return false;
if (args.capabilityTag && !(digest.capabilityTags ?? []).includes(args.capabilityTag))
return false;
@@ -3122,7 +3122,6 @@ export const listPackageCatalogPage = query({
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
),
isOfficial: v.optional(v.boolean()),
highlightedOnly: v.optional(v.boolean()),
executesCode: v.optional(v.boolean()),
capabilityTag: v.optional(v.string()),
paginationOpts: paginationOptsValidator,
@@ -3214,7 +3213,6 @@ export const searchPackageCatalogPublic = query({
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
),
isOfficial: v.optional(v.boolean()),
highlightedOnly: v.optional(v.boolean()),
executesCode: v.optional(v.boolean()),
capabilityTag: v.optional(v.string()),
},
@@ -5793,13 +5791,11 @@ async function renameOwnedSkillByActor(
}
const now = Date.now();
const sourceSlug = sourceSlugArg.trim().toLowerCase();
const newSlug = newSlugArg.trim().toLowerCase();
const sourceSlug = normalizeSkillSlug(sourceSlugArg);
if (!sourceSlug) throw new ConvexError("Current slug required");
if (!newSlug) throw new ConvexError("New slug required");
if (!/^[a-z0-9][a-z0-9-]*$/.test(newSlug)) {
throw new ConvexError("Invalid slug. Use lowercase letters, numbers, and hyphens only.");
}
// Full write-path validation for the new slug: length, pattern,
// reserved-word blocklist, no consecutive hyphens.
const newSlug = assertValidSkillSlug(newSlugArg);
const resolved = await resolveSkillBySlugOrAlias(ctx, sourceSlug);
const skill = resolved.skill;
@@ -6534,7 +6530,16 @@ export const insertVersion = internalMutation({
},
handler: async (ctx, args) => {
const userId = args.userId;
const slug = normalizeSkillSlugForWrite(args.slug);
// Lenient normalization first so we can look up an existing skill row
// before deciding whether to enforce the strict write-path validator.
// Owners of grandfathered slugs (reserved, <3 chars, >48 chars, or other
// pre-validator shapes) must remain able to publish new versions; the
// strict reserved/length/pattern rules only apply when creating a brand
// new skill. The caller (publishVersionForUser) performs the same split,
// but the mutation re-validates defensively because it can be invoked on
// its own (e.g. tests, internal schedulers).
const normalizedSlug = normalizeSkillSlug(args.slug);
if (!normalizedSlug) throw new ConvexError("Slug is required.");
const user = await ctx.db.get(userId);
if (!user || user.deletedAt || user.deactivatedAt) throw new Error("User not found");
const personalPublisher = await ensurePersonalPublisherForUser(ctx, user);
@@ -6552,9 +6557,14 @@ export const insertVersion = internalMutation({
let skill = await ctx.db
.query("skills")
.withIndex("by_slug", (q) => q.eq("slug", slug))
.withIndex("by_slug", (q) => q.eq("slug", normalizedSlug))
.unique();
// Only enforce the strict write-path rules when creating a new skill.
// For existing rows, keep the already-persisted (possibly grandfathered)
// slug as-is so legacy publishers are not locked out of version updates.
const slug = skill ? normalizedSlug : normalizeSkillSlugForWrite(args.slug);
if (!skill) {
const alias = await getSkillSlugAliasBySlug(ctx, slug);
if (alias) {
+21 -8
View File
@@ -7,6 +7,7 @@ import { assertModerator, requireUser, requireUserFromAction } from "./lib/acces
import { embeddingVisibilityFor } from "./lib/embeddingVisibility";
import { toPublicSoul, toPublicUser } from "./lib/public";
import { getFrontmatterValue, hashSkillFiles } from "./lib/skills";
import { assertValidSkillSlug, normalizeSkillSlug } from "./lib/skillSlugValidator";
import { generateSoulChangelogPreview } from "./lib/soulChangelog";
import { fetchText, type PublishResult, publishSoulVersionForUser } from "./lib/soulPublish";
@@ -70,15 +71,15 @@ function toPublicSoulVersion(
}
function normalizeSoulSlugKey(slug: string) {
return slug.trim().toLowerCase();
// Read-path normalization: lowercase + trim only. Intentionally lenient so
// that legacy rows (pre-validator) remain lookup-able.
return normalizeSkillSlug(slug);
}
function normalizeSoulSlugForWrite(slug: string) {
const normalized = normalizeSoulSlugKey(slug);
if (!normalized || !/^[a-z0-9][a-z0-9-]*$/.test(normalized)) {
throw new ConvexError("Slug must be lowercase and url-safe");
}
return normalized;
// Write-path: full validation (length, pattern, reserved words,
// no consecutive hyphens). Souls share the rules with skills.
return assertValidSkillSlug(slug);
}
export const getBySlug = query({
@@ -497,17 +498,29 @@ export const insertVersion = internalMutation({
},
handler: async (ctx, args) => {
const userId = args.userId;
const slug = normalizeSoulSlugForWrite(args.slug);
// Lenient normalization first: we must look up the existing soul row
// before deciding whether to enforce the strict write-path validator.
// Owners of grandfathered slugs (reserved, <3 chars, >48 chars, or other
// pre-validator shapes) must remain able to publish new versions; the
// strict rules only apply when creating a brand new soul. The caller
// (publishSoulVersionForUser) performs the same split, but the mutation
// re-validates defensively because it can be invoked on its own.
const normalizedSlug = normalizeSkillSlug(args.slug);
if (!normalizedSlug) throw new ConvexError("Slug is required.");
const user = await ctx.db.get(userId);
if (!user || user.deletedAt || user.deactivatedAt) throw new Error("User not found");
const soulMatches = await ctx.db
.query("souls")
.withIndex("by_slug", (q) => q.eq("slug", slug))
.withIndex("by_slug", (q) => q.eq("slug", normalizedSlug))
.order("desc")
.take(2);
let soul: Doc<"souls"> | null = soulMatches[0] ?? null;
// Only enforce the strict write-path rules when creating a new soul; for
// existing rows keep the already-persisted (possibly grandfathered) slug.
const slug = soul ? normalizedSlug : normalizeSoulSlugForWrite(args.slug);
if (soul && soul.ownerUserId !== userId) {
throw new ConvexError("Only the owner can publish soul updates");
}
+6 -28
View File
@@ -5,7 +5,6 @@ import type { ComponentType, ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const fetchPluginCatalogMock = vi.fn();
const fetchFeaturedPluginsMock = vi.fn();
const isRateLimitedPackageApiErrorMock = vi.fn(
(error: unknown) =>
typeof error === "object" && error !== null && (error as { status?: number }).status === 429,
@@ -58,10 +57,6 @@ vi.mock("../lib/packageApi", () => ({
isRateLimitedPackageApiError: (error: unknown) => isRateLimitedPackageApiErrorMock(error),
}));
vi.mock("../lib/featuredCatalog", () => ({
fetchFeaturedPlugins: (...args: unknown[]) => fetchFeaturedPluginsMock(...args),
}));
async function loadRoute() {
return (await import("../routes/plugins/index")).Route as unknown as {
__config: {
@@ -75,7 +70,6 @@ async function loadRoute() {
describe("plugins route", () => {
beforeEach(() => {
fetchPluginCatalogMock.mockReset();
fetchFeaturedPluginsMock.mockReset();
isRateLimitedPackageApiErrorMock.mockClear();
navigateMock.mockReset();
searchMock = {};
@@ -98,7 +92,6 @@ describe("plugins route", () => {
family: undefined,
q: "demo",
cursor: undefined,
featured: undefined,
verified: undefined,
executesCode: undefined,
});
@@ -218,29 +211,12 @@ describe("plugins route", () => {
);
});
it("selects featured from the sort group", async () => {
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
fireEvent.click(screen.getByRole("radio", { name: "Featured" }));
expect(navigateMock).toHaveBeenCalled();
const lastCall = navigateMock.mock.calls.at(-1)?.[0] as {
search: (prev: Record<string, unknown>) => Record<string, unknown>;
};
expect(lastCall.search({ family: "code-plugin", cursor: "cursor:current" })).toEqual({
family: undefined,
cursor: undefined,
featured: true,
});
});
it("returns a retryable empty state when the catalog is rate limited", async () => {
fetchPluginCatalogMock.mockRejectedValue({ status: 429, retryAfterSeconds: 22 });
const route = await loadRoute();
const loader = route.__config.loader as (args: { deps: Record<string, unknown> }) => Promise<{
const loader = route.__config.loader as (args: {
deps: Record<string, unknown>;
}) => Promise<{
items: Array<{ name: string }>;
nextCursor: string | null;
rateLimited: boolean;
@@ -261,7 +237,9 @@ describe("plugins route", () => {
it("flags API errors for filtered catalog requests", async () => {
fetchPluginCatalogMock.mockRejectedValue(new Error("boom"));
const route = await loadRoute();
const loader = route.__config.loader as (args: { deps: Record<string, unknown> }) => Promise<{
const loader = route.__config.loader as (args: {
deps: Record<string, unknown>;
}) => Promise<{
items: Array<{ name: string }>;
nextCursor: string | null;
rateLimited: boolean;
@@ -40,7 +40,7 @@ function runBeforeLoad(search: Record<string, unknown>) {
describe("skills route default sort", () => {
it("redirects browse view to downloads when sort is missing", () => {
expect(runBeforeLoad({})).toEqual({
expect(runBeforeLoad({ nonSuspicious: true })).toEqual({
redirect: {
to: "/skills",
search: {
@@ -48,8 +48,7 @@ describe("skills route default sort", () => {
sort: "downloads",
dir: undefined,
highlighted: undefined,
featured: undefined,
nonSuspicious: undefined,
nonSuspicious: true,
tag: undefined,
view: undefined,
focus: undefined,
@@ -62,10 +61,4 @@ describe("skills route default sort", () => {
it("does not redirect when query is present", () => {
expect(runBeforeLoad({ q: "notion" })).toBeUndefined();
});
it("does not redirect when filters are present", () => {
expect(runBeforeLoad({ nonSuspicious: true })).toBeUndefined();
expect(runBeforeLoad({ featured: true })).toBeUndefined();
expect(runBeforeLoad({ highlighted: true })).toBeUndefined();
});
});
-6
View File
@@ -1,6 +0,0 @@
import { fetchPluginCatalog } from "./packageApi";
export async function fetchFeaturedPlugins(limit: number = 50) {
const result = await fetchPluginCatalog({ featured: true, limit });
return result.items;
}
+3 -18
View File
@@ -255,7 +255,6 @@ export async function fetchPackages(params: {
cursor?: string;
family?: "skill" | "code-plugin" | "bundle-plugin";
isOfficial?: boolean;
featured?: boolean;
executesCode?: boolean;
capabilityTag?: string;
limit?: number;
@@ -268,7 +267,6 @@ export async function fetchPackages(params: {
if (typeof params.isOfficial === "boolean") {
url.searchParams.set("isOfficial", String(params.isOfficial));
}
if (params.featured) url.searchParams.set("featured", "true");
if (typeof params.executesCode === "boolean") {
url.searchParams.set("executesCode", String(params.executesCode));
}
@@ -289,7 +287,6 @@ export async function fetchPackages(params: {
if (typeof params.isOfficial === "boolean") {
url.searchParams.set("isOfficial", String(params.isOfficial));
}
if (params.featured) url.searchParams.set("featured", "true");
if (typeof params.executesCode === "boolean") {
url.searchParams.set("executesCode", String(params.executesCode));
}
@@ -302,7 +299,6 @@ export async function fetchPluginCatalog(params: {
cursor?: string;
family?: PluginFamily;
isOfficial?: boolean;
featured?: boolean;
executesCode?: boolean;
limit?: number;
}): Promise<PluginCatalogResult> {
@@ -312,7 +308,6 @@ export async function fetchPluginCatalog(params: {
cursor: params.cursor,
family: params.family,
isOfficial: params.isOfficial,
featured: params.featured,
executesCode: params.executesCode,
limit: params.limit,
});
@@ -337,7 +332,6 @@ export async function fetchPluginCatalog(params: {
if (typeof params.isOfficial === "boolean") {
url.searchParams.set("isOfficial", String(params.isOfficial));
}
if (params.featured) url.searchParams.set("featured", "true");
if (typeof params.executesCode === "boolean") {
url.searchParams.set("executesCode", String(params.executesCode));
}
@@ -345,9 +339,7 @@ export async function fetchPluginCatalog(params: {
results?: Array<{ score: number; package: PackageListItem }>;
}>(url);
return {
items: (response?.results ?? [])
.map((entry) => entry?.package)
.filter(Boolean) as PackageListItem[],
items: (response?.results ?? []).map((entry) => entry?.package).filter(Boolean) as PackageListItem[],
nextCursor: null,
};
}
@@ -358,7 +350,6 @@ export async function fetchPluginCatalog(params: {
if (typeof params.isOfficial === "boolean") {
url.searchParams.set("isOfficial", String(params.isOfficial));
}
if (params.featured) url.searchParams.set("featured", "true");
if (typeof params.executesCode === "boolean") {
url.searchParams.set("executesCode", String(params.executesCode));
}
@@ -379,10 +370,7 @@ export async function fetchPackageDetail(name: string): Promise<PackageDetailRes
return (await response.json()) as PackageDetailResponse;
}
export async function fetchPackageVersion(
name: string,
version: string,
): Promise<PackageVersionDetail | null> {
export async function fetchPackageVersion(name: string, version: string): Promise<PackageVersionDetail | null> {
try {
const url = await packageApiUrl(
`${ApiRoutes.packages}/${encodeURIComponent(name)}/versions/${encodeURIComponent(version)}`,
@@ -394,10 +382,7 @@ export async function fetchPackageVersion(
}
}
export async function fetchPackageReadme(
name: string,
version?: string | null,
): Promise<string | null> {
export async function fetchPackageReadme(name: string, version?: string | null): Promise<string | null> {
const url = await packageApiUrl(`${ApiRoutes.packages}/${encodeURIComponent(name)}/file`);
url.searchParams.set("path", "README.md");
if (version) url.searchParams.set("version", version);
+34 -44
View File
@@ -16,8 +16,6 @@ import { api } from "../../convex/_generated/api";
import { SoulCard } from "../components/SoulCard";
import { SoulStatsTripletLine } from "../components/SoulStats";
import { convexHttp } from "../convex/client";
import { fetchFeaturedPlugins } from "../lib/featuredCatalog";
import type { PackageListItem } from "../lib/packageApi";
import type { PublicSkill, PublicSoul, PublicUser } from "../lib/publicUser";
import { getSiteMode } from "../lib/site";
@@ -39,7 +37,7 @@ function SkillsHome() {
};
const [highlighted, setHighlighted] = useState<SkillPageEntry[]>([]);
const [featuredPlugins, setFeaturedPlugins] = useState<PackageListItem[]>([]);
const [popular, setPopular] = useState<SkillPageEntry[]>([]);
const [query, setQuery] = useState("");
const navigate = useNavigate();
@@ -51,9 +49,15 @@ function SkillsHome() {
if (!cancelled) setHighlighted(r as SkillPageEntry[]);
})
.catch(() => {});
fetchFeaturedPlugins(6)
.then((items) => {
if (!cancelled) setFeaturedPlugins(items);
convexHttp
.query(api.skills.listPublicPageV4, {
numItems: 12,
sort: "downloads",
dir: "desc",
nonSuspiciousOnly: true,
})
.then((r) => {
if (!cancelled) setPopular((r as { page: SkillPageEntry[] }).page);
})
.catch(() => {});
return () => {
@@ -175,24 +179,8 @@ function SkillsHome() {
{carouselCards.length > 0 && (
<section className="home-v2-carousel-section">
<div className="home-v2-carousel-header">
<h2>Featured skills</h2>
<h2>Featured</h2>
<div className="home-v2-carousel-controls">
<Link
to="/skills"
search={{
q: undefined,
sort: undefined,
dir: undefined,
featured: true,
highlighted: undefined,
nonSuspicious: undefined,
view: undefined,
focus: undefined,
}}
className="home-v2-section-link"
>
View all <ArrowRight size={14} />
</Link>
<button type="button" className="home-v2-carousel-btn" aria-label="Previous">
<ArrowLeft size={16} />
</button>
@@ -357,20 +345,21 @@ function SkillsHome() {
</div>
</div>
{/* ═══ FEATURED PLUGINS ═══ */}
{featuredPlugins.length > 0 && (
{/* ═══ TRENDING ═══ */}
{popular.length > 0 && (
<section className="home-v2-trending-section">
<div className="home-v2-section-header">
<h2>Featured plugins</h2>
<h2>Trending Now</h2>
<Link
to="/plugins"
to="/skills"
search={{
q: undefined,
cursor: undefined,
family: undefined,
featured: true,
verified: undefined,
executesCode: undefined,
sort: "downloads",
dir: "desc",
highlighted: undefined,
nonSuspicious: true,
view: undefined,
focus: undefined,
}}
className="home-v2-section-link"
>
@@ -378,26 +367,27 @@ function SkillsHome() {
</Link>
</div>
<div className="home-v2-trending-grid">
{featuredPlugins.slice(0, 6).map((plugin) => (
<Link
key={plugin.name}
to="/plugins/$name"
params={{ name: plugin.name }}
className="home-v2-trend-card"
>
{popular.slice(0, 6).map((entry) => (
<Link key={entry.skill._id} to={skillLink(entry)} className="home-v2-trend-card">
<div className="home-v2-trend-head">
<div className="home-v2-trend-title">{plugin.displayName || plugin.name}</div>
<div className="home-v2-trend-title">
{entry.skill.displayName || entry.skill.slug}
</div>
<div className="home-v2-trend-creator">
{plugin.ownerHandle ? `by @${plugin.ownerHandle}` : "community plugin"}
by {entry.ownerHandle || entry.owner?.handle || "unknown"}
</div>
</div>
<div className="home-v2-trend-desc">
{plugin.summary || "Gateway plugin for OpenClaw workflows."}
{entry.skill.summary || "Agent-ready skill pack."}
</div>
<div className="home-v2-trend-bottom">
<div className="home-v2-trend-signals">
{plugin.isOfficial ? <span>Verified</span> : null}
{plugin.latestVersion ? <span>v{plugin.latestVersion}</span> : null}
<span>
<Star size={12} /> {formatStat(entry.skill.stats?.stars)}
</span>
<span>
<Download size={12} /> {formatStat(entry.skill.stats?.downloads)}
</span>
</div>
<span className="home-v2-trend-install">
<Download size={13} /> Install
+35 -151
View File
@@ -1,4 +1,4 @@
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { createFileRoute, Link } from "@tanstack/react-router";
import { useMutation, useQuery } from "convex/react";
import { useEffect, useState } from "react";
import { api } from "../../convex/_generated/api";
@@ -12,8 +12,6 @@ import {
isSkillHighlighted,
isSkillOfficial,
} from "../lib/badges";
import { familyLabel } from "../lib/packageLabels";
import type { PublicPublisher } from "../lib/publicUser";
import { isAdmin, isModerator } from "../lib/roles";
import { useAuthStatus } from "../lib/useAuthStatus";
@@ -77,13 +75,6 @@ type SkillBySlugResult = {
} | null;
} | null;
type PluginByNameResult = {
package: Doc<"packages">;
latestRelease: Doc<"packageReleases"> | null;
owner: PublicPublisher | null;
highlighted: { byUserId: Id<"users">; at: number } | null;
} | null;
function resolveOwnerParam(
handle: string | null | undefined,
ownerId?: Id<"users"> | Id<"publishers">,
@@ -108,7 +99,6 @@ function promptUnbanReason(label: string) {
export const Route = createFileRoute("/management")({
validateSearch: (search) => ({
skill: typeof search.skill === "string" && search.skill.trim() ? search.skill : undefined,
plugin: typeof search.plugin === "string" && search.plugin.trim() ? search.plugin : undefined,
}),
component: Management,
});
@@ -116,20 +106,14 @@ export const Route = createFileRoute("/management")({
function Management() {
const { me } = useAuthStatus();
const search = Route.useSearch();
const navigate = useNavigate();
const staff = isModerator(me);
const admin = isAdmin(me);
const selectedSlug = search.skill?.trim();
const selectedPluginName = search.plugin?.trim();
const selectedSkill = useQuery(
api.skills.getBySlugForStaff,
staff && selectedSlug ? { slug: selectedSlug, auditLogLimit: SKILL_AUDIT_LOG_LIMIT } : "skip",
) as SkillBySlugResult | undefined;
const selectedPlugin = useQuery(
api.packages.getByNameForStaff,
staff && selectedPluginName ? { name: selectedPluginName } : "skip",
) as PluginByNameResult | undefined;
const selectedSkillId = selectedSkill?.skill?._id ?? null;
const recentVersions = useQuery(api.skills.listRecentVersions, staff ? { limit: 20 } : "skip") as
| RecentVersionEntry[]
@@ -146,7 +130,6 @@ function Management() {
const banUser = useMutation(api.users.banUser);
const unbanUser = useMutation(api.users.unbanUser);
const setBatch = useMutation(api.skills.setBatch);
const setPackageBatch = useMutation(api.packages.setBatch);
const setSoftDeleted = useMutation(api.skills.setSoftDeleted);
const hardDelete = useMutation(api.skills.hardDelete);
const changeOwner = useMutation(api.skills.changeOwner);
@@ -162,7 +145,6 @@ function Management() {
const [reportSearchDebounced, setReportSearchDebounced] = useState("");
const [userSearch, setUserSearch] = useState("");
const [userSearchDebounced, setUserSearchDebounced] = useState("");
const [pluginSearch, setPluginSearch] = useState(selectedPluginName ?? "");
const [skillOverrideNote, setSkillOverrideNote] = useState("");
const userQuery = userSearchDebounced.trim();
@@ -184,10 +166,6 @@ function Management() {
setSkillOverrideNote("");
}, [selectedSkillId]);
useEffect(() => {
setPluginSearch(selectedPluginName ?? "");
}, [selectedPluginName]);
useEffect(() => {
const handle = setTimeout(() => setReportSearchDebounced(reportSearch), 250);
return () => clearTimeout(handle);
@@ -279,22 +257,15 @@ function Management() {
.catch((error) => window.alert(formatMutationError(error)));
};
const managePlugin = () => {
const name = pluginSearch.trim();
if (!name) return;
void navigate({
to: "/management",
search: { skill: undefined, plugin: name },
});
};
return (
<main className="section">
<h1 className="section-title">Management console</h1>
<p className="section-subtitle">Moderation, curation, and ownership tools.</p>
<Card>
<h2 className="section-title text-[1.2rem] m-0">Reported skills</h2>
<h2 className="section-title text-[1.2rem] m-0">
Reported skills
</h2>
<div className="management-controls">
<div className="management-control management-search">
<span className="mono">Filter</span>
@@ -347,7 +318,9 @@ function Management() {
))}
</div>
) : (
<div className="section-subtitle m-0">No report reasons yet.</div>
<div className="section-subtitle m-0">
No report reasons yet.
</div>
)}
</div>
<div className="management-actions">
@@ -392,7 +365,9 @@ function Management() {
</Card>
<Card className="mt-5">
<h2 className="section-title text-[1.2rem] m-0">Skill tools</h2>
<h2 className="section-title text-[1.2rem] m-0">
Skill tools
</h2>
{selectedSlug ? (
<div className="section-subtitle mt-2">
Managing "{selectedSlug}" ·{" "}
@@ -442,12 +417,16 @@ function Management() {
{skill.moderationFlags?.length ? (
<div className="management-tags">
{skill.moderationFlags.map((flag: string) => (
<Badge key={flag}>{flag}</Badge>
<Badge key={flag}>
{flag}
</Badge>
))}
</div>
) : null}
<div className="management-sublist">
<div className="section-subtitle m-0">Manual overrides</div>
<div className="section-subtitle m-0">
Manual overrides
</div>
<section className="management-override-panel">
<div className="management-report-item">
<span className="management-report-meta">Current override</span>
@@ -499,14 +478,18 @@ function Management() {
</section>
</div>
<div className="management-sublist">
<div className="section-subtitle m-0">Recent audit activity</div>
<div className="section-subtitle m-0">
Recent audit activity
</div>
<section className="management-override-panel management-audit-panel">
<div className="management-report-item">
<span className="management-report-meta">Window</span>
<span>Last {SKILL_AUDIT_LOG_LIMIT} entries for this skill.</span>
</div>
{auditLogs.length === 0 ? (
<div className="section-subtitle m-0">No audit activity yet.</div>
<div className="section-subtitle m-0">
No audit activity yet.
</div>
) : (
<div className="management-audit-list">
{auditLogs.map((entry) => {
@@ -607,7 +590,10 @@ function Management() {
</div>
<div className="management-actions management-action-grid">
<Button asChild className="management-action-btn">
<Link to="/$owner/$slug" params={{ owner: ownerParam, slug: skill.slug }}>
<Link
to="/$owner/$slug"
params={{ owner: ownerParam, slug: skill.slug }}
>
View
</Link>
</Button>
@@ -707,115 +693,9 @@ function Management() {
</Card>
<Card className="mt-5">
<h2 className="section-title text-[1.2rem] m-0">Plugin tools</h2>
<div className="management-controls">
<div className="management-control management-search">
<span className="mono">Package</span>
<input
type="search"
placeholder="@scope/plugin-name or package-name"
value={pluginSearch}
onChange={(event) => setPluginSearch(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
managePlugin();
}
}}
/>
</div>
<Button type="button" onClick={managePlugin} disabled={!pluginSearch.trim()}>
Manage
</Button>
</div>
{selectedPluginName ? (
<div className="section-subtitle mt-2">
Managing "{selectedPluginName}" ·{" "}
<Link to="/management" search={{ skill: undefined, plugin: undefined }}>
Clear selection
</Link>
</div>
) : null}
<div className="management-list">
{!selectedPluginName ? (
<div className="stat">Enter a plugin package name to open tooling here.</div>
) : selectedPlugin === undefined ? (
<div className="stat">Loading plugin</div>
) : !selectedPlugin?.package ? (
<div className="stat">No plugin found for "{selectedPluginName}".</div>
) : (
(() => {
const plugin = selectedPlugin.package;
const owner = selectedPlugin.owner;
const latestRelease = selectedPlugin.latestRelease;
const isHighlighted = Boolean(selectedPlugin.highlighted);
return (
<div key={plugin._id} className="management-item management-item-detail">
<div className="management-item-main">
<Link to="/plugins/$name" params={{ name: plugin.name }}>
{plugin.displayName}
</Link>
<div className="section-subtitle m-0">
{owner?.handle ? `@${owner.handle}` : "unknown owner"} ·{" "}
{familyLabel(plugin.family)} · v{latestRelease?.version ?? "—"} · updated{" "}
{formatTimestamp(plugin.updatedAt)}
{plugin.softDeletedAt ? " · hidden" : ""}
{isHighlighted ? " · highlighted" : ""}
</div>
<div className="management-tags">
<Badge>{plugin.channel}</Badge>
{plugin.isOfficial ? <Badge>official</Badge> : null}
{plugin.executesCode ? <Badge>executes code</Badge> : null}
{plugin.runtimeId ? <Badge>{plugin.runtimeId}</Badge> : null}
</div>
<div className="management-sublist">
<div className="management-report-item">
<span className="management-report-meta">Package name</span>
<span className="mono">{plugin.name}</span>
</div>
<div className="management-report-item">
<span className="management-report-meta">Summary</span>
<span>{plugin.summary ?? "No summary provided."}</span>
</div>
<div className="management-report-item">
<span className="management-report-meta">Featured state</span>
<span>
{isHighlighted
? `Highlighted ${formatTimestamp(selectedPlugin.highlighted?.at ?? 0)}`
: "Not highlighted"}
</span>
</div>
</div>
</div>
<div className="management-actions management-action-grid">
<Button asChild className="management-action-btn">
<Link to="/plugins/$name" params={{ name: plugin.name }}>
View
</Link>
</Button>
<Button
className="management-action-btn"
type="button"
onClick={() =>
void setPackageBatch({
packageId: plugin._id,
batch: isHighlighted ? undefined : "highlighted",
}).catch((error) => window.alert(formatMutationError(error)))
}
>
{isHighlighted ? "Unhighlight" : "Highlight"}
</Button>
</div>
</div>
);
})()
)}
</div>
</Card>
<Card className="mt-5">
<h2 className="section-title text-[1.2rem] m-0">Duplicate candidates</h2>
<h2 className="section-title text-[1.2rem] m-0">
Duplicate candidates
</h2>
<div className="management-list">
{duplicateCandidates.length === 0 ? (
<div className="stat">No duplicate candidates.</div>
@@ -904,7 +784,9 @@ function Management() {
</Card>
<Card className="mt-5">
<h2 className="section-title text-[1.2rem] m-0">Recent pushes</h2>
<h2 className="section-title text-[1.2rem] m-0">
Recent pushes
</h2>
<div className="management-list">
{recentVersions.length === 0 ? (
<div className="stat">No recent versions.</div>
@@ -950,7 +832,9 @@ function Management() {
{admin ? (
<Card className="mt-5">
<h2 className="section-title text-[1.2rem] m-0">Users</h2>
<h2 className="section-title text-[1.2rem] m-0">
Users
</h2>
<div className="management-controls">
<div className="management-control management-search">
<span className="mono">Filter</span>
+186 -185
View File
@@ -400,194 +400,195 @@ function PluginDetailRoute() {
) : null}
{/* Capabilities */}
{capEntries.length > 0 ? (
<Card>
<CardHeader className="gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardTitle>Capabilities</CardTitle>
<InstallCopyButton
text={JSON.stringify(capabilities, null, 2)}
ariaLabel="Copy capabilities JSON"
/>
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{capEntries.map(([key, value]) => (
<div
key={key}
className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0"
>
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
{CAPABILITY_LABELS[key] ?? key}
</dt>
<dd className="min-w-0 break-words text-[color:var(--ink)]">
{key === "capabilityTags" && Array.isArray(value) ? (
<div className="flex flex-wrap gap-1.5">
{(value as string[]).map((tag) => (
<Link key={tag} to="/plugins" search={{ q: tag }}>
<Badge variant="compact">{tag}</Badge>
</Link>
))}
</div>
) : key === "hostTargets" && Array.isArray(value) ? (
<div className="flex flex-wrap gap-1.5">
{(value as string[]).map((target) => (
<Badge key={target} variant="compact">
{target}
</Badge>
))}
</div>
) : (
formatCapabilityValue(value)
)}
</dd>
</div>
))}
</dl>
</CardContent>
</Card>
) : null}
{capEntries.length > 0 ? (
<Card>
<CardHeader className="gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardTitle>Capabilities</CardTitle>
<InstallCopyButton
text={JSON.stringify(capabilities, null, 2)}
ariaLabel="Copy capabilities JSON"
/>
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{capEntries.map(([key, value]) => (
<div
key={key}
className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0"
>
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
{CAPABILITY_LABELS[key] ?? key}
</dt>
<dd className="min-w-0 break-words text-[color:var(--ink)]">
{key === "capabilityTags" && Array.isArray(value) ? (
<div className="flex flex-wrap gap-1.5">
{(value as string[]).map((tag) => (
<Link key={tag} to="/plugins" search={{ q: tag }}>
<Badge variant="compact">{tag}</Badge>
</Link>
))}
</div>
) : key === "hostTargets" && Array.isArray(value) ? (
<div className="flex flex-wrap gap-1.5">
{(value as string[]).map((target) => (
<Badge key={target} variant="compact">
{target}
</Badge>
))}
</div>
) : (
formatCapabilityValue(value)
)}
</dd>
</div>
))}
</dl>
</CardContent>
</Card>
) : null}
{/* Compatibility */}
{compatEntries.length > 0 ? (
<Card>
<CardHeader className="gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardTitle>Compatibility</CardTitle>
<InstallCopyButton
text={JSON.stringify(compatibility, null, 2)}
ariaLabel="Copy compatibility JSON"
/>
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{compatEntries.map(([key, value]) => (
<div
key={key}
className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0"
>
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
{key.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase())}
</dt>
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
{value}
</dd>
</div>
))}
</dl>
</CardContent>
</Card>
) : null}
{/* Compatibility */}
{compatEntries.length > 0 ? (
<Card>
<CardHeader className="gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardTitle>Compatibility</CardTitle>
<InstallCopyButton
text={JSON.stringify(compatibility, null, 2)}
ariaLabel="Copy compatibility JSON"
/>
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{compatEntries.map(([key, value]) => (
<div
key={key}
className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0"
>
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
{key.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase())}
</dt>
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
{value}
</dd>
</div>
))}
</dl>
</CardContent>
</Card>
) : null}
{/* Verification */}
{verification && !isEmptyObject(verification) ? (
<Card>
<CardHeader>
<CardTitle>Verification</CardTitle>
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{verification.tier ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Tier</dt>
<dd className="text-[color:var(--ink)]">
{verification.tier.replace(/-/g, " ")}
</dd>
</div>
) : null}
{verification.scope ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Scope</dt>
<dd className="text-[color:var(--ink)]">
{verification.scope.replace(/-/g, " ")}
</dd>
</div>
) : null}
{verification.summary ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Summary</dt>
<dd className="text-[color:var(--ink)]">{verification.summary}</dd>
</div>
) : null}
{verification.sourceRepo
? (() => {
const raw = verification.sourceRepo;
const href = /^https?:\/\//.test(raw) ? raw : `https://github.com/${raw}`;
const display = href.replace(/^https?:\/\//, "");
return (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Source</dt>
<dd className="text-[color:var(--ink)]">
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex max-w-full flex-wrap items-center gap-1 break-all text-[color:var(--accent)] hover:underline"
>
{display}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
</dd>
</div>
);
})()
: null}
{verification.sourceCommit ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Commit</dt>
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
{verification.sourceCommit.slice(0, 12)}
</dd>
</div>
) : null}
{verification.sourceTag ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Tag</dt>
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
{verification.sourceTag}
</dd>
</div>
) : null}
{verification.hasProvenance !== undefined ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Provenance</dt>
<dd className="text-[color:var(--ink)]">
{verification.hasProvenance ? "Yes" : "No"}
</dd>
</div>
) : null}
{verification.scanStatus ? (
<div className="flex flex-col gap-1.5 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Scan status</dt>
<dd className="text-[color:var(--ink)]">{verification.scanStatus}</dd>
</div>
) : null}
</dl>
</CardContent>
</Card>
) : null}
{/* Verification */}
{verification && !isEmptyObject(verification) ? (
<Card>
<CardHeader>
<CardTitle>Verification</CardTitle>
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{verification.tier ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Tier</dt>
<dd className="text-[color:var(--ink)]">
{verification.tier.replace(/-/g, " ")}
</dd>
</div>
) : null}
{verification.scope ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Scope</dt>
<dd className="text-[color:var(--ink)]">
{verification.scope.replace(/-/g, " ")}
</dd>
</div>
) : null}
{verification.summary ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Summary</dt>
<dd className="text-[color:var(--ink)]">{verification.summary}</dd>
</div>
) : null}
{verification.sourceRepo
? (() => {
const raw = verification.sourceRepo;
const href = /^https?:\/\//.test(raw) ? raw : `https://github.com/${raw}`;
const display = href.replace(/^https?:\/\//, "");
return (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Source</dt>
<dd className="text-[color:var(--ink)]">
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex max-w-full flex-wrap items-center gap-1 break-all text-[color:var(--accent)] hover:underline"
>
{display}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
</dd>
</div>
);
})()
: null}
{verification.sourceCommit ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Commit</dt>
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
{verification.sourceCommit.slice(0, 12)}
</dd>
</div>
) : null}
{verification.sourceTag ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Tag</dt>
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
{verification.sourceTag}
</dd>
</div>
) : null}
{verification.hasProvenance !== undefined ? (
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Provenance</dt>
<dd className="text-[color:var(--ink)]">
{verification.hasProvenance ? "Yes" : "No"}
</dd>
</div>
) : null}
{verification.scanStatus ? (
<div className="flex flex-col gap-1.5 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
<dt className="font-semibold text-[color:var(--ink-soft)]">Scan status</dt>
<dd className="text-[color:var(--ink)]">{verification.scanStatus}</dd>
</div>
) : null}
</dl>
</CardContent>
</Card>
) : null}
{/* Tags */}
{pkg.tags && Object.keys(pkg.tags).length > 0 ? (
<Card>
<CardHeader>
<CardTitle>Tags</CardTitle>
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{Object.entries(pkg.tags).map(([key, value]) => (
<div
key={key}
className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0"
>
<dt className="font-semibold text-[color:var(--ink-soft)]">{key}</dt>
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
{value}
</dd>
</div>
))}
</dl>
</CardContent>
</Card>
) : null}
{/* Tags */}
{pkg.tags && Object.keys(pkg.tags).length > 0 ? (
<Card>
<CardHeader>
<CardTitle>Tags</CardTitle>
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3 text-sm">
{Object.entries(pkg.tags).map(([key, value]) => (
<div
key={key}
className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0"
>
<dt className="font-semibold text-[color:var(--ink-soft)]">{key}</dt>
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
{value}
</dd>
</div>
))}
</dl>
</CardContent>
</Card>
) : null}
</DetailHero>
</DetailPageShell>
</main>
+11 -26
View File
@@ -14,7 +14,6 @@ type PluginSearchState = {
q?: string;
cursor?: string;
family?: "code-plugin" | "bundle-plugin";
featured?: boolean;
verified?: boolean;
executesCode?: boolean;
};
@@ -44,16 +43,14 @@ export const Route = createFileRoute("/plugins/")({
search.family === "code-plugin" || search.family === "bundle-plugin"
? search.family
: undefined,
featured:
search.featured === true || search.featured === "true" || search.featured === "1"
? true
: undefined,
verified:
search.verified === true || search.verified === "true" || search.verified === "1"
? true
: undefined,
executesCode:
search.executesCode === true || search.executesCode === "true" || search.executesCode === "1"
search.executesCode === true ||
search.executesCode === "true" ||
search.executesCode === "1"
? true
: undefined,
}),
@@ -64,7 +61,6 @@ export const Route = createFileRoute("/plugins/")({
q: deps.q,
cursor: deps.q ? undefined : deps.cursor,
family: deps.family,
featured: deps.featured,
isOfficial: deps.verified,
executesCode: deps.executesCode,
limit: 50,
@@ -104,14 +100,14 @@ function PluginsIndex() {
const search = Route.useSearch();
const navigate = Route.useNavigate();
const loaderData = Route.useLoaderData() as PluginsLoaderData | undefined;
// Defensive handling for when loader data is unavailable (SSR errors, etc.)
const items = loaderData?.items ?? [];
const nextCursor = loaderData?.nextCursor ?? null;
const rateLimited = loaderData?.rateLimited ?? false;
const retryAfterSeconds = loaderData?.retryAfterSeconds ?? null;
const apiError = loaderData?.apiError ?? !loaderData;
const [query, setQuery] = useState(search.q ?? "");
const [sidebarOpen, setSidebarOpen] = useState(false);
@@ -140,24 +136,12 @@ function PluginsIndex() {
};
const handleFamilySort = (value: string) => {
if (value === "featured") {
void navigate({
search: (prev) => ({
...prev,
cursor: undefined,
featured: true,
family: undefined,
}),
});
return;
}
const family = value === "code-plugin" || value === "bundle-plugin" ? value : undefined;
const family =
value === "code-plugin" || value === "bundle-plugin" ? value : undefined;
void navigate({
search: (prev) => ({
...prev,
cursor: undefined,
featured: undefined,
family: family as "code-plugin" | "bundle-plugin" | undefined,
}),
});
@@ -216,12 +200,11 @@ function PluginsIndex() {
<div className={`browse-layout${sidebarOpen ? " sidebar-open" : ""}`}>
<BrowseSidebar
sortOptions={[
{ value: "featured", label: "Featured" },
{ value: "all", label: "All types" },
{ value: "code-plugin", label: "Code plugins" },
{ value: "bundle-plugin", label: "Bundle plugins" },
]}
activeSort={search.featured ? "featured" : (search.family ?? "all")}
activeSort={search.family ?? "all"}
onSortChange={handleFamilySort}
filters={[
{ key: "verified", label: "Verified only", active: search.verified ?? false },
@@ -248,7 +231,9 @@ function PluginsIndex() {
<div className="empty-state">
<AlertTriangle size={20} aria-hidden="true" />
<p className="empty-state-title">Plugin catalog is temporarily unavailable</p>
<p className="empty-state-body">Try again {formatRetryDelay(retryAfterSeconds)}.</p>
<p className="empty-state-body">
Try again {formatRetryDelay(retryAfterSeconds)}.
</p>
</div>
) : items.length === 0 ? (
<div className="empty-state">
+12 -15
View File
@@ -15,7 +15,6 @@ export type SkillsSearchState = {
sort?: SortKey;
dir?: SortDir;
highlighted?: boolean;
featured?: boolean;
nonSuspicious?: boolean;
tag?: string;
view?: SkillsView;
@@ -58,7 +57,7 @@ export function useSkillsBrowseModel({
const navigateTimer = useRef<number>(0);
const view: SkillsView = search.view ?? "list";
const featuredOnly = search.featured ?? search.highlighted ?? false;
const highlightedOnly = search.highlighted ?? false;
const nonSuspiciousOnly = search.nonSuspicious ?? false;
const capabilityTag = search.tag;
const searchSkills = useAction(api.search.searchSkills);
@@ -73,7 +72,7 @@ export function useSkillsBrowseModel({
const listSort = toListSort(sort);
const dir = parseDir(search.dir, sort);
const searchKey = trimmedQuery
? `${trimmedQuery}::${featuredOnly ? "1" : "0"}::${nonSuspiciousOnly ? "1" : "0"}::${capabilityTag ?? ""}`
? `${trimmedQuery}::${highlightedOnly ? "1" : "0"}::${nonSuspiciousOnly ? "1" : "0"}::${capabilityTag ?? ""}`
: "";
// One-shot paginated fetches (no reactive subscription)
@@ -90,7 +89,7 @@ export function useSkillsBrowseModel({
numItems: pageSize,
sort: listSort,
dir,
highlightedOnly: featuredOnly,
highlightedOnly,
nonSuspiciousOnly,
capabilityTag,
});
@@ -106,7 +105,7 @@ export function useSkillsBrowseModel({
setListStatus(cursor ? "idle" : "done");
}
},
[capabilityTag, dir, featuredOnly, listSort, nonSuspiciousOnly],
[capabilityTag, dir, highlightedOnly, listSort, nonSuspiciousOnly],
);
// Reset and fetch first page when sort/dir/filters change
@@ -156,7 +155,7 @@ export function useSkillsBrowseModel({
try {
const data = (await searchSkills({
query: trimmedQuery,
highlightedOnly: featuredOnly,
highlightedOnly,
nonSuspiciousOnly,
capabilityTag,
limit: searchLimit,
@@ -175,7 +174,7 @@ export function useSkillsBrowseModel({
}, [
capabilityTag,
hasQuery,
featuredOnly,
highlightedOnly,
nonSuspiciousOnly,
searchLimit,
searchSkills,
@@ -198,8 +197,7 @@ export function useSkillsBrowseModel({
const sorted = useMemo(() => {
if (isOtherCategory) {
return baseItems.filter((entry) => {
const text =
`${entry.skill.displayName} ${entry.skill.summary ?? ""} ${entry.skill.slug}`.toLowerCase();
const text = `${entry.skill.displayName} ${entry.skill.summary ?? ""} ${entry.skill.slug}`.toLowerCase();
return !ALL_CATEGORY_KEYWORDS.some((kw) => text.includes(kw));
});
}
@@ -321,12 +319,11 @@ export function useSkillsBrowseModel({
[navigate],
);
const onToggleFeatured = useCallback(() => {
const onToggleHighlighted = useCallback(() => {
void navigate({
search: (prev) => ({
...prev,
featured: prev.featured || prev.highlighted ? undefined : true,
highlighted: undefined,
highlighted: prev.highlighted ? undefined : true,
}),
replace: true,
});
@@ -378,7 +375,7 @@ export function useSkillsBrowseModel({
}, [navigate]);
const activeFilters: string[] = [];
if (featuredOnly) activeFilters.push("featured");
if (highlightedOnly) activeFilters.push("highlighted");
if (nonSuspiciousOnly) activeFilters.push("non-suspicious");
if (capabilityTag) activeFilters.push(SKILL_CAPABILITY_LABELS[capabilityTag] ?? capabilityTag);
@@ -402,7 +399,7 @@ export function useSkillsBrowseModel({
canLoadMore,
dir,
hasQuery,
featuredOnly,
highlightedOnly,
isLoadingMore,
isLoadingSkills,
loadMore,
@@ -412,7 +409,7 @@ export function useSkillsBrowseModel({
onQueryChange,
onSortChange,
onToggleDir,
onToggleFeatured,
onToggleHighlighted,
onToggleNonSuspicious,
onToggleView,
query,
+30 -60
View File
@@ -6,7 +6,7 @@ import { api } from "../../../convex/_generated/api";
import { BrowseSidebar } from "../../components/BrowseSidebar";
import { SKILL_CATEGORIES } from "../../lib/categories";
import { formatCompactStat } from "../../lib/numberFormat";
import { parseDir, parseSort } from "./-params";
import { parseSort } from "./-params";
import { SkillsResults } from "./-SkillsResults";
import { useSkillsBrowseModel, type SkillsSearchState } from "./-useSkillsBrowseModel";
@@ -29,10 +29,6 @@ export const Route = createFileRoute("/skills/")({
search.highlighted === "1" || search.highlighted === "true" || search.highlighted === true
? true
: undefined,
featured:
search.featured === "1" || search.featured === "true" || search.featured === true
? true
: undefined,
nonSuspicious:
search.nonSuspicious === "1" ||
search.nonSuspicious === "true" ||
@@ -45,9 +41,7 @@ export const Route = createFileRoute("/skills/")({
},
beforeLoad: ({ search }) => {
const hasQuery = Boolean(search.q?.trim());
if (hasQuery || search.sort || search.featured || search.highlighted || search.nonSuspicious) {
return;
}
if (hasQuery || search.sort) return;
throw redirect({
to: "/skills",
search: {
@@ -55,7 +49,6 @@ export const Route = createFileRoute("/skills/")({
sort: "downloads",
dir: search.dir || undefined,
highlighted: search.highlighted || undefined,
featured: search.featured || undefined,
nonSuspicious: search.nonSuspicious || undefined,
view: search.view || undefined,
focus: search.focus || undefined,
@@ -71,7 +64,8 @@ export function SkillsIndex() {
const search = Route.useSearch();
const searchInputRef = useRef<HTMLInputElement>(null);
const totalSkills = useQuery(api.skills.countPublicSkills);
const totalSkillsText = typeof totalSkills === "number" ? formatCompactStat(totalSkills) : null;
const totalSkillsText =
typeof totalSkills === "number" ? formatCompactStat(totalSkills) : null;
const [sidebarOpen, setSidebarOpen] = useState(false);
const model = useSkillsBrowseModel({
@@ -86,50 +80,12 @@ export function SkillsIndex() {
const handleFilterToggle = useCallback(
(key: string) => {
if (key === "nonSuspicious") model.onToggleNonSuspicious();
if (key === "highlighted") model.onToggleHighlighted();
else if (key === "nonSuspicious") model.onToggleNonSuspicious();
},
[model.onToggleNonSuspicious],
[model.onToggleHighlighted, model.onToggleNonSuspicious],
);
const handleSortChange = useCallback(
(value: string) => {
if (value === "featured") {
if (!model.featuredOnly) model.onToggleFeatured();
return;
}
if (model.featuredOnly) {
const nextSort = parseSort(value);
void navigate({
search: (prev) => ({
...prev,
sort: nextSort,
dir: parseDir(prev.dir, nextSort),
featured: undefined,
highlighted: undefined,
}),
replace: true,
});
return;
}
model.onSortChange(value);
},
[model.featuredOnly, model.onSortChange, model.onToggleFeatured, navigate],
);
const handleClear = useCallback(() => {
model.onQueryChange("");
if (model.featuredOnly) model.onToggleFeatured();
if (model.nonSuspiciousOnly) model.onToggleNonSuspicious();
}, [
model.featuredOnly,
model.onQueryChange,
model.onToggleFeatured,
model.onToggleNonSuspicious,
model.nonSuspiciousOnly,
]);
const handleCategoryChange = useCallback(
(slug: string | undefined) => {
if (slug) {
@@ -147,8 +103,9 @@ export function SkillsIndex() {
const activeCategory = useMemo(() => {
if (!model.query) return undefined;
return (
SKILL_CATEGORIES.find((c) => c.keywords.some((k) => k === model.query.trim().toLowerCase()))
?.slug ?? undefined
SKILL_CATEGORIES.find((c) =>
c.keywords.some((k) => k === model.query.trim().toLowerCase()),
)?.slug ?? undefined
);
}, [model.query]);
@@ -165,7 +122,9 @@ export function SkillsIndex() {
</button>
<h1 className="browse-title">
Skills
{totalSkillsText ? <span className="browse-count">{totalSkillsText}</span> : null}
{totalSkillsText ? (
<span className="browse-count">{totalSkillsText}</span>
) : null}
</h1>
</div>
<div className="browse-page-search">
@@ -183,10 +142,11 @@ export function SkillsIndex() {
categories={SKILL_CATEGORIES}
activeCategory={activeCategory}
onCategoryChange={handleCategoryChange}
sortOptions={[{ value: "featured", label: "Featured" }, ...sortOptionsWithRelevance]}
activeSort={model.featuredOnly ? "featured" : model.sort}
onSortChange={handleSortChange}
sortOptions={sortOptionsWithRelevance}
activeSort={model.sort}
onSortChange={model.onSortChange}
filters={[
{ key: "highlighted", label: "Staff picks", active: model.highlightedOnly },
{ key: "nonSuspicious", label: "Hide suspicious", active: model.nonSuspiciousOnly },
]}
onFilterToggle={handleFilterToggle}
@@ -194,9 +154,19 @@ export function SkillsIndex() {
<div className="browse-results">
<div className="browse-results-toolbar">
<span className="browse-results-count">
{model.isLoadingSkills ? "\u2014" : `${model.sorted.length} results`}
{model.hasQuery || model.featuredOnly || model.nonSuspiciousOnly ? (
<button className="browse-clear-btn" type="button" onClick={handleClear}>
{model.isLoadingSkills
? "\u2014"
: `${model.sorted.length} results`}
{(model.hasQuery || model.highlightedOnly || model.nonSuspiciousOnly) ? (
<button
className="browse-clear-btn"
type="button"
onClick={() => {
model.onQueryChange("");
if (model.highlightedOnly) model.onToggleHighlighted();
if (model.nonSuspiciousOnly) model.onToggleNonSuspicious();
}}
>
Clear
</button>
) : null}