Merge pull request #1861 from openclaw/pe/rescan

feat: add owner rescan security surfaces
This commit is contained in:
Patrick Erichsen
2026-04-28 16:32:26 -07:00
committed by GitHub
71 changed files with 7033 additions and 2224 deletions
+8
View File
@@ -95,10 +95,14 @@ import type * as lib_userSkillStats from "../lib/userSkillStats.js";
import type * as lib_webhooks from "../lib/webhooks.js";
import type * as llmEval from "../llmEval.js";
import type * as maintenance from "../maintenance.js";
import type * as model_packages_rescans from "../model/packages/rescans.js";
import type * as model_rescans_policy from "../model/rescans/policy.js";
import type * as model_skills_rescans from "../model/skills/rescans.js";
import type * as packagePublishTokens from "../packagePublishTokens.js";
import type * as packages from "../packages.js";
import type * as publishers from "../publishers.js";
import type * as rateLimits from "../rateLimits.js";
import type * as rescanRequests from "../rescanRequests.js";
import type * as search from "../search.js";
import type * as seed from "../seed.js";
import type * as seedSouls from "../seedSouls.js";
@@ -212,10 +216,14 @@ declare const fullApi: ApiFromModules<{
"lib/webhooks": typeof lib_webhooks;
llmEval: typeof llmEval;
maintenance: typeof maintenance;
"model/packages/rescans": typeof model_packages_rescans;
"model/rescans/policy": typeof model_rescans_policy;
"model/skills/rescans": typeof model_skills_rescans;
packagePublishTokens: typeof packagePublishTokens;
packages: typeof packages;
publishers: typeof publishers;
rateLimits: typeof rateLimits;
rescanRequests: typeof rescanRequests;
search: typeof search;
seed: typeof seed;
seedSouls: typeof seedSouls;
+139
View File
@@ -0,0 +1,139 @@
import { describe, expect, it } from "vitest";
import { seedRescanUxFixturesHandler } from "./devSeed";
import { MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE } from "./model/rescans/policy";
function chainEq(constraints: Record<string, unknown>) {
return {
eq(field: string, value: unknown) {
constraints[field] = value;
return chainEq(constraints);
},
};
}
function matches(doc: Record<string, unknown>, constraints: Record<string, unknown>) {
return Object.entries(constraints).every(([key, value]) => doc[key] === value);
}
function createDb() {
const tables: Record<string, Array<Record<string, unknown> & { _id: string }>> = {};
const counters: Record<string, number> = {};
const list = (table: string) => {
tables[table] ??= [];
return tables[table];
};
const db = {
get: async (id: string) => {
const table = id.split(":")[0] ?? "";
return list(table).find((doc) => doc._id === id) ?? null;
},
insert: async (table: string, doc: Record<string, unknown>) => {
counters[table] = (counters[table] ?? 0) + 1;
const inserted = {
_id: `${table}:${counters[table]}`,
_creationTime: counters[table],
...doc,
};
list(table).push(inserted);
return inserted._id;
},
patch: async (id: string, patch: Record<string, unknown>) => {
const table = id.split(":")[0] ?? "";
const doc = list(table).find((candidate) => candidate._id === id);
if (doc) Object.assign(doc, patch);
},
delete: async (id: string) => {
const table = id.split(":")[0] ?? "";
const rows = list(table);
const index = rows.findIndex((doc) => doc._id === id);
if (index !== -1) rows.splice(index, 1);
},
query: (table: string) => ({
withIndex: (_name: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
const constraints: Record<string, unknown> = {};
build(chainEq(constraints));
const matched = () =>
list(table).filter((doc) => matches(doc as Record<string, unknown>, constraints));
return {
collect: async () => matched(),
unique: async () => matched()[0] ?? null,
order: () => ({
collect: async () => matched(),
}),
};
},
}),
};
return { db, tables };
}
describe("devSeed rescan UX fixtures", () => {
it("seeds flagged local owner inventory and deterministic rescan counts idempotently", async () => {
const { db, tables } = createDb();
const args = {
flaggedSkillStorageId: "storage:skill",
flaggedSkillMd: "# Flagged skill",
flaggedPluginStorageId: "storage:plugin",
flaggedPluginReadme: "# Flagged plugin",
scannedPluginStorageId: "storage:scanned-plugin",
scannedPluginReadme: "# Scanned plugin",
};
await seedRescanUxFixturesHandler({ db } as never, args as never);
await seedRescanUxFixturesHandler({ db } as never, args as never);
await seedRescanUxFixturesHandler({ db } as never, { ...args, reset: true } as never);
expect(tables.users).toHaveLength(1);
expect(tables.users?.[0]).toEqual(expect.objectContaining({ handle: "local" }));
expect(tables.publishers).toHaveLength(1);
expect(tables.skills).toHaveLength(1);
expect(tables.skills?.[0]).toEqual(
expect.objectContaining({
ownerUserId: tables.users?.[0]?._id,
ownerPublisherId: tables.publishers?.[0]?._id,
moderationStatus: "hidden",
moderationVerdict: "malicious",
}),
);
expect(tables.packages).toHaveLength(2);
expect(tables.packages?.find((pkg) => pkg.name === "local-flagged-runtime-plugin")).toEqual(
expect.objectContaining({
ownerUserId: tables.users?.[0]?._id,
ownerPublisherId: tables.publishers?.[0]?._id,
scanStatus: "malicious",
}),
);
expect(tables.packages?.find((pkg) => pkg.name === "local-scanned-runtime-plugin")).toEqual(
expect.objectContaining({
ownerUserId: tables.users?.[0]?._id,
ownerPublisherId: tables.publishers?.[0]?._id,
scanStatus: "suspicious",
}),
);
const scannedPackage = tables.packages?.find(
(pkg) => pkg.name === "local-scanned-runtime-plugin",
);
const scannedRelease = tables.packageReleases?.find(
(release) => release.packageId === scannedPackage?._id,
);
expect(scannedRelease).toEqual(
expect.objectContaining({
sha256hash: "seeded-scanned-plugin-hash",
vtAnalysis: expect.objectContaining({ status: "clean" }),
llmAnalysis: expect.objectContaining({ status: "suspicious" }),
staticScan: expect.objectContaining({ status: "suspicious" }),
}),
);
const skillRequests =
tables.rescanRequests?.filter((request) => request.targetKind === "skill") ?? [];
const pluginRequests =
tables.rescanRequests?.filter((request) => request.targetKind === "plugin") ?? [];
expect(skillRequests).toHaveLength(1);
expect(pluginRequests).toHaveLength(MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE);
});
});
+778 -21
View File
@@ -1,9 +1,15 @@
import { v } from "convex/values";
import { internal } from "./_generated/api";
import type { ActionCtx } from "./_generated/server";
import type { Id } from "./_generated/dataModel";
import type { ActionCtx, MutationCtx } from "./_generated/server";
import { internalMutation as rawInternalMutation } from "./_generated/server";
import { internalAction, internalMutation } from "./functions";
import { EMBEDDING_DIMENSIONS } from "./lib/embeddings";
import { normalizePackageName } from "./lib/packageRegistry";
import { ensurePersonalPublisherForUser } from "./lib/publishers";
import { parseClawdisMetadata, parseFrontmatter } from "./lib/skills";
import { generateToken, hashToken } from "./lib/tokens";
import { MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE } from "./model/rescans/policy";
type SeedSkillSpec = {
slug: string;
@@ -25,6 +31,37 @@ type SeedActionResult = {
type SeedMutationResult = Record<string, unknown>;
const LOCAL_SEED_HANDLE = "local";
const FLAGGED_SKILL_SLUG = "local-flagged-wallet-sync";
const FLAGGED_PLUGIN_NAME = "local-flagged-runtime-plugin";
const SCANNED_PLUGIN_NAME = "local-scanned-runtime-plugin";
const FLAGGED_SKILL_MD = `---
name: local-flagged-wallet-sync
description: Local dev fixture for flagged dashboard and rescan UI.
---
# Local Flagged Wallet Sync
This seeded skill is intentionally flagged so local development can exercise owner-only recovery
flows, dashboard unavailable states, and rescan request limits.
`;
const FLAGGED_PLUGIN_README = `# Local Flagged Runtime Plugin
This seeded plugin is intentionally flagged so local development can exercise plugin owner
inventory and cap-exhausted rescan UI.
`;
const SCANNED_PLUGIN_README = `# Local Scanned Runtime Plugin
This seeded plugin is public and intentionally has completed scan results so local development can
preview plugin scanner detail pages without owner-only visibility.
`;
type RoleHelpFixtureUser = {
handle: string;
displayName: string;
role: "admin" | "user";
};
const SEED_SKILLS: SeedSkillSpec[] = [
{
slug: "padel",
@@ -345,6 +382,26 @@ 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 fixtureResult: SeedMutationResult = await ctx.runMutation(
internal.devSeed.seedRescanUxFixturesMutation,
{
reset: args.reset,
flaggedSkillStorageId,
flaggedSkillMd: FLAGGED_SKILL_MD,
flaggedPluginStorageId,
flaggedPluginReadme: FLAGGED_PLUGIN_README,
scannedPluginStorageId,
scannedPluginReadme: SCANNED_PLUGIN_README,
},
);
results.push({ slug: FLAGGED_SKILL_SLUG, ...fixtureResult });
return { ok: true, results };
}
@@ -388,6 +445,724 @@ export const seedPadelSkill: ReturnType<typeof internalAction> = internalAction(
handler: seedPadelSkillHandler,
});
async function ensureLocalSeedOwner(ctx: MutationCtx) {
const now = Date.now();
const existingUsers = await ctx.db
.query("users")
.withIndex("handle", (q) => q.eq("handle", LOCAL_SEED_HANDLE))
.collect();
const userId =
existingUsers[0]?._id ??
(await ctx.db.insert("users", {
handle: LOCAL_SEED_HANDLE,
displayName: "Local Dev",
role: "admin",
createdAt: now,
updatedAt: now,
}));
const user = await ctx.db.get(userId);
if (!user) throw new Error("Local seed user was not created");
const publisher = await ensurePersonalPublisherForUser(ctx, user);
if (!publisher) throw new Error("Local seed publisher was not created");
return { userId, publisherId: publisher._id };
}
async function deleteRescanRequestsForSkillVersion(ctx: MutationCtx, versionId: unknown) {
if (!versionId) return;
const requests = await ctx.db
.query("rescanRequests")
.withIndex("by_skill_version", (q) =>
q.eq("targetKind", "skill").eq("skillVersionId", versionId as never),
)
.collect();
for (const request of requests) await ctx.db.delete(request._id);
}
async function deleteRescanRequestsForPackageRelease(ctx: MutationCtx, releaseId: unknown) {
if (!releaseId) return;
const requests = await ctx.db
.query("rescanRequests")
.withIndex("by_package_release", (q) =>
q.eq("targetKind", "plugin").eq("packageReleaseId", releaseId as never),
)
.collect();
for (const request of requests) await ctx.db.delete(request._id);
}
async function deleteSeedSkillFixture(ctx: MutationCtx) {
const existing = await findSeedSkillFixture(ctx);
if (!existing) return;
const versions = await ctx.db
.query("skillVersions")
.withIndex("by_skill", (q) => q.eq("skillId", existing._id))
.collect();
for (const version of versions) {
await deleteRescanRequestsForSkillVersion(ctx, version._id);
await ctx.db.delete(version._id);
}
const embeddings = await ctx.db
.query("skillEmbeddings")
.withIndex("by_skill", (q) => q.eq("skillId", existing._id))
.collect();
for (const embedding of embeddings) {
const maps = await ctx.db
.query("embeddingSkillMap")
.withIndex("by_embedding", (q) => q.eq("embeddingId", embedding._id))
.collect();
for (const map of maps) await ctx.db.delete(map._id);
await ctx.db.delete(embedding._id);
}
await ctx.db.delete(existing._id);
}
async function findSeedSkillFixture(ctx: MutationCtx) {
return await ctx.db
.query("skills")
.withIndex("by_slug", (q) => q.eq("slug", FLAGGED_SKILL_SLUG))
.unique();
}
async function deleteSeedPluginFixtureByName(ctx: MutationCtx, name: string) {
const existing = await findSeedPluginFixtureByName(ctx, name);
if (!existing) return;
const releases = await ctx.db
.query("packageReleases")
.withIndex("by_package", (q) => q.eq("packageId", existing._id))
.collect();
for (const release of releases) {
await deleteRescanRequestsForPackageRelease(ctx, release._id);
await ctx.db.delete(release._id);
}
await ctx.db.delete(existing._id);
}
async function deleteSeedPluginFixture(ctx: MutationCtx) {
await deleteSeedPluginFixtureByName(ctx, FLAGGED_PLUGIN_NAME);
}
async function deleteScannedPluginFixture(ctx: MutationCtx) {
await deleteSeedPluginFixtureByName(ctx, SCANNED_PLUGIN_NAME);
}
async function findSeedPluginFixtureByName(ctx: MutationCtx, name: string) {
return await ctx.db
.query("packages")
.withIndex("by_name", (q) => q.eq("normalizedName", normalizePackageName(name)))
.unique();
}
async function findSeedPluginFixture(ctx: MutationCtx) {
return await findSeedPluginFixtureByName(ctx, FLAGGED_PLUGIN_NAME);
}
async function findScannedPluginFixture(ctx: MutationCtx) {
return await findSeedPluginFixtureByName(ctx, SCANNED_PLUGIN_NAME);
}
function staticMaliciousScan(now: number) {
return {
status: "malicious" as const,
reasonCodes: ["malicious.local_dev_fixture"],
findings: [
{
code: "malicious.local_dev_fixture",
severity: "critical" as const,
file: "SKILL.md",
line: 1,
message: "Local dev fixture intentionally flagged for owner recovery testing.",
evidence: "seeded fixture",
},
],
summary: "Local dev fixture intentionally flagged as malicious.",
engineVersion: "local-dev-fixture",
checkedAt: now,
};
}
function staticSuspiciousScan(now: number) {
return {
status: "suspicious" as const,
reasonCodes: ["suspicious.local_dev_fixture"],
findings: [
{
code: "suspicious.local_dev_fixture",
severity: "warn" as const,
file: "README.md",
line: 3,
message: "Local dev fixture exercises scanner evidence UI for a public plugin.",
evidence: "runtime plugin requests local tool execution",
},
],
summary: "Local dev fixture completed static analysis with a suspicious finding.",
engineVersion: "local-dev-fixture",
checkedAt: now,
};
}
async function insertCompletedRescanRequests(
ctx: MutationCtx,
params:
| {
targetKind: "skill";
skillId: unknown;
skillVersionId: unknown;
packageId?: never;
packageReleaseId?: never;
targetVersion: string;
ownerUserId: unknown;
ownerPublisherId: unknown;
count: number;
now: number;
}
| {
targetKind: "plugin";
packageId: unknown;
packageReleaseId: unknown;
skillId?: never;
skillVersionId?: never;
targetVersion: string;
ownerUserId: unknown;
ownerPublisherId: unknown;
count: number;
now: number;
},
) {
for (let index = 0; index < params.count; index += 1) {
const createdAt = params.now - (params.count - index) * 60_000;
await ctx.db.insert("rescanRequests", {
targetKind: params.targetKind,
skillId: params.skillId as never,
skillVersionId: params.skillVersionId as never,
packageId: params.packageId as never,
packageReleaseId: params.packageReleaseId as never,
targetVersion: params.targetVersion,
requestedByUserId: params.ownerUserId as never,
ownerUserId: params.ownerUserId as never,
ownerPublisherId: params.ownerPublisherId as never,
status: "completed",
createdAt,
updatedAt: createdAt + 30_000,
completedAt: createdAt + 30_000,
});
}
}
type SeedRescanUxFixturesArgs = {
reset?: boolean;
flaggedSkillStorageId: Id<"_storage">;
flaggedSkillMd: string;
flaggedPluginStorageId: Id<"_storage">;
flaggedPluginReadme: string;
scannedPluginStorageId: Id<"_storage">;
scannedPluginReadme: string;
};
export async function seedRescanUxFixturesHandler(
ctx: MutationCtx,
args: SeedRescanUxFixturesArgs,
) {
const existingSkill = await findSeedSkillFixture(ctx);
const existingPlugin = await findSeedPluginFixture(ctx);
const existingScannedPlugin = await findScannedPluginFixture(ctx);
if (existingSkill && existingPlugin && existingScannedPlugin && !args.reset) {
return {
ok: true,
skipped: true,
ownerUserId: existingSkill.ownerUserId,
ownerPublisherId: existingSkill.ownerPublisherId ?? existingPlugin.ownerPublisherId,
flaggedSkillId: existingSkill._id,
flaggedSkillVersionId: existingSkill.latestVersionId,
flaggedPluginId: existingPlugin._id,
flaggedPluginReleaseId: existingPlugin.latestReleaseId,
scannedPluginId: existingScannedPlugin._id,
scannedPluginReleaseId: existingScannedPlugin.latestReleaseId,
};
}
await deleteSeedSkillFixture(ctx);
await deleteSeedPluginFixture(ctx);
await deleteScannedPluginFixture(ctx);
const now = Date.now();
const { userId, publisherId } = await ensureLocalSeedOwner(ctx);
const staticScan = staticMaliciousScan(now);
const scannedStaticScan = staticSuspiciousScan(now);
const skillId = await ctx.db.insert("skills", {
slug: FLAGGED_SKILL_SLUG,
displayName: "Local Flagged Wallet Sync",
summary: "Seeded flagged skill for local owner inventory and rescan UI testing.",
ownerUserId: userId,
ownerPublisherId: publisherId,
latestVersionId: undefined,
tags: {},
softDeletedAt: undefined,
badges: { redactionApproved: undefined },
moderationStatus: "hidden",
moderationReason: "scanner.static.malicious",
moderationVerdict: "malicious",
moderationReasonCodes: ["malicious.local_dev_fixture"],
moderationEvidence: staticScan.findings,
moderationSummary: staticScan.summary,
moderationEngineVersion: staticScan.engineVersion,
moderationEvaluatedAt: now,
moderationFlags: ["blocked.malware"],
isSuspicious: true,
statsDownloads: 4,
statsStars: 1,
statsInstallsCurrent: 0,
statsInstallsAllTime: 2,
stats: {
downloads: 4,
installsCurrent: 0,
installsAllTime: 2,
stars: 1,
versions: 0,
comments: 0,
},
createdAt: now,
updatedAt: now,
});
const skillVersionId = await ctx.db.insert("skillVersions", {
skillId,
version: "0.1.0",
changelog: "Seeded flagged local version for rescan UI testing.",
files: [
{
path: "SKILL.md",
size: args.flaggedSkillMd.length,
storageId: args.flaggedSkillStorageId,
sha256: "seeded-flagged-skill",
contentType: "text/markdown",
},
],
parsed: {
frontmatter: {
name: FLAGGED_SKILL_SLUG,
description: "Local dev fixture for flagged dashboard and rescan UI.",
},
},
createdBy: userId,
createdAt: now,
softDeletedAt: undefined,
sha256hash: "seeded-flagged-skill-hash",
vtAnalysis: {
status: "malicious",
verdict: "malicious",
analysis: "Local dev fixture intentionally flagged by VirusTotal.",
source: "local-dev-seed",
checkedAt: now,
},
llmAnalysis: {
status: "suspicious",
verdict: "suspicious",
confidence: "high",
summary: "Local dev fixture intentionally flagged by OpenClaw.",
model: "local-dev-seed",
checkedAt: now,
},
staticScan,
});
await ctx.db.patch(skillId, {
latestVersionId: skillVersionId,
moderationSourceVersionId: skillVersionId,
tags: { latest: skillVersionId },
stats: {
downloads: 4,
installsCurrent: 0,
installsAllTime: 2,
stars: 1,
versions: 1,
comments: 0,
},
updatedAt: now,
});
await insertCompletedRescanRequests(ctx, {
targetKind: "skill",
skillId,
skillVersionId,
targetVersion: "0.1.0",
ownerUserId: userId,
ownerPublisherId: publisherId,
count: 1,
now,
});
const packageId = await ctx.db.insert("packages", {
name: FLAGGED_PLUGIN_NAME,
normalizedName: normalizePackageName(FLAGGED_PLUGIN_NAME),
displayName: "Local Flagged Runtime Plugin",
summary: "Seeded flagged plugin for local owner inventory and cap-exhausted rescan UI testing.",
ownerUserId: userId,
ownerPublisherId: publisherId,
family: "code-plugin",
channel: "community",
isOfficial: false,
runtimeId: "local.flagged.runtime",
sourceRepo: "openclaw/local-dev-fixture",
latestReleaseId: undefined,
latestVersionSummary: undefined,
tags: {},
capabilityTags: ["dev-tools"],
executesCode: true,
compatibility: { pluginApiRange: ">=0.1.0" },
capabilities: {
executesCode: true,
runtimeId: "local.flagged.runtime",
pluginKind: "runtime",
capabilityTags: ["dev-tools"],
},
verification: {
tier: "structural",
scope: "artifact-only",
summary: "Local dev fixture intentionally flagged.",
sourceRepo: "openclaw/local-dev-fixture",
scanStatus: "malicious",
},
scanStatus: "malicious",
stats: { downloads: 2, installs: 0, stars: 0, versions: 0 },
softDeletedAt: undefined,
createdAt: now,
updatedAt: now,
});
const packageReleaseId = await ctx.db.insert("packageReleases", {
packageId,
version: "0.1.0",
changelog: "Seeded flagged local release for cap-exhausted rescan UI testing.",
summary: "Seeded flagged plugin release.",
distTags: ["latest"],
files: [
{
path: "README.md",
size: args.flaggedPluginReadme.length,
storageId: args.flaggedPluginStorageId,
sha256: "seeded-flagged-plugin",
contentType: "text/markdown",
},
],
integritySha256: "seeded-flagged-plugin-integrity",
extractedPackageJson: {
name: FLAGGED_PLUGIN_NAME,
version: "0.1.0",
},
compatibility: { pluginApiRange: ">=0.1.0" },
capabilities: {
executesCode: true,
runtimeId: "local.flagged.runtime",
pluginKind: "runtime",
capabilityTags: ["dev-tools"],
},
verification: {
tier: "structural",
scope: "artifact-only",
summary: "Local dev fixture intentionally flagged.",
sourceRepo: "openclaw/local-dev-fixture",
scanStatus: "malicious",
},
sha256hash: "seeded-flagged-plugin-hash",
vtAnalysis: {
status: "malicious",
verdict: "malicious",
analysis: "Local dev fixture intentionally flagged by VirusTotal.",
source: "local-dev-seed",
checkedAt: now,
},
llmAnalysis: {
status: "suspicious",
verdict: "suspicious",
confidence: "high",
summary: "Local dev fixture intentionally flagged by OpenClaw.",
model: "local-dev-seed",
checkedAt: now,
},
staticScan,
source: { kind: "github", repo: "openclaw/local-dev-fixture", path: "." },
createdBy: userId,
publishActor: { kind: "user", userId },
createdAt: now,
softDeletedAt: undefined,
});
await ctx.db.patch(packageId, {
latestReleaseId: packageReleaseId,
latestVersionSummary: {
version: "0.1.0",
createdAt: now,
changelog: "Seeded flagged local release for cap-exhausted rescan UI testing.",
compatibility: { pluginApiRange: ">=0.1.0" },
capabilities: {
executesCode: true,
runtimeId: "local.flagged.runtime",
pluginKind: "runtime",
capabilityTags: ["dev-tools"],
},
verification: {
tier: "structural",
scope: "artifact-only",
summary: "Local dev fixture intentionally flagged.",
sourceRepo: "openclaw/local-dev-fixture",
scanStatus: "malicious",
},
},
tags: { latest: packageReleaseId },
stats: { downloads: 2, installs: 0, stars: 0, versions: 1 },
updatedAt: now,
});
await insertCompletedRescanRequests(ctx, {
targetKind: "plugin",
packageId,
packageReleaseId,
targetVersion: "0.1.0",
ownerUserId: userId,
ownerPublisherId: publisherId,
count: MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE,
now,
});
const scannedPackageId = await ctx.db.insert("packages", {
name: SCANNED_PLUGIN_NAME,
normalizedName: normalizePackageName(SCANNED_PLUGIN_NAME),
displayName: "Local Scanned Runtime Plugin",
summary: "Seeded public plugin with completed security scans for scanner page previews.",
ownerUserId: userId,
ownerPublisherId: publisherId,
family: "code-plugin",
channel: "community",
isOfficial: false,
runtimeId: "local.scanned.runtime",
sourceRepo: "openclaw/local-dev-fixture",
latestReleaseId: undefined,
latestVersionSummary: undefined,
tags: {},
capabilityTags: ["dev-tools", "security"],
executesCode: true,
compatibility: { pluginApiRange: ">=0.1.0" },
capabilities: {
executesCode: true,
runtimeId: "local.scanned.runtime",
pluginKind: "runtime",
capabilityTags: ["dev-tools", "security"],
},
verification: {
tier: "structural",
scope: "artifact-only",
summary: "Local dev fixture completed security scans with reviewable findings.",
sourceRepo: "openclaw/local-dev-fixture",
scanStatus: "suspicious",
},
scanStatus: "suspicious",
stats: { downloads: 7, installs: 1, stars: 1, versions: 0 },
softDeletedAt: undefined,
createdAt: now,
updatedAt: now,
});
const scannedPackageReleaseId = await ctx.db.insert("packageReleases", {
packageId: scannedPackageId,
version: "0.1.0",
changelog: "Seeded public scanned release for plugin scanner page previews.",
summary: "Seeded scanned plugin release.",
distTags: ["latest"],
files: [
{
path: "README.md",
size: args.scannedPluginReadme.length,
storageId: args.scannedPluginStorageId,
sha256: "seeded-scanned-plugin",
contentType: "text/markdown",
},
],
integritySha256: "seeded-scanned-plugin-integrity",
extractedPackageJson: {
name: SCANNED_PLUGIN_NAME,
version: "0.1.0",
},
compatibility: { pluginApiRange: ">=0.1.0" },
capabilities: {
executesCode: true,
runtimeId: "local.scanned.runtime",
pluginKind: "runtime",
capabilityTags: ["dev-tools", "security"],
},
verification: {
tier: "structural",
scope: "artifact-only",
summary: "Local dev fixture completed security scans with reviewable findings.",
sourceRepo: "openclaw/local-dev-fixture",
scanStatus: "suspicious",
},
sha256hash: "seeded-scanned-plugin-hash",
vtAnalysis: {
status: "clean",
verdict: "clean",
analysis: "Local dev fixture scanned clean by VirusTotal.",
source: "local-dev-seed",
checkedAt: now,
},
llmAnalysis: {
status: "suspicious",
verdict: "suspicious",
confidence: "medium",
summary: "Local dev fixture flagged for review because it executes local tools.",
dimensions: [
{
name: "execution",
label: "Local execution",
rating: "concern",
detail: "Runtime plugin executes local tooling and should be reviewed before install.",
},
],
guidance: "Review the runtime command surface before trusting this plugin.",
findings: "The fixture is intentionally safe, but models a plugin with reviewable behavior.",
model: "local-dev-seed",
checkedAt: now,
},
staticScan: scannedStaticScan,
source: { kind: "github", repo: "openclaw/local-dev-fixture", path: "." },
createdBy: userId,
publishActor: { kind: "user", userId },
createdAt: now,
softDeletedAt: undefined,
});
await ctx.db.patch(scannedPackageId, {
latestReleaseId: scannedPackageReleaseId,
latestVersionSummary: {
version: "0.1.0",
createdAt: now,
changelog: "Seeded public scanned release for plugin scanner page previews.",
compatibility: { pluginApiRange: ">=0.1.0" },
capabilities: {
executesCode: true,
runtimeId: "local.scanned.runtime",
pluginKind: "runtime",
capabilityTags: ["dev-tools", "security"],
},
verification: {
tier: "structural",
scope: "artifact-only",
summary: "Local dev fixture completed security scans with reviewable findings.",
sourceRepo: "openclaw/local-dev-fixture",
scanStatus: "suspicious",
},
},
tags: { latest: scannedPackageReleaseId },
stats: { downloads: 7, installs: 1, stars: 1, versions: 1 },
updatedAt: now,
});
await ctx.db.patch(userId, {
publishedSkills: 5,
totalStars: 1,
totalDownloads: 4,
updatedAt: now,
});
return {
ok: true,
ownerUserId: userId,
ownerPublisherId: publisherId,
flaggedSkillId: skillId,
flaggedSkillVersionId: skillVersionId,
flaggedPluginId: packageId,
flaggedPluginReleaseId: packageReleaseId,
scannedPluginId: scannedPackageId,
scannedPluginReleaseId: scannedPackageReleaseId,
};
}
export const seedRescanUxFixturesMutation = internalMutation({
args: {
reset: v.optional(v.boolean()),
flaggedSkillStorageId: v.id("_storage"),
flaggedSkillMd: v.string(),
flaggedPluginStorageId: v.id("_storage"),
flaggedPluginReadme: v.string(),
scannedPluginStorageId: v.id("_storage"),
scannedPluginReadme: v.string(),
},
handler: seedRescanUxFixturesHandler,
});
export const seedCliRoleHelpFixtures = rawInternalMutation({
args: {},
handler: async (ctx) => {
const now = Date.now();
const admin = await upsertRoleHelpFixtureUser(ctx, {
handle: "cli-admin",
displayName: "CLI Admin",
role: "admin",
});
const user = await upsertRoleHelpFixtureUser(ctx, {
handle: "cli-user",
displayName: "CLI User",
role: "user",
});
const adminToken = await replaceRoleHelpFixtureToken(ctx, admin._id, now);
const userToken = await replaceRoleHelpFixtureToken(ctx, user._id, now);
return {
ok: true,
admin: { handle: admin.handle, role: admin.role, token: adminToken },
user: { handle: user.handle, role: user.role, token: userToken },
};
},
});
async function upsertRoleHelpFixtureUser(ctx: MutationCtx, user: RoleHelpFixtureUser) {
const now = Date.now();
const existing = await ctx.db
.query("users")
.withIndex("handle", (q) => q.eq("handle", user.handle))
.unique();
const patch = {
handle: user.handle,
displayName: user.displayName,
role: user.role,
deletedAt: undefined,
deactivatedAt: undefined,
updatedAt: now,
};
if (existing) {
await ctx.db.patch(existing._id, patch);
return { ...existing, ...patch };
}
const userId = await ctx.db.insert("users", {
...patch,
createdAt: now,
});
const created = await ctx.db.get(userId);
if (!created) throw new Error(`Failed to create ${user.handle}`);
return created;
}
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))
.collect();
for (const token of existingTokens) {
if (token.label === "CLI role help e2e") {
await ctx.db.patch(token._id, { revokedAt: now });
}
}
const { token, prefix } = generateToken();
await ctx.db.insert("apiTokens", {
userId,
label: "CLI role help e2e",
prefix,
tokenHash: await hashToken(token),
createdAt: now,
lastUsedAt: undefined,
revokedAt: undefined,
});
return token;
}
export const seedSkillMutation = internalMutation({
args: {
reset: v.optional(v.boolean()),
@@ -430,26 +1205,14 @@ export const seedSkillMutation = internalMutation({
}
const now = Date.now();
const existingUsers = await ctx.db
.query("users")
.withIndex("handle", (q) => q.eq("handle", "local"))
.collect();
const userId =
existingUsers[0]?._id ??
(await ctx.db.insert("users", {
handle: "local",
displayName: "Local Dev",
role: "admin",
createdAt: now,
updatedAt: now,
}));
const { userId, publisherId } = await ensureLocalSeedOwner(ctx);
const skillId = await ctx.db.insert("skills", {
slug: args.slug,
displayName: args.displayName,
summary: args.summary,
ownerUserId: userId,
ownerPublisherId: publisherId,
latestVersionId: undefined,
tags: {},
softDeletedAt: undefined,
@@ -469,12 +1232,6 @@ export const seedSkillMutation = internalMutation({
createdAt: now,
updatedAt: now,
});
await ctx.db.patch(userId, {
publishedSkills: 1,
totalStars: 0,
totalDownloads: 0,
});
const versionId = await ctx.db.insert("skillVersions", {
skillId,
version: args.version,
+81
View File
@@ -2134,6 +2134,7 @@ describe("httpApiV1 handlers", () => {
expect(response.status).toBe(200);
const json = await response.json();
expect(json.user.handle).toBe("p");
expect(json.user.role).toBeNull();
});
it("delete and undelete require auth", async () => {
@@ -2182,6 +2183,86 @@ describe("httpApiV1 handlers", () => {
expect(response2.status).toBe(200);
});
it("skill rescan routes authenticated owners to the rescan mutation", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:1",
user: { handle: "p" },
} as never);
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
if ("key" in args) return okRate();
return {
ok: true,
targetKind: "skill",
name: args.slug,
version: "1.2.3",
status: "in_progress",
remainingRequests: 2,
maxRequests: 3,
pendingRequestId: "rescanRequests:1",
};
});
const response = await __handlers.skillsPostRouterV1Handler(
makeCtx({ runMutation }),
new Request("https://example.com/api/v1/skills/demo/rescan", {
method: "POST",
headers: { Authorization: "Bearer clh_test" },
}),
);
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({
ok: true,
targetKind: "skill",
name: "demo",
remainingRequests: 2,
maxRequests: 3,
});
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ actorUserId: "users:1", slug: "demo" }),
);
});
it("package rescan routes authenticated owners to the rescan mutation", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:1",
user: { handle: "p" },
} as never);
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
if ("key" in args) return okRate();
return {
ok: true,
targetKind: "package",
name: args.name,
version: "1.2.3",
status: "in_progress",
remainingRequests: 2,
maxRequests: 3,
pendingRequestId: "rescanRequests:1",
};
});
const response = await __handlers.packagesPostRouterV1Handler(
makeCtx({ runMutation }),
new Request("https://example.com/api/v1/packages/%40scope%2Fdemo/rescan", {
method: "POST",
headers: { Authorization: "Bearer clh_test" },
}),
);
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({
ok: true,
targetKind: "package",
name: "@scope/demo",
});
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ actorUserId: "users:1", name: "@scope/demo" }),
);
});
it("transfer request requires auth", async () => {
vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error("Unauthorized"));
const runMutation = vi.fn().mockResolvedValue(okRate());
+28 -6
View File
@@ -1,4 +1,3 @@
import { getAuthUserId } from "@convex-dev/auth/server";
import {
PackagePublishRequestSchema,
PackageTrustedPublisherUpsertRequestSchema,
@@ -9,6 +8,7 @@ import { api, internal } from "../_generated/api";
import type { Doc, Id } from "../_generated/dataModel";
import type { ActionCtx } from "../_generated/server";
import { getOptionalApiTokenUserId } from "../lib/apiTokenAuth";
import { getOptionalActiveAuthUserIdFromAction } from "../lib/access";
import {
fetchGitHubRepositoryIdentity,
verifyGitHubActionsTrustedPublishJwt,
@@ -62,6 +62,7 @@ const internalRefs = internal as unknown as {
getReleaseByPackageAndVersionInternal: unknown;
getReleaseByIdInternal: unknown;
insertAuditLogInternal: unknown;
requestRescanForApiTokenInternal: unknown;
softDeletePackageInternal: unknown;
};
packagePublishTokens: {
@@ -90,12 +91,8 @@ async function getOptionalViewerUserIdForRequest(ctx: ActionCtx, request: Reques
const apiTokenUserId = await getOptionalApiTokenUserId(ctx, request);
if (apiTokenUserId) return apiTokenUserId;
try {
const userId = (await getAuthUserId(ctx)) ?? null;
const userId = (await getOptionalActiveAuthUserIdFromAction(ctx)) ?? null;
if (!userId) return null;
const user = await runQueryRef<Doc<"users"> | null>(ctx, internal.users.getByIdInternal, {
userId,
});
if (!user || user.deletedAt || user.deactivatedAt) return null;
return userId;
} catch {
// Public package reads should degrade to anonymous when cookie-backed auth is stale.
@@ -1040,6 +1037,31 @@ export async function mintPublishTokenV1Handler(ctx: ActionCtx, request: Request
export async function packagesPostRouterV1Handler(ctx: ActionCtx, request: Request) {
const segments = getPathSegments(request, "/api/v1/packages/");
if (segments[1] === "rescan" && segments.length === 2) {
const rate = await applyRateLimit(ctx, request, "write");
if (!rate.ok) return rate.response;
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
if (!auth.ok) return auth.response;
try {
const result = await runMutationRef(
ctx,
internalRefs.packages.requestRescanForApiTokenInternal,
{
actorUserId: auth.userId,
name: segments[0]!,
},
);
return json(result, 200, rate.headers);
} catch (error) {
return text(
error instanceof Error ? error.message : "Rescan request failed",
400,
rate.headers,
);
}
}
if (segments[1] !== "trusted-publisher" || segments.length !== 2) {
return text("Not found", 404);
}
+34 -1
View File
@@ -1,5 +1,5 @@
import { api, internal } from "../_generated/api";
import { normalizeTextContentType } from "clawhub-schema";
import { api, internal } from "../_generated/api";
import type { Doc, Id } from "../_generated/dataModel";
import type { ActionCtx } from "../_generated/server";
import { getOptionalApiTokenUserId, requireApiTokenUser } from "../lib/apiTokenAuth";
@@ -231,6 +231,16 @@ type SkillSecuritySnapshot = {
};
};
const internalRefs = internal as unknown as {
skills: {
requestRescanForApiTokenInternal: unknown;
};
};
async function runMutationRef<T>(ctx: ActionCtx, ref: unknown, args: unknown): Promise<T> {
return (await ctx.runMutation(ref as never, args as never)) as T;
}
function isDefinitiveSecurityStatus(
status: NormalizedSecurityStatus | null | undefined,
): status is "clean" | "suspicious" | "malicious" {
@@ -1165,6 +1175,29 @@ export async function skillsPostRouterV1Handler(ctx: ActionCtx, request: Request
}
}
if (segments.length === 2 && action === "rescan") {
if (!slug) return text("Slug required", 400, rate.headers);
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
if (!auth.ok) return auth.response;
try {
const result = await runMutationRef(
ctx,
internalRefs.skills.requestRescanForApiTokenInternal,
{
actorUserId: auth.userId,
slug,
},
);
return json(result, 200, rate.headers);
} catch (error) {
return text(
error instanceof Error ? error.message : "Rescan request failed",
400,
rate.headers,
);
}
}
if (action === "transfer") {
return handleSkillsTransferPost(ctx, request, segments, rate.headers);
}
+1
View File
@@ -15,6 +15,7 @@ export async function whoamiV1Handler(ctx: ActionCtx, request: Request) {
handle: user.handle ?? null,
displayName: user.displayName ?? null,
image: user.image ?? null,
role: user.role ?? null,
},
},
200,
+62 -6
View File
@@ -5,17 +5,61 @@ import type { ActionCtx, MutationCtx, QueryCtx } from "../_generated/server";
export type Role = "admin" | "moderator" | "user";
const DEV_IMPERSONATE_LOCAL_HANDLE = "local";
function readEnv(name: string) {
const value = process.env[name]?.trim();
return value ? value : undefined;
}
function isDevImpersonationAllowed() {
const requestedHandle = readEnv("CLAW_HUB_DEV_IMPERSONATE_USER_HANDLE");
if (requestedHandle !== DEV_IMPERSONATE_LOCAL_HANDLE) return false;
const deployment = readEnv("CONVEX_DEPLOYMENT") ?? "";
if (deployment.startsWith("prod:") || deployment.includes("production")) return false;
return (
deployment.startsWith("anonymous:") ||
deployment.startsWith("dev:") ||
deployment.startsWith("local:") ||
readEnv("CLAW_HUB_ENABLE_DEV_IMPERSONATION") === "1"
);
}
async function getDevImpersonatedUserId(
ctx: Pick<MutationCtx | QueryCtx, "db">,
): Promise<Id<"users"> | undefined> {
if (!isDevImpersonationAllowed()) return undefined;
const user = await ctx.db
.query("users")
.withIndex("handle", (q) => q.eq("handle", DEV_IMPERSONATE_LOCAL_HANDLE))
.unique();
if (!user || user.deletedAt || user.deactivatedAt) return undefined;
return user._id;
}
async function getDevImpersonatedUserIdFromAction(
ctx: ActionCtx,
): Promise<Id<"users"> | undefined> {
if (!isDevImpersonationAllowed()) return undefined;
const user = await ctx.runQuery(internal.users.getByHandleInternal, {
handle: DEV_IMPERSONATE_LOCAL_HANDLE,
});
if (!user || user.deletedAt || user.deactivatedAt) return undefined;
return user._id;
}
export async function getOptionalActiveAuthUserId(
ctx: MutationCtx | QueryCtx,
): Promise<Id<"users"> | undefined> {
try {
const userId = await getAuthUserId(ctx);
if (!userId) return undefined;
if (!userId) return await getDevImpersonatedUserId(ctx);
const user = await ctx.db.get(userId);
if (!user || user.deletedAt || user.deactivatedAt) return undefined;
return userId;
} catch {
return undefined;
return await getDevImpersonatedUserId(ctx);
}
}
@@ -24,17 +68,23 @@ export async function getOptionalActiveAuthUserIdFromAction(
): Promise<Id<"users"> | undefined> {
try {
const userId = await getAuthUserId(ctx);
if (!userId) return undefined;
if (!userId) return await getDevImpersonatedUserIdFromAction(ctx);
const user = await ctx.runQuery(internal.users.getByIdInternal, { userId });
if (!user || user.deletedAt || user.deactivatedAt) return undefined;
return userId;
} catch {
return undefined;
return await getDevImpersonatedUserIdFromAction(ctx);
}
}
export async function requireUser(ctx: MutationCtx | QueryCtx) {
const userId = await getAuthUserId(ctx);
let userId: Id<"users"> | null | undefined = null;
try {
userId = await getAuthUserId(ctx);
} catch {
userId = null;
}
userId ??= await getDevImpersonatedUserId(ctx);
if (!userId) throw new Error("Unauthorized");
let user: Doc<"users"> | null;
try {
@@ -49,7 +99,13 @@ export async function requireUser(ctx: MutationCtx | QueryCtx) {
export async function requireUserFromAction(
ctx: ActionCtx,
): Promise<{ userId: Id<"users">; user: Doc<"users"> }> {
const userId = await getAuthUserId(ctx);
let userId: Id<"users"> | null | undefined = null;
try {
userId = await getAuthUserId(ctx);
} catch {
userId = null;
}
userId ??= await getDevImpersonatedUserIdFromAction(ctx);
if (!userId) throw new Error("Unauthorized");
let user: Doc<"users"> | null;
try {
+28
View File
@@ -77,6 +77,34 @@ export function isPublisherRoleAllowed(role: PublisherRole, allowed: PublisherRo
return allowed.some((candidate) => ranks[role] >= ranks[candidate]);
}
export type OwnedResourceActor = {
_id: Id<"users">;
role?: Doc<"users">["role"];
};
export async function assertCanManageOwnedResource(
ctx: DbCtx,
params: {
actor: OwnedResourceActor;
ownerUserId: Id<"users">;
ownerPublisherId?: Id<"publishers"> | null;
allowedPublisherRoles?: PublisherRole[];
allowPlatformAdmin?: boolean;
},
) {
if (params.allowPlatformAdmin && params.actor.role === "admin") return;
if (params.ownerUserId === params.actor._id) return;
if (!params.ownerPublisherId) throw new ConvexError("Forbidden");
const membership = await getPublisherMembership(ctx, params.ownerPublisherId, params.actor._id);
if (
!membership ||
!isPublisherRoleAllowed(membership.role, params.allowedPublisherRoles ?? ["admin"])
) {
throw new ConvexError("Forbidden");
}
}
export async function getPublisherByHandle(ctx: DbCtx, handle: string | undefined | null) {
const normalized = normalizePublisherHandle(handle);
if (!normalized) return null;
+41
View File
@@ -0,0 +1,41 @@
import { ConvexError } from "convex/values";
import type { Doc, Id } from "../../_generated/dataModel";
import type { MutationCtx, QueryCtx } from "../../_generated/server";
import type { OwnedResourceActor } from "../../lib/publishers";
export async function getLatestPackageRescanTarget(
ctx: Pick<QueryCtx | MutationCtx, "db">,
packageId: Id<"packages">,
) {
const pkg = await ctx.db.get(packageId);
if (!pkg || pkg.softDeletedAt || pkg.family === "skill") {
throw new ConvexError("Plugin not found");
}
if (!pkg.latestReleaseId) throw new ConvexError("Plugin has no published release");
const release = await ctx.db.get(pkg.latestReleaseId);
if (!release || release.softDeletedAt) throw new ConvexError("Latest plugin release not found");
return { pkg, release };
}
export async function insertPackageRescanRequest(
ctx: Pick<MutationCtx, "db">,
actor: OwnedResourceActor,
target: {
pkg: Doc<"packages">;
release: Doc<"packageReleases">;
},
) {
const now = Date.now();
return await ctx.db.insert("rescanRequests", {
targetKind: "plugin",
packageId: target.pkg._id,
packageReleaseId: target.release._id,
targetVersion: target.release.version,
requestedByUserId: actor._id,
ownerUserId: target.pkg.ownerUserId,
ownerPublisherId: target.pkg.ownerPublisherId,
status: "in_progress",
createdAt: now,
updatedAt: now,
});
}
+204
View File
@@ -0,0 +1,204 @@
import { ConvexError } from "convex/values";
import type { Doc, Id } from "../../_generated/dataModel";
import type { MutationCtx, QueryCtx } from "../../_generated/server";
export const MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE = 3;
const ACTIVE_RESCAN_STATUS = "in_progress" as const;
const NON_TERMINAL_SCAN_STATUSES = new Set(["loading", "not_found", "pending"]);
const FAILED_SCAN_STATUSES = new Set(["error", "failed", "stale"]);
export type RescanTarget =
| {
kind: "skill";
artifactId: Id<"skillVersions">;
}
| {
kind: "plugin";
artifactId: Id<"packageReleases">;
};
export function serializeRescanRequest(request: Doc<"rescanRequests"> | null) {
if (!request) return null;
return {
_id: request._id,
targetKind: request.targetKind,
targetVersion: request.targetVersion,
requestedByUserId: request.requestedByUserId,
status: request.status,
error: request.error,
createdAt: request.createdAt,
updatedAt: request.updatedAt,
completedAt: request.completedAt,
};
}
type ScanSignal = {
status: string;
checkedAt: number;
};
export type RescanScanState = {
staticScan?: ScanSignal;
vtAnalysis?: ScanSignal;
llmAnalysis?: ScanSignal;
};
function freshTerminalSignal(signal: ScanSignal | undefined, requestedAt: number) {
if (!signal || signal.checkedAt < requestedAt) return null;
const status = signal.status.trim().toLowerCase();
if (NON_TERMINAL_SCAN_STATUSES.has(status)) return null;
return status;
}
function terminalRequestStatusForScanState(
scanState: RescanScanState,
requestedAt: number,
): "completed" | "failed" | null {
const statuses = [
freshTerminalSignal(scanState.staticScan, requestedAt),
freshTerminalSignal(scanState.vtAnalysis, requestedAt),
freshTerminalSignal(scanState.llmAnalysis, requestedAt),
];
if (statuses.some((status) => status === null)) return null;
if (statuses.some((status) => FAILED_SCAN_STATUSES.has(status!))) return "failed";
return "completed";
}
export async function listRequestsForTarget(
ctx: Pick<QueryCtx | MutationCtx, "db">,
target: RescanTarget,
) {
if (target.kind === "skill") {
return await ctx.db
.query("rescanRequests")
.withIndex("by_skill_version", (q) =>
q.eq("targetKind", "skill").eq("skillVersionId", target.artifactId),
)
.order("desc")
.take(MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE + 1);
}
return await ctx.db
.query("rescanRequests")
.withIndex("by_package_release", (q) =>
q.eq("targetKind", "plugin").eq("packageReleaseId", target.artifactId),
)
.order("desc")
.take(MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE + 1);
}
export async function getInProgressRequestForTarget(
ctx: Pick<QueryCtx | MutationCtx, "db">,
target: RescanTarget,
) {
if (target.kind === "skill") {
return await ctx.db
.query("rescanRequests")
.withIndex("by_skill_version_status", (q) =>
q
.eq("targetKind", "skill")
.eq("skillVersionId", target.artifactId)
.eq("status", ACTIVE_RESCAN_STATUS),
)
.order("desc")
.first();
}
return await ctx.db
.query("rescanRequests")
.withIndex("by_package_release_status", (q) =>
q
.eq("targetKind", "plugin")
.eq("packageReleaseId", target.artifactId)
.eq("status", ACTIVE_RESCAN_STATUS),
)
.order("desc")
.first();
}
export async function assertCanRequestRescan(
ctx: Pick<QueryCtx | MutationCtx, "db">,
target: RescanTarget,
) {
const existingInProgress = await getInProgressRequestForTarget(ctx, target);
if (existingInProgress) {
throw new ConvexError("A rescan request is already in progress for this release");
}
const existingRequests = await listRequestsForTarget(ctx, target);
if (existingRequests.length >= MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE) {
throw new ConvexError(
`Rescan request limit reached for this release (${MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE})`,
);
}
}
export async function buildRescanState(
ctx: Pick<QueryCtx | MutationCtx, "db">,
target: RescanTarget,
) {
const requests = await listRequestsForTarget(ctx, target);
const inProgressRequest =
requests.find((request) => request.status === ACTIVE_RESCAN_STATUS) ?? null;
const requestCount = Math.min(requests.length, MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE);
return {
maxRequests: MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE,
requestCount,
remainingRequests: Math.max(0, MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE - requestCount),
canRequest:
requestCount < MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE && inProgressRequest === null,
inProgressRequest: serializeRescanRequest(inProgressRequest),
latestRequest: serializeRescanRequest(requests[0] ?? null),
};
}
async function listInProgressRequestsForTarget(
ctx: Pick<QueryCtx | MutationCtx, "db">,
target: RescanTarget,
) {
if (target.kind === "skill") {
return await ctx.db
.query("rescanRequests")
.withIndex("by_skill_version_status", (q) =>
q
.eq("targetKind", "skill")
.eq("skillVersionId", target.artifactId)
.eq("status", ACTIVE_RESCAN_STATUS),
)
.order("desc")
.take(MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE);
}
return await ctx.db
.query("rescanRequests")
.withIndex("by_package_release_status", (q) =>
q
.eq("targetKind", "plugin")
.eq("packageReleaseId", target.artifactId)
.eq("status", ACTIVE_RESCAN_STATUS),
)
.order("desc")
.take(MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE);
}
export async function finalizeInProgressRescanRequestsForTarget(
ctx: Pick<MutationCtx, "db">,
target: RescanTarget,
scanState: RescanScanState,
) {
const requests = await listInProgressRequestsForTarget(ctx, target);
const now = Date.now();
for (const request of requests) {
const status = terminalRequestStatusForScanState(scanState, request.createdAt);
if (!status) continue;
await ctx.db.patch(request._id, {
status,
updatedAt: now,
completedAt: now,
});
}
}
export function errorMessage(error: unknown) {
return error instanceof Error ? error.message.slice(0, 500) : "Unknown rescan dispatch error";
}
+39
View File
@@ -0,0 +1,39 @@
import { ConvexError } from "convex/values";
import type { Doc, Id } from "../../_generated/dataModel";
import type { MutationCtx, QueryCtx } from "../../_generated/server";
import type { OwnedResourceActor } from "../../lib/publishers";
export async function getLatestSkillRescanTarget(
ctx: Pick<QueryCtx | MutationCtx, "db">,
skillId: Id<"skills">,
) {
const skill = await ctx.db.get(skillId);
if (!skill || skill.softDeletedAt) throw new ConvexError("Skill not found");
if (!skill.latestVersionId) throw new ConvexError("Skill has no published version");
const version = await ctx.db.get(skill.latestVersionId);
if (!version || version.softDeletedAt) throw new ConvexError("Latest skill version not found");
return { skill, version };
}
export async function insertSkillRescanRequest(
ctx: Pick<MutationCtx, "db">,
actor: OwnedResourceActor,
target: {
skill: Doc<"skills">;
version: Doc<"skillVersions">;
},
) {
const now = Date.now();
return await ctx.db.insert("rescanRequests", {
targetKind: "skill",
skillId: target.skill._id,
skillVersionId: target.version._id,
targetVersion: target.version.version,
requestedByUserId: actor._id,
ownerUserId: target.skill.ownerUserId,
ownerPublisherId: target.skill.ownerPublisherId,
status: "in_progress",
createdAt: now,
updatedAt: now,
});
}
+21 -1
View File
@@ -2594,6 +2594,15 @@ describe("packages public queries", () => {
})),
};
}
if (table === "rescanRequests") {
return {
withIndex: vi.fn(() => ({
order: vi.fn(() => ({
take: vi.fn().mockResolvedValue([]),
})),
})),
};
}
throw new Error(`Unexpected table ${table}`);
}),
},
@@ -2859,7 +2868,18 @@ describe("package scan backfill", () => {
if (id === "packages:demo") return pkg;
return null;
}),
query: vi.fn(),
query: vi.fn((table: string) => {
if (table === "rescanRequests") {
return {
withIndex: vi.fn(() => ({
order: vi.fn(() => ({
take: vi.fn(async () => []),
})),
})),
};
}
throw new Error(`Unexpected query table: ${table}`);
}),
insert: vi.fn(),
patch,
replace: vi.fn(),
+240 -12
View File
@@ -11,11 +11,19 @@ import { ConvexError, v } from "convex/values";
import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server";
import { action, internalAction, internalMutation, internalQuery, query } from "./functions";
import {
action,
internalAction,
internalMutation,
internalQuery,
mutation,
query,
} from "./functions";
import {
assertAdmin,
assertModerator,
getOptionalActiveAuthUserId,
requireUser,
requireUserFromAction,
} from "./lib/access";
import { requireGitHubAccountAge } from "./lib/githubAccount";
@@ -33,7 +41,11 @@ import {
} from "./lib/packageRegistry";
import { isPackageBlockedFromPublic, resolvePackageReleaseScanStatus } from "./lib/packageSecurity";
import { toPublicPublisher } from "./lib/public";
import { getOwnerPublisher, getPublisherMembership } from "./lib/publishers";
import {
assertCanManageOwnedResource,
getOwnerPublisher,
getPublisherMembership,
} from "./lib/publishers";
import {
findOversizedPublishFile,
getPublishFileSizeError,
@@ -43,6 +55,13 @@ import {
import { tokenize } from "./lib/searchText";
import { hashSkillFiles } from "./lib/skills";
import { runStaticPublishScan } from "./lib/staticPublishScan";
import { getLatestPackageRescanTarget, insertPackageRescanRequest } from "./model/packages/rescans";
import {
assertCanRequestRescan,
buildRescanState,
errorMessage,
finalizeInProgressRescanRequestsForTarget,
} from "./model/rescans/policy";
const MAX_PUBLIC_LIST_PAGE_SIZE = 200;
const MAX_SEARCH_PAGE_SIZE = 200;
@@ -90,6 +109,9 @@ const internalRefs = internal as unknown as {
getByIdInternal: unknown;
revokeInternal: unknown;
};
rescanRequests: {
markStatusInternal: unknown;
};
skills: {
getSkillBySlugInternal: unknown;
};
@@ -260,6 +282,7 @@ type DashboardPackageListItem = {
createdAt: number;
updatedAt: number;
pendingReview?: true;
rescanState: Awaited<ReturnType<typeof buildRescanState>> | null;
latestRelease: {
version: string;
createdAt: number;
@@ -430,6 +453,13 @@ async function toDashboardPackageListItem(
createdAt: pkg.createdAt,
updatedAt: pkg.updatedAt,
pendingReview: pkg.scanStatus === "pending" ? true : undefined,
rescanState:
latestRelease && !latestRelease.softDeletedAt
? await buildRescanState(ctx, {
kind: "plugin",
artifactId: latestRelease._id,
})
: null,
latestRelease:
latestRelease && !latestRelease.softDeletedAt
? {
@@ -950,12 +980,12 @@ async function requireTrustedPublisherEditor(
pkg: Doc<"packages">,
actorUserId: Id<"users">,
) {
if (pkg.ownerUserId === actorUserId) return;
if (!pkg.ownerPublisherId) throw new ConvexError("Forbidden");
const membership = await getPublisherMembership(ctx, pkg.ownerPublisherId, actorUserId);
if (!membership || membership.role === "publisher") {
throw new ConvexError("Forbidden");
}
await assertCanManageOwnedResource(ctx, {
actor: { _id: actorUserId },
ownerUserId: pkg.ownerUserId,
ownerPublisherId: pkg.ownerPublisherId,
allowPlatformAdmin: false,
});
}
export const getByName = query({
@@ -2410,10 +2440,16 @@ export const updateReleaseScanResultsInternal = internalMutation({
await ctx.db.patch(args.releaseId, patch);
}
if (args.vtAnalysis !== undefined) {
await syncLatestPackageVerification(ctx, {
const updatedRelease = {
...activeRelease,
...patch,
} as Doc<"packageReleases">);
} as Doc<"packageReleases">;
await syncLatestPackageVerification(ctx, updatedRelease);
await finalizeInProgressRescanRequestsForTarget(
ctx,
{ kind: "plugin", artifactId: args.releaseId },
updatedRelease,
);
}
},
});
@@ -2445,7 +2481,13 @@ export const updateReleaseLlmAnalysisInternal = internalMutation({
handler: async (ctx, args) => {
const release = await ctx.db.get(args.releaseId);
if (!isReleaseActive(release)) return;
const updatedRelease = { ...release, llmAnalysis: args.llmAnalysis };
await ctx.db.patch(args.releaseId, { llmAnalysis: args.llmAnalysis });
await finalizeInProgressRescanRequestsForTarget(
ctx,
{ kind: "plugin", artifactId: args.releaseId },
updatedRelease,
);
},
});
@@ -2493,10 +2535,16 @@ export const updateReleaseStaticScanInternal = internalMutation({
await ctx.db.patch(args.releaseId, patch);
await syncLatestPackageVerification(ctx, {
const updatedRelease = {
...activeRelease,
...patch,
} as Doc<"packageReleases">);
} as Doc<"packageReleases">;
await syncLatestPackageVerification(ctx, updatedRelease);
await finalizeInProgressRescanRequestsForTarget(
ctx,
{ kind: "plugin", artifactId: args.releaseId },
updatedRelease,
);
},
});
@@ -2623,3 +2671,183 @@ export const backfillPackageReleaseScans = action({
});
},
});
async function markPackageRescanRequest(
ctx: { runMutation: (ref: never, args: never) => Promise<unknown> },
requestId: Id<"rescanRequests">,
status: "completed" | "failed",
error?: string,
) {
await ctx.runMutation(
internalRefs.rescanRequests.markStatusInternal as never,
{
requestId,
status,
error,
} as never,
);
}
export const getRescanState = query({
args: {
packageId: v.id("packages"),
},
handler: async (ctx, args) => {
const { user } = await requireUser(ctx);
const target = await getLatestPackageRescanTarget(ctx, args.packageId);
await assertCanManageOwnedResource(ctx, {
actor: user,
ownerUserId: target.pkg.ownerUserId,
ownerPublisherId: target.pkg.ownerPublisherId,
allowPlatformAdmin: true,
});
return {
targetKind: "plugin" as const,
targetVersion: target.release.version,
packageReleaseId: target.release._id,
...(await buildRescanState(ctx, {
kind: "plugin",
artifactId: target.release._id,
})),
};
},
});
export const getOwnerRescanStateByName = query({
args: {
name: v.string(),
},
handler: async (ctx, args) => {
const viewerUserId = await getOptionalViewerUserId(ctx);
if (!viewerUserId) return null;
const pkg = await getPackageByNormalizedName(ctx, normalizePackageName(args.name));
if (!pkg || pkg.softDeletedAt || pkg.family === "skill" || !pkg.latestReleaseId) return null;
const release = await ctx.db.get(pkg.latestReleaseId);
if (!release || release.softDeletedAt) return null;
const actor = await ctx.db.get(viewerUserId);
if (!actor || actor.deletedAt || actor.deactivatedAt) return null;
if (actor.role !== "admin") {
const canAccess = await viewerCanAccessPackageOwner(ctx, pkg, viewerUserId);
if (!canAccess) return null;
}
return {
targetKind: "plugin" as const,
targetVersion: release.version,
packageReleaseId: release._id,
...(await buildRescanState(ctx, {
kind: "plugin",
artifactId: release._id,
})),
};
},
});
export const requestRescan = mutation({
args: {
packageId: v.id("packages"),
},
handler: async (ctx, args) => {
const { user } = await requireUser(ctx);
const target = await getLatestPackageRescanTarget(ctx, args.packageId);
await assertCanManageOwnedResource(ctx, {
actor: user,
ownerUserId: target.pkg.ownerUserId,
ownerPublisherId: target.pkg.ownerPublisherId,
allowPlatformAdmin: true,
});
await assertCanRequestRescan(ctx, {
kind: "plugin",
artifactId: target.release._id,
});
const requestId = await insertPackageRescanRequest(ctx, user, target);
await ctx.scheduler.runAfter(0, internal.packages.dispatchPackageRescanInternal, {
requestId,
releaseId: target.release._id,
});
return {
requestId,
...(await buildRescanState(ctx, {
kind: "plugin",
artifactId: target.release._id,
})),
};
},
});
export const requestRescanForApiTokenInternal = internalMutation({
args: {
actorUserId: v.id("users"),
name: v.string(),
},
handler: async (ctx, args) => {
const actor = await ctx.db.get(args.actorUserId);
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
const pkg = await getPackageByNormalizedName(ctx, normalizePackageName(args.name));
if (!pkg || pkg.softDeletedAt || pkg.family === "skill") {
throw new ConvexError("Plugin not found");
}
const target = await getLatestPackageRescanTarget(ctx, pkg._id);
await assertCanManageOwnedResource(ctx, {
actor,
ownerUserId: target.pkg.ownerUserId,
ownerPublisherId: target.pkg.ownerPublisherId,
allowPlatformAdmin: true,
});
await assertCanRequestRescan(ctx, {
kind: "plugin",
artifactId: target.release._id,
});
const requestId = await insertPackageRescanRequest(ctx, actor, target);
await ctx.scheduler.runAfter(0, internal.packages.dispatchPackageRescanInternal, {
requestId,
releaseId: target.release._id,
});
const state = await buildRescanState(ctx, {
kind: "plugin",
artifactId: target.release._id,
});
return {
ok: true,
targetKind: "package" as const,
name: target.pkg.normalizedName,
version: target.release.version,
status: state.inProgressRequest?.status ?? state.latestRequest?.status ?? "in_progress",
remainingRequests: state.remainingRequests,
maxRequests: state.maxRequests,
pendingRequestId: requestId,
};
},
});
export const dispatchPackageRescanInternal = internalAction({
args: {
requestId: v.id("rescanRequests"),
releaseId: v.id("packageReleases"),
},
handler: async (ctx, args) => {
try {
await runActionRef(ctx, internalRefs.packages.scanPackageReleaseStaticallyInternal, {
releaseId: args.releaseId,
});
await runActionRef(ctx, internalRefs.vt.scanPackageReleaseWithVirusTotal, {
releaseId: args.releaseId,
});
await runActionRef(ctx, internalRefs.llmEval.evaluatePackageReleaseWithLlm, {
releaseId: args.releaseId,
});
} catch (error) {
await markPackageRescanRequest(ctx, args.requestId, "failed", errorMessage(error));
throw error;
}
},
});
+2 -3
View File
@@ -1,9 +1,8 @@
import { getAuthUserId } from "@convex-dev/auth/server";
import { ConvexError, v } from "convex/values";
import type { Doc, Id } from "./_generated/dataModel";
import type { MutationCtx } from "./_generated/server";
import { internalMutation, internalQuery, mutation, query } from "./functions";
import { assertAdmin, requireUser } from "./lib/access";
import { assertAdmin, getOptionalActiveAuthUserId, requireUser } from "./lib/access";
import { toPublicPublisher } from "./lib/public";
import {
ensurePersonalPublisherForUser,
@@ -404,7 +403,7 @@ export const resolvePublishTargetForUserInternal = internalMutation({
export const listMine = query({
args: {},
handler: async (ctx) => {
const userId = await getAuthUserId(ctx);
const userId = await getOptionalActiveAuthUserId(ctx);
if (!userId) return [];
const user = await ctx.db.get(userId);
if (!user || user.deletedAt || user.deactivatedAt) return [];
+19
View File
@@ -0,0 +1,19 @@
import { v } from "convex/values";
import { internalMutation } from "./functions";
export const markStatusInternal = internalMutation({
args: {
requestId: v.id("rescanRequests"),
status: v.union(v.literal("completed"), v.literal("failed")),
error: v.optional(v.string()),
},
handler: async (ctx, args) => {
const now = Date.now();
await ctx.db.patch(args.requestId, {
status: args.status,
error: args.error,
updatedAt: now,
completedAt: now,
});
},
});
+515
View File
@@ -0,0 +1,515 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
dispatchPackageRescanInternal,
requestRescan as requestPackageRescan,
} from "./packages";
import {
dispatchSkillRescanInternal,
getRescanState as getSkillRescanState,
requestRescan as requestSkillRescan,
} from "./skills";
import { requireUser } from "./lib/access";
import {
finalizeInProgressRescanRequestsForTarget,
MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE,
} from "./model/rescans/policy";
vi.mock("./lib/access", () => ({
requireUser: vi.fn(),
}));
type WrappedHandler<TArgs, TResult = unknown> = {
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
};
const requestSkillRescanHandler = (
requestSkillRescan as unknown as WrappedHandler<{ skillId: string }>
)._handler;
const requestPackageRescanHandler = (
requestPackageRescan as unknown as WrappedHandler<{ packageId: string }>
)._handler;
const getSkillRescanStateHandler = (
getSkillRescanState as unknown as WrappedHandler<{ skillId: string }>
)._handler;
const dispatchSkillRescanHandler = (
dispatchSkillRescanInternal as unknown as WrappedHandler<{
requestId: string;
skillId: string;
versionId: string;
}>
)._handler;
const dispatchPackageRescanHandler = (
dispatchPackageRescanInternal as unknown as WrappedHandler<{
requestId: string;
releaseId: string;
}>
)._handler;
type RescanRequest = {
_id: string;
targetKind: "skill" | "plugin";
skillId?: string;
skillVersionId?: string;
packageId?: string;
packageReleaseId?: string;
targetVersion: string;
requestedByUserId: string;
ownerUserId: string;
ownerPublisherId?: string;
status: "in_progress" | "completed" | "failed";
createdAt: number;
updatedAt: number;
completedAt?: number;
};
function chainEq(constraints: Record<string, unknown>) {
return {
eq(field: string, value: unknown) {
constraints[field] = value;
return chainEq(constraints);
},
};
}
function matches(doc: Record<string, unknown>, constraints: Record<string, unknown>) {
return Object.entries(constraints).every(([key, value]) => doc[key] === value);
}
function createDb(options?: {
requests?: RescanRequest[];
userRole?: "admin" | "moderator" | "user";
ownerPublisherId?: string;
membershipRole?: "owner" | "admin" | "publisher";
skillLatestVersionId?: string;
packageLatestReleaseId?: string;
skillSoftDeletedAt?: number;
skillVersionSoftDeletedAt?: number;
packageSoftDeletedAt?: number;
packageReleaseSoftDeletedAt?: number;
}) {
const requests = [...(options?.requests ?? [])];
const skill = {
_id: "skills:1",
slug: "flagged-skill",
ownerUserId: "users:owner",
ownerPublisherId: options?.ownerPublisherId,
latestVersionId: options?.skillLatestVersionId ?? "skillVersions:latest",
softDeletedAt: options?.skillSoftDeletedAt,
};
const version = {
_id: "skillVersions:latest",
skillId: "skills:1",
version: "1.2.3",
softDeletedAt: options?.skillVersionSoftDeletedAt,
};
const pkg = {
_id: "packages:1",
name: "flagged-plugin",
family: "code-plugin",
ownerUserId: "users:owner",
ownerPublisherId: options?.ownerPublisherId,
latestReleaseId: options?.packageLatestReleaseId ?? "packageReleases:latest",
softDeletedAt: options?.packageSoftDeletedAt,
};
const release = {
_id: "packageReleases:latest",
packageId: "packages:1",
version: "2.0.0",
softDeletedAt: options?.packageReleaseSoftDeletedAt,
};
const actor = {
_id: "users:actor",
role: options?.userRole ?? "user",
deletedAt: undefined,
deactivatedAt: undefined,
};
const db = {
get: vi.fn(async (id: string) => {
if (id === "skills:1") return skill;
if (id === "skillVersions:latest") return version;
if (id === "packages:1") return pkg;
if (id === "packageReleases:latest") return release;
if (id === "users:actor") return actor;
return null;
}),
insert: vi.fn(async (table: string, doc: Omit<RescanRequest, "_id">) => {
if (table !== "rescanRequests") throw new Error(`unexpected insert ${table}`);
const inserted = {
_id: `rescanRequests:${requests.length + 1}`,
...doc,
} as RescanRequest;
requests.push(inserted);
return inserted._id;
}),
patch: vi.fn(async (id: string, patch: Partial<RescanRequest>) => {
const request = requests.find((candidate) => candidate._id === id);
if (request) Object.assign(request, patch);
}),
query: vi.fn((table: string) => {
if (table === "publisherMembers") {
return {
withIndex: (name: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
if (name !== "by_publisher_user") throw new Error(`unexpected index ${name}`);
const constraints: Record<string, unknown> = {};
build(chainEq(constraints));
return {
unique: async () =>
options?.membershipRole
? {
publisherId: constraints.publisherId,
userId: constraints.userId,
role: options.membershipRole,
}
: null,
};
},
};
}
if (table !== "rescanRequests") throw new Error(`unexpected table ${table}`);
return {
withIndex: (_name: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
const constraints: Record<string, unknown> = {};
build(chainEq(constraints));
const matched = requests
.filter((request) => matches(request as unknown as Record<string, unknown>, constraints))
.sort((a, b) => b.createdAt - a.createdAt);
return {
order: () => ({
take: async (limit: number) => matched.slice(0, limit),
first: async () => matched[0] ?? null,
}),
};
},
};
}),
normalizeId: vi.fn((table: string, id: string) => (id.startsWith(`${table}:`) ? id : null)),
};
return { db, requests };
}
function createRequest(overrides?: Partial<RescanRequest>): RescanRequest {
return {
_id: "rescanRequests:existing",
targetKind: "skill",
skillId: "skills:1",
skillVersionId: "skillVersions:latest",
targetVersion: "1.2.3",
requestedByUserId: "users:owner",
ownerUserId: "users:owner",
status: "completed",
createdAt: Date.now(),
updatedAt: Date.now(),
...overrides,
};
}
beforeEach(() => {
vi.mocked(requireUser).mockReset();
vi.mocked(requireUser).mockResolvedValue({
userId: "users:owner",
user: { _id: "users:owner", role: "user" },
} as never);
});
describe("rescan requests", () => {
it("returns owner-visible state for the latest skill version", async () => {
const { db } = createDb({
requests: [
createRequest({ _id: "rescanRequests:1", status: "completed", createdAt: 1 }),
createRequest({ _id: "rescanRequests:2", status: "failed", createdAt: 2 }),
],
});
const result = await getSkillRescanStateHandler({ db } as never, {
skillId: "skills:1",
});
expect(result).toMatchObject({
targetKind: "skill",
targetVersion: "1.2.3",
maxRequests: MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE,
requestCount: 2,
remainingRequests: 1,
canRequest: true,
});
});
it("creates a skill rescan request and schedules dispatch", async () => {
const { db, requests } = createDb();
const scheduler = { runAfter: vi.fn(async () => undefined) };
const result = await requestSkillRescanHandler({ db, scheduler } as never, {
skillId: "skills:1",
});
expect(result).toMatchObject({
requestId: "rescanRequests:1",
remainingRequests: 2,
});
expect(requests[0]).toMatchObject({
targetKind: "skill",
skillId: "skills:1",
skillVersionId: "skillVersions:latest",
status: "in_progress",
targetVersion: "1.2.3",
});
expect(scheduler.runAfter).toHaveBeenCalledWith(
0,
expect.anything(),
expect.objectContaining({
requestId: "rescanRequests:1",
skillId: "skills:1",
versionId: "skillVersions:latest",
}),
);
});
it("creates a plugin rescan request against the latest release", async () => {
const { db, requests } = createDb();
const scheduler = { runAfter: vi.fn(async () => undefined) };
await requestPackageRescanHandler({ db, scheduler } as never, {
packageId: "packages:1",
});
expect(requests[0]).toMatchObject({
targetKind: "plugin",
packageId: "packages:1",
packageReleaseId: "packageReleases:latest",
status: "in_progress",
targetVersion: "2.0.0",
});
expect(scheduler.runAfter).toHaveBeenCalledWith(
0,
expect.anything(),
expect.objectContaining({
requestId: "rescanRequests:1",
releaseId: "packageReleases:latest",
}),
);
});
it("rejects duplicate in-progress requests for the same release", async () => {
const { db } = createDb({
requests: [createRequest({ status: "in_progress" })],
});
await expect(
requestSkillRescanHandler({ db, scheduler: { runAfter: vi.fn() } } as never, {
skillId: "skills:1",
}),
).rejects.toThrow("already in progress");
});
it("enforces the per-release rescan cap", async () => {
const { db } = createDb({
requests: Array.from({ length: MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE }, (_, index) =>
createRequest({
_id: `rescanRequests:${index}`,
status: "completed",
createdAt: index,
}),
),
});
await expect(
requestSkillRescanHandler({ db, scheduler: { runAfter: vi.fn() } } as never, {
skillId: "skills:1",
}),
).rejects.toThrow("Rescan request limit reached");
});
it("rejects non-owners", async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: "users:actor",
user: { _id: "users:actor", role: "user" },
} as never);
const { db } = createDb();
await expect(
requestSkillRescanHandler({ db, scheduler: { runAfter: vi.fn() } } as never, {
skillId: "skills:1",
}),
).rejects.toThrow("Forbidden");
});
it("lets org admins request owner rescans", async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: "users:actor",
user: { _id: "users:actor", role: "user" },
} as never);
const { db } = createDb({
ownerPublisherId: "publishers:org",
membershipRole: "admin",
});
await expect(
requestSkillRescanHandler({ db, scheduler: { runAfter: vi.fn() } } as never, {
skillId: "skills:1",
}),
).resolves.toMatchObject({ requestId: "rescanRequests:1" });
});
it("rejects publisher-only org members", async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: "users:actor",
user: { _id: "users:actor", role: "user" },
} as never);
const { db } = createDb({
ownerPublisherId: "publishers:org",
membershipRole: "publisher",
});
await expect(
requestSkillRescanHandler({ db, scheduler: { runAfter: vi.fn() } } as never, {
skillId: "skills:1",
}),
).rejects.toThrow("Forbidden");
});
it("lets admins request owner rescans", async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: "users:admin",
user: { _id: "users:admin", role: "admin" },
} as never);
const { db } = createDb();
await expect(
requestSkillRescanHandler({ db, scheduler: { runAfter: vi.fn() } } as never, {
skillId: "skills:1",
}),
).resolves.toMatchObject({ requestId: "rescanRequests:1" });
});
it("rejects missing or soft-deleted skill targets", async () => {
const softDeletedSkill = createDb({ skillSoftDeletedAt: 123 });
await expect(
requestSkillRescanHandler(
{ db: softDeletedSkill.db, scheduler: { runAfter: vi.fn() } } as never,
{ skillId: "skills:1" },
),
).rejects.toThrow("Skill not found");
const softDeletedVersion = createDb({ skillVersionSoftDeletedAt: 123 });
await expect(
requestSkillRescanHandler(
{ db: softDeletedVersion.db, scheduler: { runAfter: vi.fn() } } as never,
{ skillId: "skills:1" },
),
).rejects.toThrow("Latest skill version not found");
});
it("rejects missing or soft-deleted plugin targets", async () => {
const softDeletedPackage = createDb({ packageSoftDeletedAt: 123 });
await expect(
requestPackageRescanHandler(
{ db: softDeletedPackage.db, scheduler: { runAfter: vi.fn() } } as never,
{ packageId: "packages:1" },
),
).rejects.toThrow("Plugin not found");
const softDeletedRelease = createDb({ packageReleaseSoftDeletedAt: 123 });
await expect(
requestPackageRescanHandler(
{ db: softDeletedRelease.db, scheduler: { runAfter: vi.fn() } } as never,
{ packageId: "packages:1" },
),
).rejects.toThrow("Latest plugin release not found");
});
it("dispatches skill rescans through each existing scanner without completing early", async () => {
const runAction = vi.fn(async () => undefined);
const runMutation = vi.fn(async () => undefined);
await dispatchSkillRescanHandler({ runAction, runMutation } as never, {
requestId: "rescanRequests:1",
skillId: "skills:1",
versionId: "skillVersions:latest",
});
expect(runAction).toHaveBeenCalledTimes(3);
expect(runAction).toHaveBeenNthCalledWith(
1,
expect.anything(),
expect.objectContaining({ skillId: "skills:1", versionId: "skillVersions:latest" }),
);
expect(runAction).toHaveBeenNthCalledWith(
2,
expect.anything(),
expect.objectContaining({ versionId: "skillVersions:latest" }),
);
expect(runAction).toHaveBeenNthCalledWith(
3,
expect.anything(),
expect.objectContaining({ versionId: "skillVersions:latest" }),
);
expect(runMutation).not.toHaveBeenCalled();
});
it("dispatches plugin rescans through each existing scanner without completing early", async () => {
const runAction = vi.fn(async () => undefined);
const runMutation = vi.fn(async () => undefined);
await dispatchPackageRescanHandler({ runAction, runMutation } as never, {
requestId: "rescanRequests:1",
releaseId: "packageReleases:latest",
});
expect(runAction).toHaveBeenCalledTimes(3);
expect(runAction).toHaveBeenNthCalledWith(
1,
expect.anything(),
expect.objectContaining({ releaseId: "packageReleases:latest" }),
);
expect(runAction).toHaveBeenNthCalledWith(
2,
expect.anything(),
expect.objectContaining({ releaseId: "packageReleases:latest" }),
);
expect(runAction).toHaveBeenNthCalledWith(
3,
expect.anything(),
expect.objectContaining({ releaseId: "packageReleases:latest" }),
);
expect(runMutation).not.toHaveBeenCalled();
});
it("completes in-progress rescans when all scanner results are fresh", async () => {
const { db, requests } = createDb({
requests: [createRequest({ status: "in_progress", createdAt: 100 })],
});
await finalizeInProgressRescanRequestsForTarget(
{ db } as never,
{ kind: "skill", artifactId: "skillVersions:latest" as never },
{
staticScan: { status: "clean", checkedAt: 101 },
vtAnalysis: { status: "clean", checkedAt: 102 },
llmAnalysis: { status: "benign", checkedAt: 103 },
},
);
expect(requests[0]).toMatchObject({ status: "completed" });
expect(requests[0].completedAt).toEqual(expect.any(Number));
});
it("keeps in-progress rescans open while VT only has old results", async () => {
const { db, requests } = createDb({
requests: [createRequest({ status: "in_progress", createdAt: 100 })],
});
await finalizeInProgressRescanRequestsForTarget(
{ db } as never,
{ kind: "skill", artifactId: "skillVersions:latest" as never },
{
staticScan: { status: "clean", checkedAt: 101 },
vtAnalysis: { status: "clean", checkedAt: 99 },
llmAnalysis: { status: "benign", checkedAt: 103 },
},
);
expect(requests[0]).toMatchObject({ status: "in_progress" });
});
});
+28
View File
@@ -1200,6 +1200,33 @@ const vtScanLogs = defineTable({
createdAt: v.number(),
}).index("by_type_date", ["type", "createdAt"]);
const rescanRequests = defineTable({
targetKind: v.union(v.literal("skill"), v.literal("plugin")),
skillId: v.optional(v.id("skills")),
skillVersionId: v.optional(v.id("skillVersions")),
packageId: v.optional(v.id("packages")),
packageReleaseId: v.optional(v.id("packageReleases")),
targetVersion: v.string(),
requestedByUserId: v.id("users"),
ownerUserId: v.id("users"),
ownerPublisherId: v.optional(v.id("publishers")),
status: v.union(v.literal("in_progress"), v.literal("completed"), v.literal("failed")),
error: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
completedAt: v.optional(v.number()),
})
.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_requester", ["requestedByUserId", "createdAt"]);
const apiTokens = defineTable({
userId: v.id("users"),
label: v.string(),
@@ -1361,6 +1388,7 @@ export default defineSchema({
soulStars,
auditLogs,
vtScanLogs,
rescanRequests,
apiTokens,
rateLimits,
downloadDedupes,
+157 -25
View File
@@ -11,44 +11,62 @@ type WrappedHandler<TArgs, TResult = unknown> = {
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
};
function makeSkill(overrides: Record<string, unknown> = {}) {
return {
_id: "skills:skill",
_creationTime: 1,
slug: "demo-skill",
displayName: "Demo Skill",
summary: "Demo skill",
ownerUserId: "users:owner",
ownerPublisherId: undefined,
canonicalSkillId: undefined,
forkOf: undefined,
latestVersionId: undefined,
latestVersionSummary: undefined,
tags: {},
capabilityTags: [],
badges: undefined,
statsDownloads: 7,
statsStars: 3,
statsInstallsCurrent: 0,
statsInstallsAllTime: 0,
stats: {
downloads: 0,
installsCurrent: 0,
installsAllTime: 0,
stars: 0,
versions: 1,
comments: 0,
},
createdAt: 1,
updatedAt: 2,
softDeletedAt: undefined,
moderationStatus: "active",
moderationFlags: [],
moderationReason: undefined,
moderationVerdict: "clean",
isSuspicious: false,
...overrides,
};
}
const listHandler = (
list as unknown as WrappedHandler<
{ ownerPublisherId?: string; ownerUserId?: string; limit?: number },
Array<{ slug: string }>
Array<{ slug: string; stats: { downloads: number; stars: number } }>
>
)._handler;
describe("skills.list", () => {
it("includes legacy personal skills when listing a personal publisher", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
const legacySkill = {
const legacySkill = makeSkill({
_id: "skills:legacy",
_creationTime: 1,
slug: "legacy-skill",
displayName: "Legacy Skill",
summary: "Pre-backfill skill",
ownerUserId: "users:owner",
ownerPublisherId: undefined,
canonicalSkillId: undefined,
forkOf: undefined,
latestVersionId: undefined,
tags: {},
badges: undefined,
stats: {
downloads: 0,
installsCurrent: 0,
installsAllTime: 0,
stars: 0,
versions: 1,
comments: 0,
},
createdAt: 1,
updatedAt: 2,
softDeletedAt: undefined,
moderationStatus: "active",
moderationFlags: [],
moderationReason: undefined,
};
});
const ctx = {
db: {
@@ -121,4 +139,118 @@ describe("skills.list", () => {
expect(result).toEqual([expect.objectContaining({ slug: "legacy-skill" })]);
});
it("includes non-public flagged skills for the owning user dashboard", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
const blockedSkill = makeSkill({
slug: "blocked-skill",
moderationStatus: "hidden",
moderationFlags: ["blocked.malware"],
moderationReason: "scanner.vt.malicious",
moderationVerdict: "malicious",
isSuspicious: true,
});
const ctx = {
db: {
get: vi.fn(async (id: string) => {
if (id === "users:owner") {
return {
_id: "users:owner",
_creationTime: 1,
handle: "owner",
displayName: "Owner",
};
}
return null;
}),
query: vi.fn((table: string) => {
if (table === "skills") {
return {
withIndex: vi.fn(() => ({
order: vi.fn(() => ({
take: vi.fn().mockResolvedValue([blockedSkill]),
})),
})),
};
}
if (table === "skillBadges") {
return {
withIndex: vi.fn(() => ({
take: vi.fn().mockResolvedValue([]),
})),
};
}
throw new Error(`unexpected table ${table}`);
}),
},
};
const result = await listHandler(
ctx as never,
{ ownerUserId: "users:owner", limit: 10 } as never,
);
expect(result).toEqual([
expect.objectContaining({
slug: "blocked-skill",
moderationStatus: "hidden",
moderationVerdict: "malicious",
stats: expect.objectContaining({ downloads: 7, stars: 3 }),
}),
]);
});
it("does not expose non-public flagged skills to non-owner list callers", async () => {
vi.mocked(getAuthUserId).mockResolvedValue(null);
const blockedSkill = makeSkill({
slug: "blocked-skill",
moderationStatus: "hidden",
moderationFlags: ["blocked.malware"],
moderationReason: "scanner.vt.malicious",
moderationVerdict: "malicious",
});
const ctx = {
db: {
get: vi.fn(async (id: string) => {
if (id === "users:owner") {
return {
_id: "users:owner",
_creationTime: 1,
handle: "owner",
displayName: "Owner",
};
}
return null;
}),
query: vi.fn((table: string) => {
if (table === "skills") {
return {
withIndex: vi.fn(() => ({
order: vi.fn(() => ({
take: vi.fn().mockResolvedValue([blockedSkill]),
})),
})),
};
}
if (table === "skillBadges") {
return {
withIndex: vi.fn(() => ({
take: vi.fn().mockResolvedValue([]),
})),
};
}
throw new Error(`unexpected table ${table}`);
}),
},
};
const result = await listHandler(
ctx as never,
{ ownerUserId: "users:owner", limit: 10 } as never,
);
expect(result).toEqual([]);
});
});
+10
View File
@@ -57,6 +57,16 @@ function makeCtx(params: { skill: Record<string, unknown>; version?: Record<stri
};
}
if (table === "rescanRequests") {
return {
withIndex: vi.fn(() => ({
order: vi.fn(() => ({
take: vi.fn(async () => []),
})),
})),
};
}
throw new Error(`Unexpected query table: ${table}`);
});
const get = vi.fn(async (id: string) => {
+340 -118
View File
@@ -15,7 +15,14 @@ import {
mutation,
query,
} from "./functions";
import { assertAdmin, assertModerator, requireUser, requireUserFromAction } from "./lib/access";
import {
assertAdmin,
assertModerator,
getOptionalActiveAuthUserId,
getOptionalActiveAuthUserIdFromAction,
requireUser,
requireUserFromAction,
} from "./lib/access";
import { getSkillBadgeMap, getSkillBadgeMaps, isSkillHighlighted } from "./lib/badges";
import { scheduleNextBatchIfNeeded } from "./lib/batching";
import { generateChangelogPreview as buildChangelogPreview } from "./lib/changelog";
@@ -56,6 +63,7 @@ import {
toPublicUser,
} from "./lib/public";
import {
assertCanManageOwnedResource,
ensurePersonalPublisherForUser,
getOwnerPublisher,
requirePublisherRole,
@@ -80,7 +88,6 @@ import {
publishVersionForUser,
queueHighlightedWebhook,
} from "./lib/skillPublish";
import { runStaticPublishScan } from "./lib/staticPublishScan";
import { getFrontmatterValue, hashSkillFiles } from "./lib/skills";
import { computeIsSuspicious, isSkillSuspicious } from "./lib/skillSafety";
import {
@@ -89,7 +96,16 @@ import {
extractDigestFields,
upsertSkillSearchDigest,
} from "./lib/skillSearchDigest";
import { readCanonicalStat } from "./lib/skillStats";
import { runStaticPublishScan } from "./lib/staticPublishScan";
import { adjustUserSkillStatsForSkillChange } from "./lib/userSkillStats";
import {
assertCanRequestRescan,
buildRescanState,
errorMessage,
finalizeInProgressRescanRequestsForTarget,
} from "./model/rescans/policy";
import { getLatestSkillRescanTarget, insertSkillRescanRequest } from "./model/skills/rescans";
import schema from "./schema";
export { publishVersionForUser } from "./lib/skillPublish";
@@ -507,20 +523,14 @@ async function syncSkillModerationFromLatestVersion(
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
}
function buildConflictingSkillUrl(
skill: Doc<"skills">,
owner: SkillOwnerRef,
) {
function buildConflictingSkillUrl(skill: Doc<"skills">, owner: SkillOwnerRef) {
if (!owner || owner.deletedAt || owner.deactivatedAt || !isPublicSkillDoc(skill)) return null;
const ownerParam = owner.handle?.trim() || String(owner._id);
if (!ownerParam) return null;
return `/${encodeURIComponent(ownerParam)}/${encodeURIComponent(skill.slug)}`;
}
function buildSlugTakenErrorMessage(
skill: Doc<"skills">,
owner: SkillOwnerRef,
) {
function buildSlugTakenErrorMessage(skill: Doc<"skills">, owner: SkillOwnerRef) {
if (!owner || owner.deletedAt || owner.deactivatedAt) {
return (
"This slug is locked to a deleted or banned account. " +
@@ -533,10 +543,7 @@ function buildSlugTakenErrorMessage(
return `${base} Existing skill: ${url}`;
}
function buildAliasTakenErrorMessage(
skill: Doc<"skills">,
owner: SkillOwnerRef,
) {
function buildAliasTakenErrorMessage(skill: Doc<"skills">, owner: SkillOwnerRef) {
const base = "Slug redirects to an existing skill. Choose a different slug.";
const url = buildConflictingSkillUrl(skill, owner);
if (!url) return base;
@@ -1138,6 +1145,40 @@ type ManagementSkillEntry = {
owner: Doc<"users"> | null;
};
type DashboardSkillListItem = {
_id: Id<"skills">;
_creationTime: number;
slug: string;
displayName: string;
summary?: string;
ownerUserId: Id<"users">;
ownerPublisherId?: Id<"publishers">;
canonicalSkillId?: Id<"skills">;
forkOf?: Doc<"skills">["forkOf"];
latestVersionId?: Id<"skillVersions">;
tags: Doc<"skills">["tags"];
capabilityTags?: string[];
badges: Doc<"skills">["badges"];
stats: Doc<"skills">["stats"];
moderationStatus?: Doc<"skills">["moderationStatus"];
moderationReason?: string;
moderationVerdict?: Doc<"skills">["moderationVerdict"];
moderationFlags?: string[];
isSuspicious?: boolean;
pendingReview?: true;
qualityDecision?: NonNullable<Doc<"skills">["quality"]>["decision"];
rescanState: Awaited<ReturnType<typeof buildRescanState>> | null;
latestVersion: {
version: string;
createdAt: number;
vtStatus: string | null;
llmStatus: string | null;
staticScanStatus: "clean" | "suspicious" | "malicious" | null;
} | null;
createdAt: number;
updatedAt: number;
};
type BadgeKind = Doc<"skillBadges">["kind"];
async function buildPublicSkillEntries(
@@ -1368,6 +1409,66 @@ async function attachBadgesToSkills(ctx: QueryCtx, skills: Doc<"skills">[]) {
}));
}
async function toDashboardSkillListItem(
ctx: QueryCtx,
skill: Doc<"skills"> & { badges?: Doc<"skills">["badges"] },
): Promise<DashboardSkillListItem> {
const latestVersion = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null;
const stats = {
...skill.stats,
downloads: readCanonicalStat(skill, "downloads"),
stars: readCanonicalStat(skill, "stars"),
installsCurrent: readCanonicalStat(skill, "installsCurrent"),
installsAllTime: readCanonicalStat(skill, "installsAllTime"),
};
return {
_id: skill._id,
_creationTime: skill._creationTime,
slug: skill.slug,
displayName: skill.displayName,
summary: skill.summary,
ownerUserId: skill.ownerUserId,
ownerPublisherId: skill.ownerPublisherId,
canonicalSkillId: skill.canonicalSkillId,
forkOf: skill.forkOf,
latestVersionId: skill.latestVersionId,
tags: skill.tags,
capabilityTags: skill.capabilityTags,
badges: skill.badges,
stats,
moderationStatus: skill.moderationStatus,
moderationReason: skill.moderationReason,
moderationVerdict: skill.moderationVerdict,
moderationFlags: skill.moderationFlags,
isSuspicious: skill.isSuspicious,
pendingReview:
skill.moderationReason === "pending.scan" || skill.moderationReason === "pending.scan.stale"
? true
: undefined,
qualityDecision: skill.quality?.decision,
rescanState:
latestVersion && !latestVersion.softDeletedAt
? await buildRescanState(ctx, {
kind: "skill",
artifactId: latestVersion._id,
})
: null,
latestVersion:
latestVersion && !latestVersion.softDeletedAt
? {
version: latestVersion.version,
createdAt: latestVersion.createdAt,
vtStatus: latestVersion.vtAnalysis?.status ?? null,
llmStatus: latestVersion.llmAnalysis?.status ?? null,
staticScanStatus: latestVersion.staticScan?.status ?? null,
}
: null,
createdAt: skill.createdAt,
updatedAt: skill.updatedAt,
};
}
async function loadHighlightedSkills(ctx: QueryCtx, limit: number) {
const entries = await ctx.db
.query("skillBadges")
@@ -1442,7 +1543,7 @@ export const getBySlug = query({
const skill = resolved.skill;
if (!skill) return null;
const userId = await getAuthUserId(ctx);
const userId = await getOptionalActiveAuthUserId(ctx);
const ownerPublisher = await getOwnerPublisher(ctx, {
ownerPublisherId: skill.ownerPublisherId,
ownerUserId: skill.ownerUserId,
@@ -2130,7 +2231,7 @@ export const list = query({
}
const ownerPublisherId = args.ownerPublisherId;
if (ownerPublisherId) {
const userId = await getAuthUserId(ctx);
const userId = await getOptionalActiveAuthUserId(ctx);
const ownerPublisher = await ctx.db.get(ownerPublisherId);
const membership =
userId &&
@@ -2167,36 +2268,9 @@ export const list = query({
const withBadges = await attachBadgesToSkills(ctx, filtered);
if (isOwnDashboard) {
return withBadges
.map((skill) => {
const publicSkill = toPublicSkill(skill);
if (publicSkill) return publicSkill;
const isPending =
skill.moderationStatus === "hidden" && skill.moderationReason === "pending.scan";
if (isPending) {
const { badges } = skill;
return {
_id: skill._id,
_creationTime: skill._creationTime,
slug: skill.slug,
displayName: skill.displayName,
summary: skill.summary,
ownerUserId: skill.ownerUserId,
ownerPublisherId: skill.ownerPublisherId,
canonicalSkillId: skill.canonicalSkillId,
forkOf: skill.forkOf,
latestVersionId: skill.latestVersionId,
tags: skill.tags,
badges,
stats: skill.stats,
createdAt: skill.createdAt,
updatedAt: skill.updatedAt,
pendingReview: true as const,
};
}
return null;
})
.filter((skill): skill is NonNullable<typeof skill> => Boolean(skill));
return await Promise.all(
withBadges.map(async (skill) => await toDashboardSkillListItem(ctx, skill)),
);
}
const visibleSkills = await filterSkillsByActiveOwner(ctx, withBadges);
@@ -2206,7 +2280,7 @@ export const list = query({
}
const ownerUserId = args.ownerUserId;
if (ownerUserId) {
const userId = await getAuthUserId(ctx);
const userId = await getOptionalActiveAuthUserId(ctx);
const isOwnDashboard = Boolean(userId && userId === ownerUserId);
const entries = await ctx.db
.query("skills")
@@ -2217,38 +2291,9 @@ export const list = query({
const withBadges = await attachBadgesToSkills(ctx, filtered);
if (isOwnDashboard) {
// For owner's own dashboard, include pending skills
return withBadges
.map((skill) => {
const publicSkill = toPublicSkill(skill);
if (publicSkill) return publicSkill;
// Include pending skills for owner
const isPending =
skill.moderationStatus === "hidden" && skill.moderationReason === "pending.scan";
if (isPending) {
// Use computed badges from attachBadgesToSkills, not stored skill.badges
const { badges } = skill;
return {
_id: skill._id,
_creationTime: skill._creationTime,
slug: skill.slug,
displayName: skill.displayName,
summary: skill.summary,
ownerUserId: skill.ownerUserId,
canonicalSkillId: skill.canonicalSkillId,
forkOf: skill.forkOf,
latestVersionId: skill.latestVersionId,
tags: skill.tags,
badges,
stats: skill.stats,
createdAt: skill.createdAt,
updatedAt: skill.updatedAt,
pendingReview: true as const,
};
}
return null;
})
.filter((skill): skill is NonNullable<typeof skill> => Boolean(skill));
return await Promise.all(
withBadges.map(async (skill) => await toDashboardSkillListItem(ctx, skill)),
);
}
const visibleSkills = await filterSkillsByActiveOwner(ctx, withBadges);
@@ -2822,7 +2867,10 @@ export const listPublicPageV4 = query({
let scanInclusive = isFirstPage;
let hasMore = false;
let nextCursor: string | null = null;
let remainingRows = Math.max(numItems, Math.min(MAX_FILTERED_PUBLIC_LIST_SCAN_ROWS, numItems * 12));
let remainingRows = Math.max(
numItems,
Math.min(MAX_FILTERED_PUBLIC_LIST_SCAN_ROWS, numItems * 12),
);
for (let pageCount = 0; pageCount < MAX_FILTERED_PUBLIC_LIST_SCAN_PAGES; pageCount += 1) {
if (remainingRows <= 0) break;
@@ -3955,11 +4003,18 @@ export const updateSkillVersionStaticScanInternal = internalMutation({
},
handler: async (ctx, args) => {
const version = await ctx.db.get(args.versionId);
if (!version || version.skillId !== args.skillId) return { ok: true as const, skipped: "missing" as const };
if (!version || version.skillId !== args.skillId)
return { ok: true as const, skipped: "missing" as const };
await ctx.db.patch(version._id, {
staticScan: args.staticScan,
});
const updatedVersion = { ...version, staticScan: args.staticScan };
await finalizeInProgressRescanRequestsForTarget(
ctx,
{ kind: "skill", artifactId: version._id },
updatedVersion,
);
const skill = await ctx.db.get(args.skillId);
if (!skill) return { ok: true as const, skipped: "missing" as const };
@@ -3969,7 +4024,6 @@ export const updateSkillVersionStaticScanInternal = internalMutation({
const owner = skill.ownerUserId ? await ctx.db.get(skill.ownerUserId) : null;
const now = Date.now();
const updatedVersion = { ...version, staticScan: args.staticScan };
const basePatch = buildScannerModerationPatchFromVersion({
owner,
version: updatedVersion,
@@ -4001,37 +4055,39 @@ export const updateSkillVersionStaticScanInternal = internalMutation({
},
});
export const scanSkillVersionStaticallyInternal: ReturnType<typeof internalAction> = internalAction({
args: {
skillId: v.id("skills"),
versionId: v.id("skillVersions"),
export const scanSkillVersionStaticallyInternal: ReturnType<typeof internalAction> = internalAction(
{
args: {
skillId: v.id("skills"),
versionId: v.id("skillVersions"),
},
handler: async (ctx, args) => {
const [skill, version] = await Promise.all([
ctx.runQuery(internal.skills.getSkillByIdInternal, { skillId: args.skillId }),
ctx.runQuery(internal.skills.getVersionByIdInternal, { versionId: args.versionId }),
]);
if (!skill || !version) {
return { ok: true as const, skipped: "missing" as const };
}
const staticScan = await runStaticPublishScan(ctx, {
slug: skill.slug,
displayName: skill.displayName,
summary: skill.summary ?? undefined,
frontmatter: version.parsed?.frontmatter ?? {},
metadata: version.parsed?.metadata,
files: version.files,
});
return await ctx.runMutation(internal.skills.updateSkillVersionStaticScanInternal, {
skillId: skill._id,
versionId: version._id,
staticScan,
});
},
},
handler: async (ctx, args) => {
const [skill, version] = await Promise.all([
ctx.runQuery(internal.skills.getSkillByIdInternal, { skillId: args.skillId }),
ctx.runQuery(internal.skills.getVersionByIdInternal, { versionId: args.versionId }),
]);
if (!skill || !version) {
return { ok: true as const, skipped: "missing" as const };
}
const staticScan = await runStaticPublishScan(ctx, {
slug: skill.slug,
displayName: skill.displayName,
summary: skill.summary ?? undefined,
frontmatter: version.parsed?.frontmatter ?? {},
metadata: version.parsed?.metadata,
files: version.files,
});
return await ctx.runMutation(internal.skills.updateSkillVersionStaticScanInternal, {
skillId: skill._id,
versionId: version._id,
staticScan,
});
},
});
);
export const backfillSkillStaticScansInternal: ReturnType<typeof internalAction> = internalAction({
args: {
@@ -4041,10 +4097,13 @@ export const backfillSkillStaticScansInternal: ReturnType<typeof internalAction>
},
handler: async (ctx, args) => {
const batchSize = Math.max(1, Math.min(args.batchSize ?? 25, 100));
const batch = await ctx.runQuery(internal.skills.getActiveSkillBatchForStaticScanBackfillInternal, {
cursor: args.cursor,
batchSize,
});
const batch = await ctx.runQuery(
internal.skills.getActiveSkillBatchForStaticScanBackfillInternal,
{
cursor: args.cursor,
batchSize,
},
);
let rescanned = args.rescanned ?? 0;
for (const skill of batch.skills) {
@@ -4084,6 +4143,156 @@ export const backfillSkillStaticScans: ReturnType<typeof action> = action({
},
});
async function markSkillRescanRequest(
ctx: { runMutation: (ref: never, args: never) => Promise<unknown> },
requestId: Id<"rescanRequests">,
status: "completed" | "failed",
error?: string,
) {
await ctx.runMutation(
internal.rescanRequests.markStatusInternal as never,
{
requestId,
status,
error,
} as never,
);
}
export const getRescanState = query({
args: {
skillId: v.id("skills"),
},
handler: async (ctx, args) => {
const { user } = await requireUser(ctx);
const target = await getLatestSkillRescanTarget(ctx, args.skillId);
await assertCanManageOwnedResource(ctx, {
actor: user,
ownerUserId: target.skill.ownerUserId,
ownerPublisherId: target.skill.ownerPublisherId,
allowPlatformAdmin: true,
});
return {
targetKind: "skill" as const,
targetVersion: target.version.version,
skillVersionId: target.version._id,
...(await buildRescanState(ctx, {
kind: "skill",
artifactId: target.version._id,
})),
};
},
});
export const requestRescan = mutation({
args: {
skillId: v.id("skills"),
},
handler: async (ctx, args) => {
const { user } = await requireUser(ctx);
const target = await getLatestSkillRescanTarget(ctx, args.skillId);
await assertCanManageOwnedResource(ctx, {
actor: user,
ownerUserId: target.skill.ownerUserId,
ownerPublisherId: target.skill.ownerPublisherId,
allowPlatformAdmin: true,
});
await assertCanRequestRescan(ctx, {
kind: "skill",
artifactId: target.version._id,
});
const requestId = await insertSkillRescanRequest(ctx, user, target);
await ctx.scheduler.runAfter(0, internal.skills.dispatchSkillRescanInternal, {
requestId,
skillId: target.skill._id,
versionId: target.version._id,
});
return {
requestId,
...(await buildRescanState(ctx, {
kind: "skill",
artifactId: target.version._id,
})),
};
},
});
export const requestRescanForApiTokenInternal = internalMutation({
args: {
actorUserId: v.id("users"),
slug: v.string(),
},
handler: async (ctx, args) => {
const actor = await ctx.db.get(args.actorUserId);
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
const resolved = await resolveSkillBySlugOrAlias(ctx, args.slug.trim().toLowerCase());
const skill = resolved.skill;
if (!skill) throw new ConvexError("Skill not found");
const target = await getLatestSkillRescanTarget(ctx, skill._id);
await assertCanManageOwnedResource(ctx, {
actor,
ownerUserId: target.skill.ownerUserId,
ownerPublisherId: target.skill.ownerPublisherId,
allowPlatformAdmin: true,
});
await assertCanRequestRescan(ctx, {
kind: "skill",
artifactId: target.version._id,
});
const requestId = await insertSkillRescanRequest(ctx, actor, target);
await ctx.scheduler.runAfter(0, internal.skills.dispatchSkillRescanInternal, {
requestId,
skillId: target.skill._id,
versionId: target.version._id,
});
const state = await buildRescanState(ctx, {
kind: "skill",
artifactId: target.version._id,
});
return {
ok: true,
targetKind: "skill" as const,
name: target.skill.slug,
version: target.version.version,
status: state.inProgressRequest?.status ?? state.latestRequest?.status ?? "in_progress",
remainingRequests: state.remainingRequests,
maxRequests: state.maxRequests,
pendingRequestId: requestId,
};
},
});
export const dispatchSkillRescanInternal: ReturnType<typeof internalAction> = internalAction({
args: {
requestId: v.id("rescanRequests"),
skillId: v.id("skills"),
versionId: v.id("skillVersions"),
},
handler: async (ctx, args) => {
try {
await ctx.runAction(internal.skills.scanSkillVersionStaticallyInternal, {
skillId: args.skillId,
versionId: args.versionId,
});
await ctx.runAction(internal.vt.scanWithVirusTotal, {
versionId: args.versionId,
});
await ctx.runAction(internal.llmEval.evaluateWithLlm, {
versionId: args.versionId,
});
} catch (error) {
await markSkillRescanRequest(ctx, args.requestId, "failed", errorMessage(error));
throw error;
}
},
});
/**
* Emergency escalation by skillId for legacy rows without sha256hash.
* Rebuilds the full moderation snapshot so legacy rows stay in sync with structured fields.
@@ -4612,6 +4821,11 @@ export const updateVersionScanResultsInternal = internalMutation({
if (Object.keys(patch).length > 0) {
await ctx.db.patch(args.versionId, patch);
await finalizeInProgressRescanRequestsForTarget(
ctx,
{ kind: "skill", artifactId: args.versionId },
{ ...version, ...patch },
);
}
},
});
@@ -4645,6 +4859,11 @@ export const updateVersionLlmAnalysisInternal = internalMutation({
if (!version) return;
const nextVersion = { ...version, llmAnalysis: args.llmAnalysis };
await ctx.db.patch(args.versionId, { llmAnalysis: args.llmAnalysis });
await finalizeInProgressRescanRequestsForTarget(
ctx,
{ kind: "skill", artifactId: version._id },
nextVersion,
);
const skill = await ctx.db.get(version.skillId);
if (!skill || skill.latestVersionId !== version._id) return;
@@ -5014,7 +5233,7 @@ async function canReadSkillVersionFiles(ctx: ActionCtx, version: Doc<"skillVersi
})) as Doc<"skills"> | null;
if (!skill) return false;
const authUserId = await getAuthUserId(ctx);
const authUserId = await getOptionalActiveAuthUserIdFromAction(ctx);
if (authUserId) {
if (authUserId === skill.ownerUserId && !skill.softDeletedAt && !version.softDeletedAt) {
return true;
@@ -5485,7 +5704,10 @@ export const changeOwner = mutation({
lastReviewedAt: now,
updatedAt: now,
});
await adjustUserSkillStatsForSkillChange(ctx, skill, { ...skill, ownerUserId: args.ownerUserId });
await adjustUserSkillStatsForSkillChange(ctx, skill, {
...skill,
ownerUserId: args.ownerUserId,
});
const embeddings = await listSkillEmbeddingsForSkill(ctx, skill._id);
for (const embedding of embeddings) {
+15 -5
View File
@@ -3,15 +3,20 @@ import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server";
import { internalAction, internalMutation, internalQuery, mutation, query } from "./functions";
import { assertAdmin, assertModerator, getOptionalActiveAuthUserId, requireUser } from "./lib/access";
import {
assertAdmin,
assertModerator,
getOptionalActiveAuthUserId,
requireUser,
} from "./lib/access";
import { syncGitHubProfile } from "./lib/githubAccount";
import { toPublicUser } from "./lib/public";
import {
ensurePersonalPublisherForUser,
getActiveUserByHandleOrPersonalPublisher,
getPublisherByHandle,
getUserByHandleOrPersonalPublisher,
} from "./lib/publishers";
import { toPublicUser } from "./lib/public";
import {
getLatestActiveReservedHandle,
isHandleReservedForAnotherUser,
@@ -296,7 +301,9 @@ export async function ensureHandler(ctx: MutationCtx) {
updates.updatedAt = Date.now();
await ctx.db.patch(userId, updates);
}
const ensuredUser = hasUpdates ? ({ ...user, ...updates } as Doc<"users">) : ((await ctx.db.get(userId)) ?? user);
const ensuredUser = hasUpdates
? ({ ...user, ...updates } as Doc<"users">)
: ((await ctx.db.get(userId)) ?? user);
await ensurePersonalPublisherForUser(ctx, ensuredUser);
return await ctx.db.get(userId);
}
@@ -445,7 +452,9 @@ async function queryUsersForPublicList(
: clampInt(args.limit * 6, args.limit, MAX_USER_SEARCH_SCAN);
const scannedUsers = await ctx.db
.query("users")
.withIndex("by_active_handle", (q) => q.eq("deletedAt", undefined).eq("deactivatedAt", undefined))
.withIndex("by_active_handle", (q) =>
q.eq("deletedAt", undefined).eq("deactivatedAt", undefined),
)
.order("desc")
.take(scanLimit);
const activeUsers = scannedUsers.filter((user) => Boolean(user.handle));
@@ -899,7 +908,8 @@ async function ensurePublisherHandleWithActor(
if (existing) {
const nextDisplayName =
args.displayName?.trim() && (!existing.displayName || existing.displayName === existing.handle)
args.displayName?.trim() &&
(!existing.displayName || existing.displayName === existing.handle)
? displayName
: existing.displayName;
await ctx.db.patch(existing._id, {
+2
View File
@@ -92,6 +92,7 @@ describe("version file access actions", () => {
it("allows owners to read hidden skill versions", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
const ctx = makeActionCtx({
actor: { _id: "users:owner", role: "user" },
version: makeSkillVersion(),
skill: {
_id: "skills:1",
@@ -133,6 +134,7 @@ describe("version file access actions", () => {
it("allows owners to read hidden skill files", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
const ctx = makeActionCtx({
actor: { _id: "users:owner", role: "user" },
version: makeSkillVersion(),
skill: {
_id: "skills:1",
+15
View File
@@ -782,6 +782,11 @@ export const pollPackageReleaseScanResults = internalAction({
attempt: attempt + 1,
},
);
} else {
await runMutationRef(ctx, internalRefs.packages.updateReleaseScanResultsInternal, {
releaseId: args.releaseId,
vtAnalysis: { status: "stale", checkedAt: Date.now() },
});
}
return;
}
@@ -806,6 +811,11 @@ export const pollPackageReleaseScanResults = internalAction({
attempt: attempt + 1,
},
);
} else {
await runMutationRef(ctx, internalRefs.packages.updateReleaseScanResultsInternal, {
releaseId: args.releaseId,
vtAnalysis: { status: "stale", checkedAt: Date.now() },
});
}
} catch (error) {
console.error(`[vt:package] Error polling ${release.sha256hash}:`, error);
@@ -819,6 +829,11 @@ export const pollPackageReleaseScanResults = internalAction({
attempt: attempt + 1,
},
);
} else {
await runMutationRef(ctx, internalRefs.packages.updateReleaseScanResultsInternal, {
releaseId: args.releaseId,
vtAnalysis: { status: "error", checkedAt: Date.now() },
});
}
}
},
+138
View File
@@ -33,6 +33,14 @@ function mustGetToken() {
return null;
}
function getAdminToken() {
return process.env.CLAWHUB_E2E_ADMIN_TOKEN?.trim() || null;
}
function getUserToken() {
return process.env.CLAWHUB_E2E_USER_TOKEN?.trim() || null;
}
function getRegistry() {
return (
process.env.CLAWHUB_REGISTRY?.trim() ||
@@ -73,7 +81,19 @@ function allowLiveMutations() {
return value === "1" || value?.toLowerCase() === "true";
}
function shouldSeedRoleHelpTokens() {
const value = process.env.CLAWHUB_E2E_SEED_CLI_ROLE_HELP?.trim();
return value === "1" || value?.toLowerCase() === "true";
}
const itIfLiveMutations = allowLiveMutations() ? it : it.skip;
const itIfAdminAndUserTokens =
getAdminToken() && getUserToken() || shouldSeedRoleHelpTokens() ? it : it.skip;
type RoleHelpTokens = {
adminToken: string;
userToken: string;
};
async function makeTempConfig(registry: string, token: string | null) {
const dir = await mkdtemp(join(tmpdir(), "clawhub-e2e-"));
@@ -86,6 +106,67 @@ async function makeTempConfig(registry: string, token: string | null) {
return { dir, path };
}
async function resolveRoleHelpTokens(registry: string): Promise<RoleHelpTokens> {
const adminToken = getAdminToken();
const userToken = getUserToken();
if (adminToken && userToken) return { adminToken, userToken };
if (!shouldSeedRoleHelpTokens()) {
throw new Error(
"Missing CLAWHUB_E2E_ADMIN_TOKEN/CLAWHUB_E2E_USER_TOKEN or CLAWHUB_E2E_SEED_CLI_ROLE_HELP=1",
);
}
if (!isLocalRegistry(registry)) {
throw new Error("CLAWHUB_E2E_SEED_CLI_ROLE_HELP=1 only works against local registries");
}
const result = spawnSync(
"bunx",
["convex", "run", "--no-push", "devSeed:seedCliRoleHelpFixtures"],
{
cwd: process.cwd(),
encoding: "utf8",
env: process.env,
},
);
if (result.status !== 0) {
throw new Error(`Failed to seed role help fixtures:\n${result.stderr || result.stdout}`);
}
const parsed = JSON.parse(extractLastJsonObject(result.stdout)) as {
admin?: { token?: unknown };
user?: { token?: unknown };
};
if (typeof parsed.admin?.token !== "string" || typeof parsed.user?.token !== "string") {
throw new Error("Role help fixture seed did not return admin and user tokens");
}
return { adminToken: parsed.admin.token, userToken: parsed.user.token };
}
function isLocalRegistry(registry: string) {
try {
const hostname = new URL(registry).hostname;
return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
} catch {
return false;
}
}
function extractLastJsonObject(output: string) {
const trimmed = output.trim();
for (let index = 0; index < trimmed.length; index += 1) {
if (trimmed[index] !== "{") continue;
const candidate = trimmed.slice(index);
try {
JSON.parse(candidate);
return candidate;
} catch {
// Keep scanning for the actual JSON payload if Convex printed status lines first.
}
}
throw new Error(`No JSON object in convex run output:\n${output}`);
}
async function fetchWithTimeout(input: RequestInfo | URL, init?: RequestInit) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(new Error("Timeout")), REQUEST_TIMEOUT_MS);
@@ -197,6 +278,63 @@ describe("clawhub e2e", () => {
}
});
itIfAdminAndUserTokens("shows staff CLI commands only in admin help", async () => {
const registry = getRegistry();
const site = getSite();
const { adminToken, userToken } = await resolveRoleHelpTokens(registry);
async function expectRole(token: string, expectedRole: "admin" | "user") {
const whoamiUrl = new URL(ApiRoutes.whoami, registry);
const response = await fetchWithTimeout(whoamiUrl.toString(), {
headers: { Accept: "application/json", Authorization: `Bearer ${token}` },
});
expect(response.ok).toBe(true);
const whoami = parseArk(
ApiV1WhoamiResponseSchema,
(await response.json()) as unknown,
"Whoami",
);
expect(whoami.user.role).toBe(expectedRole);
}
await expectRole(adminToken, "admin");
await expectRole(userToken, "user");
const adminCfg = await makeTempConfig(registry, adminToken);
const userCfg = await makeTempConfig(registry, userToken);
try {
const baseEnv = { ...process.env, CLAWHUB_DISABLE_TELEMETRY: "1" };
const adminResult = spawnSync(
"bun",
["clawhub", "--registry", registry, "--site", site, "--help"],
{
cwd: process.cwd(),
env: { ...baseEnv, CLAWHUB_CONFIG_PATH: adminCfg.path },
encoding: "utf8",
},
);
const userResult = spawnSync(
"bun",
["clawhub", "--registry", registry, "--site", site, "--help"],
{
cwd: process.cwd(),
env: { ...baseEnv, CLAWHUB_CONFIG_PATH: userCfg.path },
encoding: "utf8",
},
);
expect(adminResult.status).toBe(0);
expect(adminResult.stdout).toContain("ban-user");
expect(adminResult.stdout).toContain("set-role");
expect(userResult.status).toBe(0);
expect(userResult.stdout).not.toContain("ban-user");
expect(userResult.stdout).not.toContain("set-role");
} finally {
await rm(adminCfg.dir, { recursive: true, force: true });
await rm(userCfg.dir, { recursive: true, force: true });
}
});
it("sync dry-run finds skills from an explicit root", async () => {
const registry = getRegistry();
const site = getSite();
+29 -2
View File
@@ -2,6 +2,7 @@
import { stat } from "node:fs/promises";
import { join, resolve } from "node:path";
import { Command } from "commander";
import { shouldShowAdminCommandsInHelp } from "./cli/adminHelp.js";
import { getCliBuildLabel, getCliVersion } from "./cli/buildInfo.js";
import { resolveClawdbotDefaultWorkspace } from "./cli/clawdbotConfig.js";
import { cmdLoginFlow, cmdLogout, cmdWhoami } from "./cli/commands/auth.js";
@@ -23,6 +24,7 @@ import {
cmdSetPackageTrustedPublisher,
} from "./cli/commands/packages.js";
import { cmdPublish } from "./cli/commands/publish.js";
import { cmdRescanPackage, cmdRescanSkill } from "./cli/commands/rescan.js";
import {
cmdExplore,
cmdInstall,
@@ -47,6 +49,9 @@ import type { GlobalOpts } from "./cli/types.js";
import { fail } from "./cli/ui.js";
import { readGlobalConfig } from "./config.js";
const showAdminCommandsInHelp = await shouldShowAdminCommandsInHelp();
const adminCommandOptions = showAdminCommandsInHelp ? undefined : { hidden: true };
const program = new Command()
.name("clawhub")
.description(
@@ -451,6 +456,17 @@ trustedPublisherCmd
await cmdDeletePackageTrustedPublisher(opts, name, options);
});
packageCmd
.command("rescan")
.description("Request a security rescan for the latest published package release")
.argument("<name>", "Package name")
.option("--yes", "Skip confirmation")
.option("--json", "Output JSON")
.action(async (name, options) => {
const opts = await resolveGlobalOpts();
await cmdRescanPackage(opts, name, options, isInputAllowed());
});
skill
.command("rename")
.description("Rename a published skill and keep the old slug as a redirect")
@@ -473,8 +489,19 @@ skill
await cmdMergeSkill(opts, sourceSlug, targetSlug, options, isInputAllowed());
});
skill
.command("rescan")
.description("Request a security rescan for the latest published skill version")
.argument("<slug>", "Skill slug")
.option("--yes", "Skip confirmation")
.option("--json", "Output JSON")
.action(async (slug, options) => {
const opts = await resolveGlobalOpts();
await cmdRescanSkill(opts, slug, options, isInputAllowed());
});
program
.command("ban-user")
.command("ban-user", adminCommandOptions)
.description("Ban a user and delete owned skills (moderator/admin only)")
.argument("<handleOrId>", "User handle (default) or user id")
.option("--id", "Treat argument as user id")
@@ -487,7 +514,7 @@ program
});
program
.command("set-role")
.command("set-role", adminCommandOptions)
.description("Change a user role (admin only)")
.argument("<handleOrId>", "User handle (default) or user id")
.argument("<role>", "user | moderator | admin")
@@ -0,0 +1,87 @@
/* @vitest-environment node */
import { afterEach, describe, expect, it, vi } from "vitest";
const mockReadGlobalConfig = vi.fn(
async () => null as { registry?: string; token?: string } | null,
);
vi.mock("../config.js", () => ({
readGlobalConfig: () => mockReadGlobalConfig(),
}));
const mockApiRequest = vi.fn();
vi.mock("../http.js", () => ({
apiRequest: (...args: unknown[]) => mockApiRequest(...args),
}));
const { isHelpRequest, shouldShowAdminCommandsInHelp } = await import("./adminHelp");
afterEach(() => {
vi.clearAllMocks();
});
describe("admin help gating", () => {
it("treats root help-like invocations as help requests", () => {
expect(isHelpRequest(["node", "clawhub", "--help"])).toBe(true);
expect(isHelpRequest(["node", "clawhub", "help"])).toBe(true);
expect(isHelpRequest(["node", "clawhub"])).toBe(true);
expect(isHelpRequest(["node", "clawhub", "search", "weather"])).toBe(false);
});
it("does not hide commands for normal command execution", async () => {
await expect(
shouldShowAdminCommandsInHelp({
argv: ["node", "clawhub", "ban-user", "demo"],
}),
).resolves.toBe(true);
expect(mockReadGlobalConfig).not.toHaveBeenCalled();
});
it("hides admin commands from help when logged out", async () => {
mockReadGlobalConfig.mockResolvedValueOnce(null);
await expect(
shouldShowAdminCommandsInHelp({
argv: ["node", "clawhub", "--help"],
}),
).resolves.toBe(false);
expect(mockApiRequest).not.toHaveBeenCalled();
});
it("shows admin commands in help for stored admin tokens", async () => {
mockReadGlobalConfig.mockResolvedValueOnce({
registry: "https://registry.example",
token: "clh_admin",
});
mockApiRequest.mockResolvedValueOnce({
user: { handle: "p", role: "admin" },
});
await expect(
shouldShowAdminCommandsInHelp({
argv: ["node", "clawhub", "--help"],
}),
).resolves.toBe(true);
expect(mockApiRequest).toHaveBeenCalledWith(
"https://registry.example",
expect.objectContaining({ path: "/api/v1/whoami", token: "clh_admin" }),
expect.anything(),
);
});
it("hides admin commands in help for non-admin tokens", async () => {
mockReadGlobalConfig.mockResolvedValueOnce({
registry: "https://registry.example",
token: "clh_user",
});
mockApiRequest.mockResolvedValueOnce({
user: { handle: "p", role: "moderator" },
});
await expect(
shouldShowAdminCommandsInHelp({
argv: ["node", "clawhub", "--help"],
}),
).resolves.toBe(false);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { readGlobalConfig } from "../config.js";
import { apiRequest } from "../http.js";
import { ApiRoutes, ApiV1WhoamiResponseSchema } from "../schema/index.js";
import { DEFAULT_REGISTRY } from "./registry.js";
type AdminHelpDeps = {
argv?: string[];
env?: NodeJS.ProcessEnv;
};
export function isHelpRequest(argv: string[] = process.argv) {
const args = argv.slice(2);
return args.length === 0 || args[0] === "help" || args.includes("--help") || args.includes("-h");
}
export async function shouldShowAdminCommandsInHelp(deps: AdminHelpDeps = {}) {
if (!isHelpRequest(deps.argv)) return true;
const env = deps.env ?? process.env;
const cfg = await readGlobalConfig();
const token = cfg?.token?.trim();
if (!token) return false;
const registry =
readFlagValue(deps.argv ?? process.argv, "--registry")?.trim() ||
env.CLAWHUB_REGISTRY?.trim() ||
env.CLAWDHUB_REGISTRY?.trim() ||
cfg?.registry?.trim() ||
DEFAULT_REGISTRY;
try {
const whoami = await apiRequest(
registry,
{ method: "GET", path: ApiRoutes.whoami, token },
ApiV1WhoamiResponseSchema,
);
return whoami.user.role === "admin";
} catch {
return false;
}
}
function readFlagValue(argv: string[], flag: string) {
const args = argv.slice(2);
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === flag) return args[index + 1];
if (arg?.startsWith(`${flag}=`)) return arg.slice(flag.length + 1);
}
return undefined;
}
@@ -0,0 +1,91 @@
/* @vitest-environment node */
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createAuthTokenModuleMocks,
createHttpModuleMocks,
createRegistryModuleMocks,
createUiModuleMocks,
makeGlobalOpts,
} from "../../../test/cliCommandTestKit.js";
const authTokenMocks = createAuthTokenModuleMocks();
const registryMocks = createRegistryModuleMocks();
const httpMocks = createHttpModuleMocks();
const uiMocks = createUiModuleMocks();
vi.mock("../authToken.js", () => authTokenMocks.moduleFactory());
vi.mock("../registry.js", () => registryMocks.moduleFactory());
vi.mock("../../http.js", () => httpMocks.moduleFactory());
vi.mock("../ui.js", () => uiMocks.moduleFactory());
const { cmdRescanPackage, cmdRescanSkill } = await import("./rescan");
const mockLog = vi.spyOn(console, "log").mockImplementation(() => {});
const response = {
ok: true,
targetKind: "skill",
name: "demo",
version: "1.2.3",
status: "in_progress",
remainingRequests: 2,
maxRequests: 3,
pendingRequestId: "rescanRequests:1",
};
afterEach(() => {
vi.clearAllMocks();
mockLog.mockClear();
});
describe("rescan commands", () => {
it("requires --yes when input is disabled", async () => {
await expect(cmdRescanSkill(makeGlobalOpts(), "demo", {}, false)).rejects.toThrow(/--yes/i);
await expect(cmdRescanPackage(makeGlobalOpts(), "demo", {}, false)).rejects.toThrow(/--yes/i);
expect(httpMocks.apiRequest).not.toHaveBeenCalled();
});
it("posts skill rescans to the skill rescan endpoint", async () => {
httpMocks.apiRequest.mockResolvedValueOnce(response);
await cmdRescanSkill(makeGlobalOpts(), "Demo", { yes: true }, false);
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ method: "POST", path: "/api/v1/skills/demo/rescan" }),
expect.anything(),
);
expect(uiMocks.spinner.succeed).toHaveBeenCalledWith(
"OK. Requested skill rescan for demo@1.2.3 (2/3 remaining)",
);
});
it("posts package rescans to the package rescan endpoint", async () => {
httpMocks.apiRequest.mockResolvedValueOnce({
...response,
targetKind: "package",
name: "@scope/demo",
});
await cmdRescanPackage(makeGlobalOpts(), "@scope/demo", { yes: true }, false);
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
method: "POST",
path: "/api/v1/packages/%40scope%2Fdemo/rescan",
}),
expect.anything(),
);
});
it("prints JSON output", async () => {
httpMocks.apiRequest.mockResolvedValueOnce(response);
await cmdRescanSkill(makeGlobalOpts(), "demo", { yes: true, json: true }, false);
expect(uiMocks.spinner.stop).toHaveBeenCalled();
expect(mockLog).toHaveBeenCalledWith(JSON.stringify(response, null, 2));
});
});
@@ -0,0 +1,91 @@
import { apiRequest } from "../../http.js";
import {
ApiRoutes,
ApiV1RescanResponseSchema,
parseArk,
type ApiV1RescanResponse,
} from "../../schema/index.js";
import { requireAuthToken } from "../authToken.js";
import { getRegistry } from "../registry.js";
import type { GlobalOpts } from "../types.js";
import { createSpinner, fail, formatError, isInteractive, promptConfirm } from "../ui.js";
type RescanTargetKind = "skill" | "package";
type RescanOptions = {
yes?: boolean;
json?: boolean;
};
export async function cmdRescanSkill(
opts: GlobalOpts,
slugArg: string,
options: RescanOptions,
inputAllowed: boolean,
) {
const slug = slugArg.trim().toLowerCase();
if (!slug) fail("Slug required");
return requestRescan(opts, "skill", slug, options, inputAllowed);
}
export async function cmdRescanPackage(
opts: GlobalOpts,
nameArg: string,
options: RescanOptions,
inputAllowed: boolean,
) {
const name = nameArg.trim();
if (!name) fail("Package name required");
return requestRescan(opts, "package", name, options, inputAllowed);
}
async function requestRescan(
opts: GlobalOpts,
targetKind: RescanTargetKind,
name: string,
options: RescanOptions,
inputAllowed: boolean,
) {
const allowPrompt = isInteractive() && inputAllowed !== false;
if (!options.yes) {
if (!allowPrompt) fail("Pass --yes (no input)");
const ok = await promptConfirm(`Request latest ${targetKind} rescan for ${name}?`);
if (!ok) return undefined;
}
const token = await requireAuthToken();
const registry = await getRegistry(opts, { cache: true });
const spinner = createSpinner(`Requesting ${targetKind} rescan for ${name}`);
try {
const result = await apiRequest(
registry,
{
method: "POST",
path: buildRescanPath(targetKind, name),
token,
},
ApiV1RescanResponseSchema,
);
const parsed = parseArk(ApiV1RescanResponseSchema, result, "Rescan response");
if (options.json) {
spinner.stop();
console.log(JSON.stringify(parsed, null, 2));
} else {
spinner.succeed(formatRescanSuccess(parsed));
}
return parsed;
} catch (error) {
spinner.fail(formatError(error));
throw error;
}
}
function buildRescanPath(targetKind: RescanTargetKind, name: string) {
const root = targetKind === "skill" ? ApiRoutes.skills : ApiRoutes.packages;
return `${root}/${encodeURIComponent(name)}/rescan`;
}
function formatRescanSuccess(result: ApiV1RescanResponse) {
const label = result.targetKind === "skill" ? "skill" : "package";
return `OK. Requested ${label} rescan for ${result.name}@${result.version} (${result.remainingRequests}/${result.maxRequests} remaining)`;
}
+13
View File
@@ -134,6 +134,7 @@ export const ApiV1WhoamiResponseSchema = type({
handle: "string|null",
displayName: "string|null?",
image: "string|null?",
role: '"admin"|"moderator"|"user"|null?',
},
});
@@ -273,6 +274,18 @@ export const ApiV1DeleteResponseSchema = type({
ok: "true",
});
export const ApiV1RescanResponseSchema = type({
ok: "true",
targetKind: '"skill"|"package"',
name: "string",
version: "string",
status: '"in_progress"|"completed"|"failed"',
remainingRequests: "number",
maxRequests: "number",
pendingRequestId: "string?",
});
export type ApiV1RescanResponse = (typeof ApiV1RescanResponseSchema)[inferred];
export const ApiV1SkillRenameResponseSchema = type({
ok: "true",
slug: "string",
+1
View File
@@ -134,6 +134,7 @@ export declare const ApiV1WhoamiResponseSchema: import("arktype/internal/variant
handle: string | null;
displayName?: string | null | undefined;
image?: string | null | undefined;
role?: "user" | "admin" | "moderator" | null | undefined;
};
}, {}>;
export declare const ApiV1UserSearchResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
+1
View File
@@ -110,6 +110,7 @@ export const ApiV1WhoamiResponseSchema = type({
handle: "string|null",
displayName: "string|null?",
image: "string|null?",
role: '"admin"|"moderator"|"user"|null?',
},
});
export const ApiV1UserSearchResponseSchema = type({
File diff suppressed because one or more lines are too long
+13
View File
@@ -135,6 +135,7 @@ export const ApiV1WhoamiResponseSchema = type({
handle: "string|null",
displayName: "string|null?",
image: "string|null?",
role: '"admin"|"moderator"|"user"|null?',
},
});
@@ -282,6 +283,18 @@ export const ApiV1DeleteResponseSchema = type({
ok: "true",
});
export const ApiV1RescanResponseSchema = type({
ok: "true",
targetKind: '"skill"|"package"',
name: "string",
version: "string",
status: '"in_progress"|"completed"|"failed"',
remainingRequests: "number",
maxRequests: "number",
pendingRequestId: "string?",
});
export type ApiV1RescanResponse = (typeof ApiV1RescanResponseSchema)[inferred];
export const ApiV1SkillRenameResponseSchema = type({
ok: "true",
slug: "string",
+11 -6
View File
@@ -124,7 +124,7 @@ describe("Header", () => {
expect(screen.queryByText("Packages")).toBeNull();
});
it("renders direct desktop theme family controls and plain Skills tab", () => {
it("renders simplified desktop nav and theme toggle", () => {
siteModeMock.mockReturnValue("skills");
setThemeMock.mockClear();
setModeMock.mockClear();
@@ -132,22 +132,27 @@ describe("Header", () => {
render(<Header />);
expect(
screen.getByRole("button", { name: /Cycle theme mode/i }),
screen.getByRole("button", { name: /Toggle theme\. Current: system/i }),
).toBeTruthy();
expect(screen.getAllByText("Skills")).toHaveLength(1);
expect(screen.getAllByText("Users")).toHaveLength(1);
expect(screen.getAllByText("Plugins")).toHaveLength(1);
expect(screen.queryByText("Users")).toBeNull();
expect(screen.queryByText("Dashboard")).toBeNull();
expect(screen.queryByText("Manage")).toBeNull();
expect(
screen.getByPlaceholderText("Search skills, plugins, users"),
).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /Cycle theme mode/i }));
expect(setModeMock).toHaveBeenCalledWith("light");
fireEvent.click(
screen.getByRole("button", { name: /Toggle theme\. Current: system/i }),
);
expect(setModeMock).toHaveBeenCalledWith("dark");
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
expect(screen.getAllByText("Home")).toHaveLength(1);
expect(screen.getAllByText("Skills")).toHaveLength(2);
expect(screen.getAllByText("Users")).toHaveLength(2);
expect(screen.getAllByText("Plugins")).toHaveLength(2);
});
it("shows Home above Skills in the mobile menu", () => {
+97 -9
View File
@@ -15,17 +15,17 @@ const isRateLimitedPackageApiErrorMock = vi.fn(
(error: unknown) =>
typeof error === "object" && error !== null && (error as { status?: number }).status === 429,
);
const useQueryMock = vi.fn();
const useAuthStatusMock = vi.fn();
type PluginDetailLoaderData = {
detail: PackageDetailResponse;
version: PackageVersionDetail | null;
readme: string | null;
rateLimited:
| {
scope: "detail" | "metadata";
retryAfterSeconds: number | null;
}
| null;
rateLimited: {
scope: "detail" | "metadata";
retryAfterSeconds: number | null;
} | null;
};
let paramsMock = { name: "demo-plugin" };
@@ -59,6 +59,14 @@ vi.mock("@tanstack/react-router", () => ({
useParams: () => paramsMock,
useLoaderData: () => loaderDataMock,
}),
useRouterState: ({
select,
}: {
select?: (state: { location: { pathname: string } }) => string;
}) =>
select
? select({ location: { pathname: `/plugins/${paramsMock.name}` } })
: `/plugins/${paramsMock.name}`,
Link: ({
children,
to,
@@ -73,6 +81,15 @@ vi.mock("@tanstack/react-router", () => ({
),
}));
vi.mock("convex/react", () => ({
useQuery: (...args: unknown[]) => useQueryMock(...args),
useMutation: () => vi.fn(),
}));
vi.mock("../lib/useAuthStatus", () => ({
useAuthStatus: () => useAuthStatusMock(),
}));
vi.mock("../lib/packageApi", () => ({
fetchPackageDetail: vi.fn(),
fetchPackageReadme: vi.fn(),
@@ -86,7 +103,13 @@ vi.mock("../lib/packageApi", () => ({
}));
vi.mock("../components/MarkdownPreview", () => ({
MarkdownPreview: ({ children }: { children: string; className?: string; highlight?: boolean }) => <div>{children}</div>,
MarkdownPreview: ({
children,
}: {
children: string;
className?: string;
highlight?: boolean;
}) => <div>{children}</div>,
}));
async function loadRoute() {
@@ -128,6 +151,14 @@ describe("plugin detail route", () => {
rateLimited: null,
};
isRateLimitedPackageApiErrorMock.mockClear();
useQueryMock.mockReset();
useQueryMock.mockReturnValue(undefined);
useAuthStatusMock.mockReset();
useAuthStatusMock.mockReturnValue({
isAuthenticated: false,
isLoading: false,
me: null,
});
});
it("hides download actions when the plugin has no latest release", async () => {
@@ -188,9 +219,66 @@ describe("plugin detail route", () => {
render(<Component />);
expect(screen.getByText("Security Scan")).toBeTruthy();
expect(screen.getByText("Security Scans")).toBeTruthy();
expect(screen.getAllByText("VirusTotal").length).toBeGreaterThan(0);
expect(screen.getAllByText("OpenClaw").length).toBeGreaterThan(0);
expect(screen.getAllByText("ClawScan").length).toBeGreaterThan(0);
expect(screen.getByRole("link", { name: /VirusTotal.*Benign/i }).getAttribute("href")).toBe(
"/plugins/demo-plugin/security/virustotal",
);
expect(
screen.getByRole("link", { name: /Static analysis.*Benign/i }).getAttribute("href"),
).toBe("/plugins/demo-plugin/security/static-analysis");
});
it("shows owner-only plugin rescan state in the security summary", async () => {
useAuthStatusMock.mockReturnValue({
isAuthenticated: true,
isLoading: false,
me: { _id: "users:1" },
});
useQueryMock.mockReturnValue({
maxRequests: 3,
requestCount: 1,
remainingRequests: 2,
canRequest: true,
inProgressRequest: null,
latestRequest: null,
});
loaderDataMock = {
detail: loaderDataMock.detail,
version: {
package: {
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
},
version: {
version: "1.0.0",
createdAt: 1,
changelog: "Initial release",
distTags: ["latest"],
files: [],
compatibility: null,
capabilities: null,
verification: null,
sha256hash: "a".repeat(64),
vtAnalysis: null,
llmAnalysis: null,
staticScan: null,
},
},
readme: null,
rateLimited: null,
};
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
expect(screen.getByRole("button", { name: "Rescan" })).toBeTruthy();
expect(screen.queryByText("Owner rescan")).toBeNull();
expect(screen.queryByText("2/3 rescans left")).toBeNull();
});
it("shows a retryable empty state when the detail lookup is rate limited", async () => {
+113 -15
View File
@@ -71,7 +71,7 @@ describe("SkillDetailPage", () => {
});
render(<SkillDetailPage slug="weather" />);
expect(screen.getByText(/Loading skill/i)).toBeTruthy();
expect(screen.getByRole("status", { name: /Loading skill details/i })).toBeTruthy();
expect(screen.queryByText(/Skill not found/i)).toBeNull();
});
@@ -222,18 +222,105 @@ describe("SkillDetailPage", () => {
/>,
);
const installHeading = await screen.findByRole("heading", { name: "Install with OpenClaw" });
const scanDisclaimer = screen.getByText(
/Like a lobster shell, security has layers — review code before you run it\./i,
await screen.findByRole("heading", { name: "Install" });
const securityHeading = screen.getByRole("heading", { name: "Security Scans" });
expect(screen.getAllByRole("heading", { name: "Install" }).length).toBeGreaterThan(0);
expect(screen.getByText("openclaw skills install weather")).toBeTruthy();
expect(screen.queryByText("npx clawhub@latest install weather")).toBeNull();
expect(screen.queryByRole("tab", { name: "ClawHub" })).toBeNull();
expect(screen.getByRole("tab", { name: "CLI" }).getAttribute("aria-selected")).toBe("true");
expect(screen.getByRole("tab", { name: "Prompt" })).toBeTruthy();
expect(screen.queryByText(/After install, inspect the skill metadata/i)).toBeNull();
expect(securityHeading).toBeTruthy();
expect(screen.getByRole("link", { name: /VirusTotal.*Pending/i })).toBeTruthy();
expect(screen.getByRole("link", { name: /ClawScan.*Pending/i })).toBeTruthy();
expect(screen.getByRole("link", { name: /Static analysis.*Pending/i })).toBeTruthy();
expect(screen.queryByText(/Like a lobster shell, security has layers/i)).toBeNull();
expect(screen.queryByRole("button", { name: "Rescan" })).toBeNull();
});
it("shows an owner rescan action in the security summary for owned skills", async () => {
useAuthStatusMock.mockReturnValue({
isAuthenticated: true,
isLoading: false,
me: { _id: ownerId, role: "user" },
});
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
if (args === "skip") return undefined;
if (args && typeof args === "object" && "skillId" in args && !("limit" in args)) {
return {
maxRequests: 3,
requestCount: 1,
remainingRequests: 2,
canRequest: true,
inProgressRequest: null,
latestRequest: null,
};
}
if (args && typeof args === "object" && "limit" in args) return [];
return undefined;
});
render(
<SkillDetailPage
slug="weather"
initialData={{
result: {
skill: {
_id: skillId,
_creationTime: 0,
slug: "weather",
displayName: "Weather",
summary: "Get current weather.",
ownerUserId: ownerId,
ownerPublisherId,
tags: {},
badges: {},
stats: {
stars: 12,
downloads: 34,
installsCurrent: 5,
installsAllTime: 8,
versions: 1,
comments: 0,
},
createdAt: 0,
updatedAt: 0,
},
owner: {
_id: ownerPublisherId,
_creationTime: 0,
kind: "user",
handle: "steipete",
displayName: "Peter",
linkedUserId: ownerId,
},
latestVersion: {
_id: versionId,
_creationTime: 0,
skillId,
version: "1.0.0",
fingerprint: "abc",
changelog: "Initial release",
parsed: { license: "MIT-0", frontmatter: {} },
files: [],
sha256hash: "abc123",
createdBy: ownerId,
createdAt: 0,
},
forkOf: null,
canonical: null,
},
readme: "# Weather",
readmeError: null,
}}
/>,
);
expect(screen.getByRole("heading", { name: "CLI Commands" })).toBeTruthy();
expect(screen.getByText("openclaw skills install weather")).toBeTruthy();
expect(screen.getByText("npx clawhub@latest install weather")).toBeTruthy();
expect(screen.getByText(/After install, inspect the skill metadata/i)).toBeTruthy();
expect(
installHeading.compareDocumentPosition(scanDisclaimer) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(await screen.findByRole("button", { name: "Rescan" })).toBeTruthy();
expect(screen.queryByText("Owner rescan")).toBeNull();
expect(screen.queryByText("2/3 rescans left")).toBeNull();
});
it("does not refetch readme when SSR data already matches the latest version", async () => {
@@ -349,7 +436,7 @@ describe("SkillDetailPage", () => {
});
render(<SkillDetailPage slug="weather" redirectToCanonical />);
expect(screen.getByText(/Loading skill/i)).toBeTruthy();
expect(screen.getByRole("status", { name: /Loading skill details/i })).toBeTruthy();
await waitFor(() => {
expect(navigateMock).toHaveBeenCalled();
@@ -506,7 +593,7 @@ describe("SkillDetailPage", () => {
expect(screen.getByText(/Report skill/i)).toBeTruthy();
});
it("shows owner tools for the skill owner", async () => {
it("links owner tools from the detail page and renders them on settings", async () => {
useAuthStatusMock.mockReturnValue({
isAuthenticated: true,
isLoading: false,
@@ -514,7 +601,11 @@ describe("SkillDetailPage", () => {
});
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
if (args === "skip") return undefined;
if (args && typeof args === "object" && "ownerUserId" in args) {
if (
args &&
typeof args === "object" &&
("ownerUserId" in args || "ownerPublisherId" in args)
) {
return [
{ _id: "skills:1", slug: "weather", displayName: "Weather" },
{ _id: "skills:2", slug: "weather-pro", displayName: "Weather Pro" },
@@ -547,7 +638,14 @@ describe("SkillDetailPage", () => {
return undefined;
});
render(<SkillDetailPage slug="weather" />);
const { unmount } = render(<SkillDetailPage slug="weather" />);
const settingsLink = await screen.findByRole("link", { name: /settings/i });
expect(settingsLink.getAttribute("href")).toBe("/steipete/weather/settings");
expect(screen.queryByText(/Owner tools/i)).toBeNull();
unmount();
render(<SkillDetailPage slug="weather" mode="settings" />);
expect(await screen.findByText(/Owner tools/i)).toBeTruthy();
expect(screen.getByRole("button", { name: /Rename and redirect/i })).toBeTruthy();
+61
View File
@@ -0,0 +1,61 @@
import type { ReactNode } from "react";
import { cn } from "../lib/utils";
type DetailPageShellProps = {
children: ReactNode;
className?: string;
};
type DetailHeroProps = {
main: ReactNode;
sidebar?: ReactNode;
children?: ReactNode;
className?: string;
topClassName?: string;
mainClassName?: string;
sidebarClassName?: string;
};
type DetailBodyProps = {
children: ReactNode;
className?: string;
mainClassName?: string;
};
export function DetailPageShell({ children, className }: DetailPageShellProps) {
return <div className={cn("skill-detail-stack", className)}>{children}</div>;
}
export function DetailHero({
main,
sidebar,
children,
className,
topClassName,
mainClassName,
sidebarClassName,
}: DetailHeroProps) {
return (
<div className={cn("skill-hero", className)}>
<div className={cn("skill-hero-top", topClassName)}>
<div className="skill-hero-layout">
<div className={cn("skill-hero-main", mainClassName)}>
{main}
{children ? <div className="skill-hero-main-extra">{children}</div> : null}
</div>
{sidebar ? (
<aside className={cn("skill-hero-sidebar", sidebarClassName)}>{sidebar}</aside>
) : null}
</div>
</div>
</div>
);
}
export function DetailBody({ children, className, mainClassName }: DetailBodyProps) {
return (
<div className={cn("detail-layout", className)}>
<div className={cn("detail-main", mainClassName)}>{children}</div>
</div>
);
}
@@ -0,0 +1,38 @@
/* @vitest-environment jsdom */
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { DetailSecuritySummary } from "./DetailSecuritySummary";
describe("DetailSecuritySummary", () => {
it("shows a disabled spinner button while a rescan is in progress", () => {
render(
<DetailSecuritySummary
scannerBasePath="/steipete/weather/security"
rescanState={{
maxRequests: 3,
requestCount: 1,
remainingRequests: 2,
canRequest: false,
inProgressRequest: {
_id: "rescanRequests:1",
targetKind: "skill",
targetVersion: "1.0.0",
status: "in_progress",
createdAt: 1,
updatedAt: 1,
},
latestRequest: null,
}}
onRequestRescan={vi.fn()}
/>,
);
const button = screen.getByRole("button", { name: "Scanning" });
expect((button as HTMLButtonElement).disabled).toBe(true);
expect(button.getAttribute("title")).toBe("A rescan is already in progress.");
expect(button.querySelector(".animate-spin")?.className).toContain(
"[animation-duration:2.4s]",
);
});
});
+155
View File
@@ -0,0 +1,155 @@
import { useState } from "react";
import {
getScanStatusInfo,
type LlmAnalysis,
type StaticFinding,
type VtAnalysis,
} from "./SkillSecurityScanResults";
import { Badge, type BadgeProps } from "./ui/badge";
import { Button } from "./ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
type RescanRequest = {
_id: string;
targetKind: "skill" | "plugin";
targetVersion: string;
status: "in_progress" | "completed" | "failed";
createdAt: number;
updatedAt: number;
completedAt?: number;
};
export type DetailRescanState = {
maxRequests: number;
requestCount: number;
remainingRequests: number;
canRequest: boolean;
inProgressRequest: RescanRequest | null;
latestRequest: RescanRequest | null;
};
type DetailSecuritySummaryProps = {
scannerBasePath: string;
sha256hash?: string | null;
vtAnalysis?: VtAnalysis | null;
llmAnalysis?: LlmAnalysis | null;
staticScan?: {
status: string;
reasonCodes: string[];
findings: StaticFinding[];
summary: string;
engineVersion: string;
checkedAt: number;
} | null;
rescanState?: DetailRescanState | null;
onRequestRescan?: (() => Promise<void>) | null;
};
function statusFromStaticScan(staticScan: DetailSecuritySummaryProps["staticScan"]) {
if (staticScan?.status) return staticScan.status;
return "pending";
}
function badgeVariantForScanStatus(status: string): BadgeProps["variant"] {
const normalized = status.toLowerCase();
if (normalized === "clean" || normalized === "benign") return "success";
if (normalized === "suspicious") return "warning";
if (normalized === "malicious" || normalized === "error") return "destructive";
if (normalized === "pending" || normalized === "queued" || normalized === "loading") {
return "pending";
}
return "compact";
}
function ScannerRow({ href, label, status }: { href: string; label: string; status: string }) {
const info = getScanStatusInfo(status);
return (
<a
href={href}
className="flex min-w-0 items-center justify-between gap-3 rounded-[var(--radius-sm)] px-1 py-2 text-sm !no-underline hover:bg-[color:var(--surface-muted)] hover:!no-underline"
>
<span className="flex min-w-0 items-center gap-2 font-semibold text-[color:var(--ink)]">
<span className="truncate">{label}</span>
</span>
<span className="flex shrink-0 items-center gap-2">
<Badge variant={badgeVariantForScanStatus(status)}>{info.label}</Badge>
</span>
</a>
);
}
function rescanDisabledReason(state: DetailRescanState | null | undefined) {
if (!state) return null;
if (state.inProgressRequest) return "A rescan is already in progress.";
if (state.remainingRequests <= 0) {
return `Rescan limit reached (${state.requestCount}/${state.maxRequests}).`;
}
if (!state.canRequest) return "This release is not eligible for another rescan.";
return null;
}
export function DetailSecuritySummary({
scannerBasePath,
vtAnalysis,
llmAnalysis,
staticScan,
rescanState,
onRequestRescan,
}: DetailSecuritySummaryProps) {
const [isRequestingRescan, setIsRequestingRescan] = useState(false);
const vtStatus = vtAnalysis?.verdict ?? vtAnalysis?.status ?? "pending";
const llmStatus = llmAnalysis?.verdict ?? llmAnalysis?.status ?? "pending";
const staticStatus = statusFromStaticScan(staticScan);
const rescanButtonDisabledReason = rescanDisabledReason(rescanState);
const isScanInProgress = Boolean(rescanState?.inProgressRequest);
const rescanButtonLabel = isScanInProgress
? "Scanning"
: isRequestingRescan
? "Requesting..."
: "Rescan";
async function handleRequestRescan() {
if (!onRequestRescan || rescanButtonDisabledReason || isRequestingRescan) return;
setIsRequestingRescan(true);
try {
await onRequestRescan();
} finally {
setIsRequestingRescan(false);
}
}
return (
<Card>
<CardHeader>
<CardTitle className="flex flex-col items-start gap-3 sm:flex-row sm:items-center">
Security Scans
{rescanState && onRequestRescan ? (
<Button
type="button"
variant="outline"
size="sm"
className="w-full justify-center sm:ml-auto sm:w-auto"
loading={isRequestingRescan || isScanInProgress}
disabled={Boolean(rescanButtonDisabledReason)}
title={rescanButtonDisabledReason ?? "Request a fresh scan"}
onClick={() => void handleRequestRescan()}
>
{rescanButtonLabel}
</Button>
) : null}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-2">
<ScannerRow href={`${scannerBasePath}/virustotal`} label="VirusTotal" status={vtStatus} />
<ScannerRow href={`${scannerBasePath}/openclaw`} label="ClawScan" status={llmStatus} />
<ScannerRow
href={`${scannerBasePath}/static-analysis`}
label="Static analysis"
status={staticStatus}
/>
</div>
</CardContent>
</Card>
);
}
+65 -142
View File
@@ -1,19 +1,17 @@
import { useAuthActions } from "@convex-dev/auth/react";
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
import { Ghost, Github, Menu, Monitor, Moon, Plug, Search, Sun, Wrench } from "lucide-react";
import { type ComponentType, useMemo, useRef, useState } from "react";
import { Ghost, Menu, Moon, Plug, Search, Sun, Wrench } from "lucide-react";
import { type ComponentType, useMemo, useState } from "react";
import { getUserFacingAuthError } from "../lib/authErrorMessage";
import { gravatarUrl } from "../lib/gravatar";
import {
filterNavItems,
type NavIconName,
PRIMARY_NAV_ITEMS,
SECONDARY_NAV_ITEMS,
} from "../lib/nav-items";
import { isModerator } from "../lib/roles";
import { getClawHubSiteUrl, getSiteMode, getSiteName } from "../lib/site";
import { applyTheme, useThemeMode } from "../lib/theme";
import { startThemeTransition } from "../lib/theme-transition";
import { setAuthError, useAuthError } from "../lib/useAuthError";
import { useAuthStatus } from "../lib/useAuthStatus";
import { Button } from "./ui/button";
@@ -32,7 +30,6 @@ import {
SheetHeader,
SheetTitle,
} from "./ui/sheet";
import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group";
const NAV_ICONS: Record<NavIconName, ComponentType<{ size?: number; className?: string }>> = {
wrench: Wrench,
@@ -40,13 +37,10 @@ const NAV_ICONS: Record<NavIconName, ComponentType<{ size?: number; className?:
ghost: Ghost,
};
const THEME_MODE_SEQUENCE: Array<"system" | "light" | "dark"> = ["system", "light", "dark"];
export default function Header() {
const { isAuthenticated, isLoading, me } = useAuthStatus();
const { signIn, signOut } = useAuthActions();
const { theme, mode, setMode } = useThemeMode();
const toggleRef = useRef<HTMLDivElement | null>(null);
const siteMode = getSiteMode();
const siteName = useMemo(() => getSiteName(siteMode), [siteMode]);
const isSoulMode = siteMode === "souls";
@@ -64,7 +58,6 @@ export default function Header() {
[hasResolvedUser, isSoulMode, isStaff],
);
const primaryItems = useMemo(() => filterNavItems(PRIMARY_NAV_ITEMS, navCtx), [navCtx]);
const secondaryItems = useMemo(() => filterNavItems(SECONDARY_NAV_ITEMS, navCtx), [navCtx]);
const { error: authError, clear: clearAuthError } = useAuthError();
const signInRedirectTo = getCurrentRelativeUrl();
@@ -72,24 +65,11 @@ export default function Header() {
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const ThemeModeIcon = getThemeModeIcon(mode);
const nextThemeMode = getNextThemeMode(mode);
const setThemeMode = (next: "system" | "light" | "dark") => {
startThemeTransition({
nextTheme: next,
currentTheme: mode,
setTheme: (value) => {
const nextMode = value as "system" | "light" | "dark";
applyTheme(nextMode, theme);
setMode(nextMode);
},
context: { element: toggleRef.current },
});
};
const cycleThemeMode = () => {
const currentIndex = Math.max(0, THEME_MODE_SEQUENCE.indexOf(mode));
const nextMode = THEME_MODE_SEQUENCE[(currentIndex + 1) % THEME_MODE_SEQUENCE.length] ?? "system";
setThemeMode(nextMode);
applyTheme(next, theme);
setMode(next);
};
const handleNavSearch = (e: React.FormEvent) => {
@@ -166,48 +146,19 @@ export default function Header() {
</Link>
</SheetClose>
))}
{secondaryItems.map((item) => (
<SheetClose key={item.to + item.label} asChild>
<Link to={item.to} search={item.search ?? {}} className="mobile-nav-link">
{item.label === "Management" ? "Manage" : item.label}
</Link>
</SheetClose>
))}
</div>
<div className="mobile-nav-section">
<div className="mobile-nav-section-title">Theme mode</div>
<div className="mobile-nav-section-title">Theme</div>
<button
className="mobile-nav-link"
type="button"
onClick={() => {
setThemeMode("system");
setThemeMode(nextThemeMode);
setMobileMenuOpen(false);
}}
>
<Monitor className="h-4 w-4" aria-hidden="true" />
System
</button>
<button
className="mobile-nav-link"
type="button"
onClick={() => {
setThemeMode("light");
setMobileMenuOpen(false);
}}
>
<Sun className="h-4 w-4" aria-hidden="true" />
Light
</button>
<button
className="mobile-nav-link"
type="button"
onClick={() => {
setThemeMode("dark");
setMobileMenuOpen(false);
}}
>
<Moon className="h-4 w-4" aria-hidden="true" />
Dark
<ThemeModeIcon className="h-4 w-4" aria-hidden="true" />
{mode === "system" ? "System theme" : `${mode} theme`}
</button>
</div>
</SheetContent>
@@ -237,6 +188,32 @@ export default function Header() {
/>
</form>
<nav className="navbar-top-links" aria-label="Primary">
{isSoulMode ? (
<a href={clawHubUrl} className="navbar-tab">
ClawHub
</a>
) : null}
{primaryItems.map((item) => {
const Icon = item.icon ? NAV_ICONS[item.icon] : null;
const isActiveByPrefix = item.activePathPrefixes?.some((prefix) =>
location.pathname.startsWith(prefix),
);
return (
<Link
key={item.to + item.label}
to={item.to}
className="navbar-tab"
search={item.search ?? {}}
data-status={isActiveByPrefix ? "active" : undefined}
>
{Icon ? <Icon size={14} className="opacity-50" aria-hidden="true" /> : null}
{item.label}
</Link>
);
})}
</nav>
<div className="nav-actions">
<button
className="navbar-search-mobile-trigger"
@@ -246,41 +223,16 @@ export default function Header() {
>
<Search size={18} aria-hidden="true" />
</button>
<div className="theme-toggle" ref={toggleRef}>
<div className="theme-cycle-group" aria-label="Theme controls">
<button
type="button"
className="theme-cycle-button theme-cycle-button-mode"
onClick={cycleThemeMode}
aria-label={`Cycle theme mode. Current: ${mode}`}
title={`Theme mode: ${mode}`}
>
<ThemeModeIcon className="h-4 w-4" aria-hidden="true" />
</button>
</div>
<ToggleGroup
className="theme-mode-toggle"
type="single"
value={mode}
onValueChange={(value) => {
if (!value) return;
setThemeMode(value as "system" | "light" | "dark");
}}
aria-label="Theme mode"
<div className="theme-toggle">
<button
type="button"
className="theme-cycle-button"
onClick={() => setThemeMode(nextThemeMode)}
aria-label={`Toggle theme. Current: ${mode}`}
title={`Theme: ${mode}`}
>
<ToggleGroupItem value="system" aria-label="System theme">
<Monitor className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">System</span>
</ToggleGroupItem>
<ToggleGroupItem value="light" aria-label="Light theme">
<Sun className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">Light</span>
</ToggleGroupItem>
<ToggleGroupItem value="dark" aria-label="Dark theme">
<Moon className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">Dark</span>
</ToggleGroupItem>
</ToggleGroup>
<ThemeModeIcon className="h-4 w-4" aria-hidden="true" />
</button>
</div>
{isAuthenticated && me ? (
<DropdownMenu>
@@ -296,6 +248,9 @@ export default function Header() {
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link to="/stars">Stars</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/dashboard">Dashboard</Link>
</DropdownMenuItem>
@@ -336,9 +291,7 @@ export default function Header() {
});
}}
>
<Github size={16} aria-hidden="true" />
<span className="sign-in-label">Sign in</span>
<span className="sign-in-provider">with GitHub</span>
Sign In
</Button>
</>
)}
@@ -360,52 +313,6 @@ export default function Header() {
</form>
) : null}
{/* Row 2: Content type tabs */}
<nav className="navbar-tabs" aria-label="Content types">
<div className="navbar-tabs-primary">
{isSoulMode ? (
<a href={clawHubUrl} className="navbar-tab">
ClawHub
</a>
) : null}
{primaryItems.map((item) => {
const Icon = item.icon ? NAV_ICONS[item.icon] : null;
const isActiveByPrefix = item.activePathPrefixes?.some((prefix) =>
location.pathname.startsWith(prefix)
);
return (
<Link
key={item.to + item.label}
to={item.to}
className="navbar-tab"
search={item.search ?? {}}
data-status={isActiveByPrefix ? "active" : undefined}
>
{Icon ? <Icon size={14} className="opacity-50" aria-hidden="true" /> : null}
{item.label}
</Link>
);
})}
</div>
<div className="navbar-tabs-secondary">
{secondaryItems.map((item) => {
const isActiveByPrefix = item.activePathPrefixes?.some((prefix) =>
location.pathname.startsWith(prefix)
);
return (
<Link
key={item.to + item.label}
to={item.to}
search={item.search ?? {}}
className="navbar-tab navbar-tab-secondary"
data-status={isActiveByPrefix ? "active" : undefined}
>
{item.label === "Management" ? "Manage" : item.label}
</Link>
);
})}
</div>
</nav>
</div>
</header>
);
@@ -424,6 +331,22 @@ function getThemeModeIcon(mode: "system" | "light" | "dark") {
return Moon;
case "system":
default:
return Monitor;
return Sun;
}
}
function getResolvedThemeMode(): "light" | "dark" {
if (typeof document !== "undefined") {
const resolved = document.documentElement.dataset.themeResolved;
if (resolved === "light" || resolved === "dark") return resolved;
}
if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
return "light";
}
function getNextThemeMode(mode: "system" | "light" | "dark"): "light" | "dark" {
const resolved = mode === "system" ? getResolvedThemeMode() : mode;
return resolved === "dark" ? "light" : "dark";
}
+3 -1
View File
@@ -37,11 +37,13 @@ export function InstallCopyButton({
label = "Copy",
ariaLabel,
className,
showLabel = true,
}: {
text: string;
label?: string;
ariaLabel?: string;
className?: string;
showLabel?: boolean;
}) {
const [copyState, setCopyState] = useState<CopyState>("idle");
const resetTimeoutRef = useRef<number | null>(null);
@@ -94,7 +96,7 @@ export function InstallCopyButton({
) : (
<Copy className="h-3.5 w-3.5" aria-hidden="true" />
)}
<span aria-live="polite">{buttonLabel}</span>
{showLabel ? <span aria-live="polite">{buttonLabel}</span> : null}
</Button>
);
}
+305
View File
@@ -0,0 +1,305 @@
import { ArrowLeft, Clock, ExternalLink, Fingerprint } from "lucide-react";
import type { ReactNode } from "react";
import type { Id } from "../../convex/_generated/dataModel";
import {
getScanStatusInfo,
type LlmAnalysis,
type StaticFinding,
type VtAnalysis,
} from "./SkillSecurityScanResults";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
export type ScannerSlug = "virustotal" | "openclaw" | "static-analysis";
type OwnerRef = {
_id?: string;
handle?: string | null;
};
type EntityRef = {
kind: "skill" | "plugin";
title: string;
name: string;
version?: string | null;
owner?: OwnerRef | null;
ownerUserId?: Id<"users"> | null;
ownerPublisherId?: Id<"publishers"> | null;
detailPath: string;
};
type SecurityScannerPageProps = {
scanner: ScannerSlug;
entity: EntityRef;
sha256hash?: string | null;
vtAnalysis?: VtAnalysis | null;
llmAnalysis?: LlmAnalysis | null;
staticScan?: {
status: string;
reasonCodes: string[];
findings: StaticFinding[];
summary: string;
engineVersion: string;
checkedAt: number;
} | null;
source?: Record<string, unknown> | null;
};
const SCANNER_LABELS: Record<ScannerSlug, string> = {
virustotal: "VirusTotal",
openclaw: "ClawScan",
"static-analysis": "Static analysis",
};
const SCANNER_SUMMARIES: Record<ScannerSlug, string> = {
virustotal: "External malware reputation and Code Insight signals for this exact artifact hash.",
openclaw: "ClawHub's context-aware review of the artifact, metadata, and declared behavior.",
"static-analysis": "Deterministic local checks for risky code patterns and metadata mismatches.",
};
function formatTime(value?: number | null) {
if (!value) return "Not checked yet";
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
function formatValue(value: unknown): string | null {
if (value === undefined || value === null || value === "") return null;
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
if (Array.isArray(value)) return value.length ? value.map(formatValue).filter(Boolean).join(", ") : null;
return JSON.stringify(value);
}
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
if (children === null || children === undefined || children === "") return null;
return (
<div className="grid gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid-cols-[180px_1fr] sm:gap-4">
<dt className="text-sm font-semibold text-[color:var(--ink-soft)]">{label}</dt>
<dd className="min-w-0 break-words text-sm text-[color:var(--ink)]">{children}</dd>
</div>
);
}
function getScannerStatus(props: SecurityScannerPageProps) {
if (props.scanner === "virustotal") return props.vtAnalysis?.verdict ?? props.vtAnalysis?.status ?? "pending";
if (props.scanner === "openclaw") return props.llmAnalysis?.verdict ?? props.llmAnalysis?.status ?? "pending";
return props.staticScan?.status ?? "pending";
}
function getCheckedAt(props: SecurityScannerPageProps) {
if (props.scanner === "virustotal") return props.vtAnalysis?.checkedAt ?? null;
if (props.scanner === "openclaw") return props.llmAnalysis?.checkedAt ?? null;
return props.staticScan?.checkedAt ?? null;
}
export function SecurityScannerPage(props: SecurityScannerPageProps) {
const label = SCANNER_LABELS[props.scanner];
const status = getScannerStatus(props);
const statusInfo = getScanStatusInfo(status);
const checkedAt = getCheckedAt(props);
const vtUrl = props.sha256hash ? `https://www.virustotal.com/gui/file/${props.sha256hash}` : null;
const sourceRepo = formatValue(props.source?.repository ?? props.source?.repo ?? props.source?.url);
const sourceCommit = formatValue(props.source?.commit ?? props.source?.sha);
return (
<main className="section">
<div className="flex min-w-0 flex-col gap-5">
<div className="flex flex-col gap-3">
<Button asChild variant="ghost" size="sm" className="w-fit">
<a href={props.entity.detailPath}>
<ArrowLeft className="h-3.5 w-3.5" aria-hidden="true" />
Back to {props.entity.kind}
</a>
</Button>
<div className="flex flex-col gap-3">
<div className="min-w-0">
<div className="mb-2 flex flex-wrap items-center gap-2">
<Badge>{props.entity.kind === "skill" ? "Skill" : "Plugin"}</Badge>
{props.entity.version ? <Badge variant="compact">v{props.entity.version}</Badge> : null}
</div>
<h1 className="m-0 break-words font-display text-3xl font-bold text-[color:var(--ink)]">
{label} security
</h1>
<p className="mt-2 max-w-3xl text-sm text-[color:var(--ink-soft)]">
{props.entity.title} · {SCANNER_SUMMARIES[props.scanner]}
</p>
</div>
</div>
</div>
<div className="security-scanner-layout">
<div className="flex min-w-0 flex-col gap-5">
<Card>
<CardHeader>
<CardTitle>Scanner verdict</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap items-center gap-2">
<span className={`scan-result-status ${statusInfo.className}`}>
{statusInfo.label}
</span>
<span className="inline-flex items-center gap-1 text-xs text-[color:var(--ink-soft)]">
<Clock className="h-3.5 w-3.5" aria-hidden="true" />
{formatTime(checkedAt)}
</span>
</div>
<dl className="mt-2 flex flex-col gap-3">
{props.scanner === "virustotal" ? (
<>
<DetailRow label="Hash">
{props.sha256hash ? (
<span className="inline-flex max-w-full items-center gap-2 break-all font-mono text-xs">
<Fingerprint className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
{props.sha256hash}
</span>
) : (
"No artifact hash recorded."
)}
</DetailRow>
<DetailRow label="Source">{props.vtAnalysis?.source ?? "File reputation"}</DetailRow>
<DetailRow label="Verdict">{props.vtAnalysis?.verdict ?? props.vtAnalysis?.status ?? "Pending"}</DetailRow>
<DetailRow label="Code Insight">{props.vtAnalysis?.analysis ?? null}</DetailRow>
<DetailRow label="External report">
{vtUrl ? (
<a
href={vtUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 break-all text-[color:var(--accent)] hover:underline"
>
View on VirusTotal
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
) : (
"Unavailable until an artifact hash is recorded."
)}
</DetailRow>
</>
) : null}
{props.scanner === "openclaw" ? (
<>
<DetailRow label="Verdict">{props.llmAnalysis?.verdict ?? props.llmAnalysis?.status ?? "Pending"}</DetailRow>
<DetailRow label="Confidence">{props.llmAnalysis?.confidence ?? "Not reported"}</DetailRow>
<DetailRow label="Model">{props.llmAnalysis?.model ?? "Not reported"}</DetailRow>
<DetailRow label="Summary">{props.llmAnalysis?.summary ?? "No ClawScan analysis has been recorded yet."}</DetailRow>
<DetailRow label="Guidance">{props.llmAnalysis?.guidance ?? null}</DetailRow>
<DetailRow label="Findings">
{props.llmAnalysis?.findings ? (
<pre className="m-0 whitespace-pre-wrap break-words font-mono text-xs">
{props.llmAnalysis.findings}
</pre>
) : null}
</DetailRow>
</>
) : null}
{props.scanner === "static-analysis" ? (
<>
<DetailRow label="Summary">
{props.staticScan?.summary ?? "No static analysis result has been recorded yet."}
</DetailRow>
<DetailRow label="Reason codes">
{props.staticScan?.reasonCodes?.length ? (
<div className="flex flex-wrap gap-1.5">
{props.staticScan.reasonCodes.map((code) => (
<Badge key={code} variant="compact">
{code}
</Badge>
))}
</div>
) : (
"None"
)}
</DetailRow>
<DetailRow label="Engine">{props.staticScan?.engineVersion ?? "Not reported"}</DetailRow>
</>
) : null}
<DetailRow label="Source repository">{sourceRepo}</DetailRow>
<DetailRow label="Source commit">
{sourceCommit ? <span className="font-mono text-xs">{sourceCommit}</span> : null}
</DetailRow>
</dl>
</CardContent>
</Card>
{props.scanner === "openclaw" && props.llmAnalysis?.dimensions?.length ? (
<Card>
<CardHeader>
<CardTitle>Review Dimensions</CardTitle>
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3">
{props.llmAnalysis.dimensions.map((dimension) => (
<DetailRow key={dimension.name} label={dimension.label}>
<div className="flex flex-col gap-1">
<Badge variant="compact" className="w-fit">
{dimension.rating}
</Badge>
<span>{dimension.detail}</span>
</div>
</DetailRow>
))}
</dl>
</CardContent>
</Card>
) : null}
{props.scanner === "static-analysis" && props.staticScan?.findings?.length ? (
<Card>
<CardHeader>
<CardTitle>Evidence</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-3">
{props.staticScan.findings.map((finding, index) => (
<div
key={`${finding.code}-${finding.file}-${finding.line}-${index}`}
className="rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] p-3"
>
<div className="mb-2 flex flex-wrap items-center gap-2">
<Badge variant="compact">{finding.severity}</Badge>
<span className="break-all font-mono text-xs text-[color:var(--ink-soft)]">
{finding.file}:{finding.line}
</span>
</div>
<div className="text-sm font-semibold text-[color:var(--ink)]">
{finding.message}
</div>
<pre className="mt-2 whitespace-pre-wrap break-words rounded-[var(--radius-sm)] bg-[color:var(--surface)] p-2 font-mono text-xs text-[color:var(--ink-soft)]">
{finding.evidence || finding.code}
</pre>
</div>
))}
</div>
</CardContent>
</Card>
) : null}
</div>
<aside className="flex min-w-0 flex-col gap-5">
<Card>
<CardHeader>
<CardTitle>Artifact</CardTitle>
</CardHeader>
<CardContent>
<dl className="flex flex-col gap-3">
<DetailRow label="Package">{props.entity.name}</DetailRow>
<DetailRow label="Version">{props.entity.version ?? "Latest"}</DetailRow>
<DetailRow label="Hash">
{props.sha256hash ? <span className="break-all font-mono text-xs">{props.sha256hash}</span> : "Not recorded"}
</DetailRow>
</dl>
</CardContent>
</Card>
</aside>
</div>
</div>
</main>
);
}
+6 -6
View File
@@ -42,8 +42,8 @@ describe("SignInButton", () => {
it("starts GitHub sign-in with the current relative URL by default", async () => {
signInMock.mockResolvedValue({ signingIn: true });
render(<SignInButton>Sign in with GitHub</SignInButton>);
fireEvent.click(screen.getByRole("button", { name: "Sign in with GitHub" }));
render(<SignInButton />);
fireEvent.click(screen.getByRole("button", { name: "Sign In" }));
await waitFor(() => {
expect(signInMock).toHaveBeenCalledWith("github", {
@@ -57,8 +57,8 @@ describe("SignInButton", () => {
it("surfaces a generic error when sign-in resolves without redirecting", async () => {
signInMock.mockResolvedValue({ signingIn: false });
render(<SignInButton>Sign in with GitHub</SignInButton>);
fireEvent.click(screen.getByRole("button", { name: "Sign in with GitHub" }));
render(<SignInButton />);
fireEvent.click(screen.getByRole("button", { name: "Sign In" }));
await waitFor(() => {
expect(setAuthErrorMock).toHaveBeenCalledWith("Sign in failed. Please try again.");
@@ -70,8 +70,8 @@ describe("SignInButton", () => {
signInMock.mockRejectedValue(failure);
getUserFacingAuthErrorMock.mockReturnValue("GitHub auth unavailable");
render(<SignInButton>Sign in with GitHub</SignInButton>);
fireEvent.click(screen.getByRole("button", { name: "Sign in with GitHub" }));
render(<SignInButton />);
fireEvent.click(screen.getByRole("button", { name: "Sign In" }));
await waitFor(() => {
expect(getUserFacingAuthErrorMock).toHaveBeenCalledWith(
+3 -2
View File
@@ -12,14 +12,16 @@ type SignInButtonProps = Omit<ButtonProps, "onClick" | "type"> & {
export function SignInButton({
redirectTo,
children = "Sign in with GitHub",
children = "Sign In",
...props
}: SignInButtonProps) {
const { signIn } = useAuthActions();
return (
<Button
{...props}
type="button"
variant="primary"
onClick={() => {
clearAuthError();
const next = redirectTo ?? getCurrentRelativeUrl();
@@ -35,7 +37,6 @@ export function SignInButton({
);
});
}}
{...props}
>
{children}
</Button>
+228 -95
View File
@@ -1,17 +1,21 @@
import { useNavigate } from "@tanstack/react-router";
import type { ClawdisSkillMetadata } from "clawhub-schema";
import { useAction, useMutation, useQuery } from "convex/react";
import type { ComponentProps } from "react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { api } from "../../convex/_generated/api";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import { getUserFacingConvexError } from "../lib/convexError";
import { canManageSkill, isModerator } from "../lib/roles";
import type { SkillBySlugResult, SkillPageInitialData } from "../lib/skillPage";
import { useAuthStatus } from "../lib/useAuthStatus";
import { Card } from "./ui/card";
import { ClientOnly } from "./ClientOnly";
import { DetailBody, DetailPageShell } from "./DetailPageShell";
import { DetailSecuritySummary } from "./DetailSecuritySummary";
import { SkillDetailSkeleton } from "./skeletons/SkillDetailSkeleton";
import { SkillCommentsPanel } from "./SkillCommentsPanel";
import { SkillDetailTabs, type DetailTab } from "./SkillDetailTabs";
import { SkillMetadataSidebar } from "./SkillMetadataSidebar";
import {
buildSkillHref,
formatConfigSnippet,
@@ -22,16 +26,22 @@ import {
import { SkillHeader } from "./SkillHeader";
import { SkillOwnershipPanel } from "./SkillOwnershipPanel";
import { SkillReportDialog } from "./SkillReportDialog";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
type SkillDetailPageProps = {
slug: string;
canonicalOwner?: string;
redirectToCanonical?: boolean;
initialData?: SkillPageInitialData | null;
mode?: "detail" | "settings";
};
type SkillFile = Doc<"skillVersions">["files"][number];
const SHOW_SKILL_COMMENTS = false;
function formatReportError(error: unknown) {
if (error && typeof error === "object" && "data" in error) {
const data = (error as { data?: unknown }).data;
@@ -65,6 +75,7 @@ export function SkillDetailPage({
canonicalOwner,
redirectToCanonical,
initialData,
mode = "detail",
}: SkillDetailPageProps) {
const navigate = useNavigate();
const { isAuthenticated, me } = useAuthStatus();
@@ -83,6 +94,7 @@ export function SkillDetailPage({
const reportSkill = useMutation(api.skills.report);
const updateTags = useMutation(api.skills.updateTags);
const deleteTags = useMutation(api.skills.deleteTags);
const requestRescan = useMutation(api.skills.requestRescan);
const getReadme = useAction(api.skills.getReadme);
const myPublishers = useQuery(api.publishers.listMine) as
| Array<{ publisher: { _id: Id<"publishers"> }; role: string }>
@@ -145,6 +157,11 @@ export function SkillDetailPage({
: { ownerUserId: skill.ownerUserId, limit: 100 }
: "skip",
) as Array<{ _id: Id<"skills">; slug: string; displayName: string }> | undefined;
const canViewOwnerRescanState = isOwner || me?.role === "admin";
const rescanState = useQuery(
api.skills.getRescanState,
canViewOwnerRescanState && skill ? { skillId: skill._id } : "skip",
) as ComponentProps<typeof DetailSecuritySummary>["rescanState"] | undefined;
const ownerHandle = owner?.handle ?? null;
const ownerParam = ownerHandle?.trim().toLowerCase() || (owner?._id ? String(owner._id) : null);
@@ -237,7 +254,10 @@ export function SkillDetailPage({
useEffect(() => {
let cancelled = false;
if (latestVersion && !(loadedReadmeVersionId === latestVersion._id && (readme !== null || readmeError !== null))) {
if (
latestVersion &&
!(loadedReadmeVersionId === latestVersion._id && (readme !== null || readmeError !== null))
) {
setReadme(null);
setReadmeError(null);
setLoadedReadmeVersionId(latestVersion._id);
@@ -325,19 +345,36 @@ export function SkillDetailPage({
}
};
const submitRescanRequest = async () => {
if (!skill) return;
try {
await requestRescan({ skillId: skill._id });
toast.success("Rescan requested.", {
action: {
label: "Dashboard",
onClick: () => {
window.location.href = "/dashboard";
},
},
});
} catch (error) {
toast.error(getUserFacingConvexError(error, "Could not request a rescan."));
}
};
if (isLoadingSkill || wantsCanonicalRedirect) {
return (
<main className="section">
<Card>
<div className="loading-indicator">Loading skill</div>
</Card>
<main className="section detail-page-section" aria-busy="true">
<div role="status" aria-label="Loading skill details">
<SkillDetailSkeleton />
</div>
</main>
);
}
if (result === null || !skill) {
return (
<main className="section">
<main className="section detail-page-section">
<Card>Skill not found.</Card>
</main>
);
@@ -353,10 +390,25 @@ export function SkillDetailPage({
latestTagVersionId === null
? []
: tagEntries.filter(([, versionId]) => versionId !== latestTagVersionId);
const securitySummary = latestVersion ? (
<DetailSecuritySummary
scannerBasePath={`/${encodeURIComponent(
ownerParam ?? ownerHandle ?? "unknown",
)}/${encodeURIComponent(skill.slug)}/security`}
sha256hash={latestVersion.sha256hash ?? null}
vtAnalysis={latestVersion.vtAnalysis ?? null}
llmAnalysis={latestVersion.llmAnalysis ?? null}
staticScan={latestVersion.staticScan ?? null}
rescanState={rescanState ?? null}
onRequestRescan={canViewOwnerRescanState ? submitRescanRequest : null}
/>
) : null;
const detailPath = `/${encodeURIComponent(ownerParam ?? ownerHandle ?? "unknown")}/${encodeURIComponent(skill.slug)}`;
const settingsHref = canManage ? `${detailPath}/settings` : null;
return (
<main className="section">
<div className="skill-detail-stack">
<main className="section detail-page-section">
<DetailPageShell>
<SkillHeader
skill={skill}
owner={owner}
@@ -384,102 +436,183 @@ export function SkillDetailPage({
hasPluginBundle={hasPluginBundle}
configRequirements={configRequirements}
cliHelp={cliHelp}
tagEntries={currentTagEntries}
historicalTagEntries={historicalTagEntries}
versionById={versionById}
tagName={tagName}
onTagNameChange={setTagName}
tagVersionId={tagVersionId}
onTagVersionChange={setTagVersionId}
onTagSubmit={submitTag}
onTagDelete={deleteTag}
tagVersions={versions ?? []}
clawdis={clawdis}
osLabels={osLabels}
/>
sidebarContent={securitySummary}
settingsHref={settingsHref}
>
{mode === "detail" ? (
<>
{nixSnippet ? (
<Card>
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
Install via Nix
</h3>
<pre className="hero-install-code mt-2">{nixSnippet}</pre>
</Card>
) : null}
{isOwner && skill ? (
<SkillOwnershipPanel
skillId={skill._id}
slug={skill.slug}
ownerHandle={ownerHandle}
ownerId={owner?._id ?? null}
ownedSkills={(ownedSkills ?? []).filter((entry) => entry._id !== skill._id)}
/>
) : null}
{configExample ? (
<Card>
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
Config example
</h3>
<pre className="hero-install-code mt-2">{configExample}</pre>
</Card>
) : null}
<SkillMetadataSidebar
skill={skill}
latestVersion={latestVersion}
owner={owner}
ownerHandle={ownerHandle}
clawdis={clawdis}
osLabels={osLabels}
tagEntries={currentTagEntries}
isMalwareBlocked={modInfo?.isMalwareBlocked}
isRemoved={modInfo?.isRemoved}
nixPlugin={nixPlugin}
/>
<SkillDetailTabs
activeTab={activeTab}
setActiveTab={setActiveTab}
onCompareIntent={() => setShouldPrefetchCompare(true)}
readmeContent={readmeContent}
readmeError={readmeError}
latestFiles={latestFiles}
latestVersionId={latestVersion?._id ?? null}
skill={skill as Doc<"skills">}
diffVersions={diffVersions}
versions={versions}
nixPlugin={Boolean(nixPlugin)}
suppressVersionScanResults={suppressVersionScanResults}
scanResultsSuppressedMessage={scanResultsSuppressedMessage}
/>
<div className="detail-content-full">
{nixSnippet ? (
<Card>
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
Install via Nix
</h3>
<pre className="hero-install-code mt-2">
{nixSnippet}
</pre>
</Card>
<Card className="skill-tag-card">
<CardHeader>
<CardTitle>Version tags</CardTitle>
</CardHeader>
<CardContent>
<div className="skill-tag-row">
{currentTagEntries.length === 0 ? (
<span className="section-subtitle m-0">No tags yet.</span>
) : (
currentTagEntries.map(([tag, versionId]) => (
<Badge key={tag}>
{tag}
<span className="tag-meta">
v{versionById.get(versionId)?.version ?? versionId}
</span>
{canManage && tag !== "latest" ? (
<button
type="button"
className="tag-delete"
onClick={() => deleteTag(tag)}
aria-label={`Delete tag ${tag}`}
title={`Delete tag "${tag}"`}
>
x
</button>
) : null}
</Badge>
))
)}
</div>
{canManage && historicalTagEntries.length > 0 ? (
<div className="skill-tag-history">
<div className="skill-tag-history-label">Historical tags</div>
<div className="skill-tag-row">
{historicalTagEntries.map(([tag, versionId]) => (
<Badge key={tag}>
{tag}
<span className="tag-meta">
v{versionById.get(versionId)?.version ?? versionId}
</span>
{tag !== "latest" ? (
<button
type="button"
className="tag-delete"
onClick={() => deleteTag(tag)}
aria-label={`Delete tag ${tag}`}
title={`Delete tag "${tag}"`}
>
x
</button>
) : null}
</Badge>
))}
</div>
</div>
) : null}
{canManage ? (
<form
onSubmit={(event) => {
event.preventDefault();
submitTag();
}}
className="tag-form"
>
<input
aria-label="Tag name"
className="search-input"
name="tagName"
value={tagName}
onChange={(event) => setTagName(event.target.value)}
placeholder="latest..."
/>
<select
aria-label="Tag version"
className="search-input"
name="tagVersion"
value={tagVersionId ?? ""}
onChange={(event) =>
setTagVersionId(event.target.value as Id<"skillVersions">)
}
>
{(versions ?? []).map((version) => (
<option key={version._id} value={version._id}>
v{version.version}
</option>
))}
</select>
<Button type="submit">Update Tag</Button>
</form>
) : null}
</CardContent>
</Card>
{SHOW_SKILL_COMMENTS ? (
<ClientOnly
fallback={
<Card>
<h2 className="section-title text-[1.2rem] m-0">Comments</h2>
<p className="section-subtitle mt-3 mb-0">Loading comments...</p>
</Card>
}
>
<SkillCommentsPanel
skillId={skill._id}
isAuthenticated={isAuthenticated}
me={me ?? null}
/>
</ClientOnly>
) : null}
</>
) : null}
</SkillHeader>
{configExample ? (
<Card>
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
Config example
</h3>
<pre className="hero-install-code mt-2">
{configExample}
</pre>
</Card>
) : null}
<SkillDetailTabs
activeTab={activeTab}
setActiveTab={setActiveTab}
onCompareIntent={() => setShouldPrefetchCompare(true)}
readmeContent={readmeContent}
readmeError={readmeError}
latestFiles={latestFiles}
latestVersionId={latestVersion?._id ?? null}
skill={skill as Doc<"skills">}
diffVersions={diffVersions}
versions={versions}
nixPlugin={Boolean(nixPlugin)}
suppressVersionScanResults={suppressVersionScanResults}
scanResultsSuppressedMessage={scanResultsSuppressedMessage}
/>
<ClientOnly
fallback={
{mode === "settings" ? (
<DetailBody>
{isOwner && skill ? (
<SkillOwnershipPanel
skillId={skill._id}
slug={skill.slug}
ownerHandle={ownerHandle}
ownerId={owner?._id ?? null}
ownedSkills={(ownedSkills ?? []).filter((entry) => entry._id !== skill._id)}
/>
) : (
<Card>
<h2 className="section-title text-[1.2rem] m-0">
Comments
</h2>
<h2 className="section-title text-[1.2rem] m-0">Settings unavailable</h2>
<p className="section-subtitle mt-3 mb-0">
Loading comments...
Only the skill owner can manage these settings.
</p>
</Card>
}
>
<SkillCommentsPanel
skillId={skill._id}
isAuthenticated={isAuthenticated}
me={me ?? null}
/>
</ClientOnly>
</div>
</div>
)}
</DetailBody>
) : null}
</DetailPageShell>
<SkillReportDialog
isOpen={isAuthenticated && isReportDialogOpen}
+154 -260
View File
@@ -1,14 +1,16 @@
import type { ClawdisSkillMetadata } from "clawhub-schema";
import { Link } from "@tanstack/react-router";
import { Package } from "lucide-react";
import type { ClawdisSkillMetadata } from "clawhub-schema";
import { PLATFORM_SKILL_LICENSE } from "clawhub-schema/licenseConstants";
import { Calendar, Download, History, Package, Scale, Settings, Star } from "lucide-react";
import type { ReactNode } from "react";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import { getSkillBadges } from "../lib/badges";
import { formatCompactStat, formatSkillStatsTriplet } from "../lib/numberFormat";
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
import { type LlmAnalysis, SecurityScanResults } from "./SkillSecurityScanResults";
import { timeAgo } from "../lib/timeAgo";
import { DetailHero } from "./DetailPageShell";
import { SkillInstallCard } from "./SkillInstallCard";
import { SkillInstallSurface } from "./SkillInstallSurface";
import { SkillCommandLineCard } from "./SkillInstallSurface";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { UserBadge } from "./UserBadge";
@@ -63,18 +65,11 @@ type SkillHeaderProps = {
hasPluginBundle: boolean;
configRequirements: ClawdisSkillMetadata["config"] | undefined;
cliHelp: string | undefined;
tagEntries: Array<[string, Id<"skillVersions">]>;
historicalTagEntries: Array<[string, Id<"skillVersions">]>;
versionById: Map<Id<"skillVersions">, Doc<"skillVersions">>;
tagName: string;
onTagNameChange: (value: string) => void;
tagVersionId: Id<"skillVersions"> | "";
onTagVersionChange: (value: Id<"skillVersions"> | "") => void;
onTagSubmit: () => void;
onTagDelete: (tag: string) => void;
tagVersions: Doc<"skillVersions">[];
clawdis: ClawdisSkillMetadata | undefined;
osLabels: string[];
sidebarContent?: ReactNode;
settingsHref?: string | null;
children?: ReactNode;
};
export function SkillHeader({
@@ -104,28 +99,13 @@ export function SkillHeader({
hasPluginBundle,
configRequirements,
cliHelp,
tagEntries,
historicalTagEntries,
versionById,
tagName,
onTagNameChange,
tagVersionId,
onTagVersionChange,
onTagSubmit,
onTagDelete,
tagVersions,
clawdis,
osLabels,
sidebarContent,
settingsHref,
children,
}: SkillHeaderProps) {
const formattedStats = formatSkillStatsTriplet(skill.stats);
const suppressScanResults =
!isStaff &&
Boolean(modInfo?.overrideActive) &&
!modInfo?.isMalwareBlocked &&
!modInfo?.isSuspicious;
const overrideScanMessage = suppressScanResults
? "Security findings were reviewed by staff and cleared for public use."
: null;
const installOwnerId = owner?._id ?? skill.ownerPublisherId ?? skill.ownerUserId ?? null;
return (
@@ -141,16 +121,6 @@ export function SkillHeader({
</p>
</div>
</div>
) : modInfo?.isMalwareBlocked ? (
<div className="pending-banner pending-banner-blocked">
<div className="pending-banner-content">
<strong>Skill blocked malicious content detected</strong>
<p>
ClawHub Security flagged this skill as malicious. Downloads are disabled. Review the
scan results below.
</p>
</div>
</div>
) : modInfo?.isSuspicious ? (
<div className="pending-banner pending-banner-warning">
<div className="pending-banner-content">
@@ -190,145 +160,161 @@ export function SkillHeader({
</div>
) : null}
<div className="card skill-hero">
<div className={`skill-hero-top${hasPluginBundle ? " has-plugin" : ""}`}>
<div className="skill-hero-header">
<div className="skill-hero-title">
<div className="skill-hero-title-row">
<h1 className="section-title m-0">
{skill.displayName}
</h1>
{latestVersion?.version ? (
<span className="plugin-version-badge">v{latestVersion.version}</span>
) : null}
{nixPlugin ? <Badge variant="accent">Plugin bundle (nix)</Badge> : null}
</div>
<p className="section-subtitle">{skill.summary ?? "No summary provided."}</p>
{isStaff && staffModerationNote ? (
<div className="skill-hero-note">{staffModerationNote}</div>
) : null}
{nixPlugin ? (
<div className="skill-hero-note">
Bundles the skill pack, CLI binary, and config requirements in one Nix install.
</div>
) : null}
<div className="skill-hero-inline-meta">
<div className="skill-hero-stats-row">
<span className="stat"> {formattedStats.stars}</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat"><Package size={14} aria-hidden="true" /> {formattedStats.downloads}</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">{formatCompactStat(skill.stats.installsCurrent ?? 0)} current</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">{formattedStats.installsAllTime} all-time</span>
</div>
<div className="skill-hero-meta-row">
<UserBadge
user={owner}
fallbackHandle={ownerHandle}
prefix="by"
size="md"
showName
/>
{forkOf && forkOfHref ? (
<>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
{forkOfLabel}{" "}
<a href={forkOfHref}>
{forkOfOwnerHandle ? `@${forkOfOwnerHandle}/` : ""}
{forkOf.skill.slug}
</a>
{forkOf.version ? ` (${forkOf.version})` : null}
</span>
</>
<DetailHero
topClassName={hasPluginBundle ? "has-plugin" : undefined}
main={
<>
<div className="skill-hero-title">
<div className="skill-hero-title-row">
<h1 className="skill-page-title">{skill.displayName}</h1>
{latestVersion?.version ? (
<span className="plugin-version-badge">v{latestVersion.version}</span>
) : null}
{canonicalHref ? (
<>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
canonical:{" "}
<a href={canonicalHref}>
{canonicalOwnerHandle ? `@${canonicalOwnerHandle}/` : ""}
{canonical?.skill?.slug}
</a>
</span>
</>
{nixPlugin ? <Badge variant="accent">Plugin bundle (nix)</Badge> : null}
{isStaff || settingsHref ? (
<div className="skill-title-actions">
{isStaff ? (
<Button asChild variant="outline" size="sm">
<Link to="/management" search={{ skill: skill.slug }}>
Manage
</Link>
</Button>
) : null}
{settingsHref ? (
<Button asChild variant="outline" size="sm" className="skill-settings-link">
<a href={settingsHref}>
<Settings size={14} aria-hidden="true" />
Settings
</a>
</Button>
) : null}
</div>
) : null}
</div>
<p className="section-subtitle">{skill.summary ?? "No summary provided."}</p>
{isStaff && staffModerationNote ? (
<div className="skill-hero-note">{staffModerationNote}</div>
) : null}
{nixPlugin ? (
<div className="skill-hero-note">
Bundles the skill pack, CLI binary, and config requirements in one Nix install.
</div>
) : null}
<div className="skill-hero-inline-meta">
<div className="skill-hero-stats-row">
<span className="stat">
<Star size={14} aria-hidden="true" /> {formattedStats.stars}
</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
<Download size={14} aria-hidden="true" /> {formattedStats.downloads}
</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
<Package size={14} aria-hidden="true" /> {skill.stats.versions ?? 0} versions
</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
<History size={14} aria-hidden="true" />{" "}
{formatCompactStat(skill.stats.installsCurrent ?? 0)} current
</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
<History size={14} aria-hidden="true" /> {formattedStats.installsAllTime}{" "}
all-time
</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
<Calendar size={14} aria-hidden="true" /> Updated {timeAgo(skill.updatedAt)}
</span>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
<Scale size={14} aria-hidden="true" /> {PLATFORM_SKILL_LICENSE}
</span>
</div>
<div className="skill-hero-meta-row">
<UserBadge
user={owner}
fallbackHandle={ownerHandle}
prefix="by"
size="md"
showName
/>
{forkOf && forkOfHref ? (
<>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
{forkOfLabel}{" "}
<a href={forkOfHref}>
{forkOfOwnerHandle ? `@${forkOfOwnerHandle}/` : ""}
{forkOf.skill.slug}
</a>
{forkOf.version ? ` (${forkOf.version})` : null}
</span>
</>
) : null}
{canonicalHref ? (
<>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
canonical:{" "}
<a href={canonicalHref}>
{canonicalOwnerHandle ? `@${canonicalOwnerHandle}/` : ""}
{canonical?.skill?.slug}
</a>
</span>
</>
) : null}
</div>
</div>
<div className="skill-hero-badges">
{getSkillBadges(skill).map((badge) => (
<Badge key={badge} variant="compact">
{badge}
</Badge>
))}
{isStaff && staffVisibilityTag ? (
<Badge variant={isAutoHidden || isRemoved ? "accent" : "compact"}>
{staffVisibilityTag}
</Badge>
) : null}
</div>
</div>
<div className="skill-hero-badges">
{getSkillBadges(skill).map((badge) => (
<Badge key={badge} variant="compact">
{badge}
</Badge>
))}
{isStaff && staffVisibilityTag ? (
<Badge variant={isAutoHidden || isRemoved ? "accent" : "compact"}>
{staffVisibilityTag}
</Badge>
) : null}
</div>
</div>
<div className="skill-hero-sidebar">
<div className="skill-actions">
{isAuthenticated ? (
</>
}
sidebar={
<>
{isAuthenticated ? (
<div className="skill-actions">
<button
className={`star-toggle${isStarred ? " is-active" : ""}`}
type="button"
onClick={onToggleStar}
aria-label={isStarred ? "Unstar skill" : "Star skill"}
>
<span aria-hidden="true"></span>
<Star size={16} aria-hidden="true" />
</button>
) : null}
{isAuthenticated ? (
<Button variant="ghost" size="sm" type="button" onClick={onOpenReport}>
Report
</Button>
) : null}
{isStaff ? (
<Button asChild size="sm">
<Link to="/management" search={{ skill: skill.slug }}>
Manage
</Link>
</Button>
) : null}
</div>
</div>
</div>
<SkillInstallSurface
slug={skill.slug}
displayName={skill.displayName}
ownerHandle={ownerHandle}
ownerId={installOwnerId}
clawdis={clawdis}
/>
{/* Security scan — full width below the header columns */}
{suppressScanResults ? (
<div className="skill-hero-note">{overrideScanMessage}</div>
) : latestVersion?.sha256hash ||
latestVersion?.llmAnalysis ||
(latestVersion?.staticScan?.findings?.length ?? 0) > 0 ||
(latestVersion?.capabilityTags?.length ?? 0) > 0 ? (
<div className="skill-hero-scan-row">
<SecurityScanResults
sha256hash={latestVersion?.sha256hash}
vtAnalysis={latestVersion?.vtAnalysis}
llmAnalysis={latestVersion?.llmAnalysis as LlmAnalysis | undefined}
staticFindings={latestVersion?.staticScan?.findings}
capabilityTags={latestVersion?.capabilityTags}
</div>
) : null}
{sidebarContent}
<SkillCommandLineCard
slug={skill.slug}
displayName={skill.displayName}
ownerHandle={ownerHandle}
ownerId={installOwnerId}
clawdis={clawdis}
/>
<p className="scan-disclaimer">
Like a lobster shell, security has layers review code before you run it.
</p>
</div>
) : null}
</>
}
>
{children}
{hasPluginBundle ? (
<div className="skill-panel bundle-card">
<div className="bundle-header">
@@ -368,99 +354,7 @@ export function SkillHeader({
</div>
) : null}
<SkillInstallCard clawdis={clawdis} osLabels={osLabels} />
</div>
<div className="skill-tag-row">
{tagEntries.length === 0 ? (
<span className="section-subtitle m-0">
No tags yet.
</span>
) : (
tagEntries.map(([tag, versionId]) => (
<Badge key={tag}>
{tag}
<span className="tag-meta">
v{versionById.get(versionId)?.version ?? versionId}
</span>
{canManage && tag !== "latest" ? (
<button
type="button"
className="tag-delete"
onClick={() => onTagDelete(tag)}
aria-label={`Delete tag ${tag}`}
title={`Delete tag "${tag}"`}
>
×
</button>
) : null}
</Badge>
))
)}
</div>
{canManage && historicalTagEntries.length > 0 ? (
<div className="skill-tag-history">
<div className="skill-tag-history-label">Historical tags</div>
<div className="skill-tag-row">
{historicalTagEntries.map(([tag, versionId]) => (
<Badge key={tag}>
{tag}
<span className="tag-meta">
v{versionById.get(versionId)?.version ?? versionId}
</span>
{tag !== "latest" ? (
<button
type="button"
className="tag-delete"
onClick={() => onTagDelete(tag)}
aria-label={`Delete tag ${tag}`}
title={`Delete tag "${tag}"`}
>
×
</button>
) : null}
</Badge>
))}
</div>
</div>
) : null}
{canManage ? (
<form
onSubmit={(event) => {
event.preventDefault();
onTagSubmit();
}}
className="tag-form"
>
<input
aria-label="Tag name"
className="search-input"
name="tagName"
value={tagName}
onChange={(event) => onTagNameChange(event.target.value)}
placeholder="latest…"
/>
<select
aria-label="Tag version"
className="search-input"
name="tagVersion"
value={tagVersionId ?? ""}
onChange={(event) => onTagVersionChange(event.target.value as Id<"skillVersions">)}
>
{tagVersions.map((version) => (
<option key={version._id} value={version._id}>
v{version.version}
</option>
))}
</select>
<Button type="submit">
Update Tag
</Button>
</form>
) : null}
</div>
</DetailHero>
</>
);
}
+27 -8
View File
@@ -3,7 +3,7 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SkillInstallSurface } from "./SkillInstallSurface";
import { SkillCommandLineCard, SkillInstallSurface } from "./SkillInstallSurface";
const writeTextMock = vi.fn();
@@ -57,7 +57,7 @@ describe("SkillInstallSurface", () => {
);
expect(screen.getByRole("heading", { name: "Install with OpenClaw" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "CLI Commands" })).toBeTruthy();
expect(screen.queryByRole("heading", { name: "CLI Commands" })).toBeNull();
expect(screen.getByText(/After install, inspect the skill metadata/i)).toBeTruthy();
expect(screen.getAllByText("Install & Setup").length).toBeGreaterThan(0);
@@ -72,9 +72,9 @@ describe("SkillInstallSurface", () => {
expect(screen.getAllByText("Install Only").length).toBeGreaterThan(0);
});
it("switches the ClawHub command and copies the visible CLI command", async () => {
it("defaults to CLI install and can copy the compact prompt tab", async () => {
render(
<SkillInstallSurface
<SkillCommandLineCard
slug="weather"
displayName="Weather"
ownerHandle="steipete"
@@ -82,13 +82,32 @@ describe("SkillInstallSurface", () => {
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Use pnpm for ClawHub install command" }));
expect(screen.getByText("pnpm dlx clawhub@latest install weather")).toBeTruthy();
expect(screen.getByText("openclaw skills install weather")).toBeTruthy();
expect(screen.queryByText("npx clawhub@latest install weather")).toBeNull();
expect(screen.getByRole("tab", { name: "CLI" }).getAttribute("aria-selected")).toBe("true");
expect(screen.getByRole("tab", { name: "Prompt" }).getAttribute("aria-selected")).toBe(
"false",
);
fireEvent.click(screen.getByRole("button", { name: "Copy ClawHub CLI command" }));
fireEvent.click(screen.getByRole("button", { name: "Copy OpenClaw CLI command" }));
await waitFor(() => {
expect(writeTextMock).toHaveBeenCalledWith("pnpm dlx clawhub@latest install weather");
expect(writeTextMock).toHaveBeenCalledWith("openclaw skills install weather");
});
fireEvent.click(screen.getByRole("tab", { name: "Prompt" }));
expect(screen.getByText(/Install the skill "Weather"/i)).toBeTruthy();
expect(screen.getByRole("tab", { name: "Prompt" }).getAttribute("aria-selected")).toBe(
"true",
);
fireEvent.click(screen.getByRole("button", { name: "Copy OpenClaw prompt" }));
await waitFor(() => {
expect(writeTextMock).toHaveBeenCalledWith(
expect.stringContaining("After install, inspect the skill metadata"),
);
});
});
});
+113 -108
View File
@@ -5,10 +5,8 @@ import type { Id } from "../../convex/_generated/dataModel";
import { copyText, InstallCopyButton } from "./InstallCopyButton";
import {
buildSkillInstallTarget,
formatClawHubInstallCommand,
formatOpenClawInstallCommand,
formatOpenClawPrompt,
type SkillPackageManager,
type SkillPromptMode,
} from "./skillDetailUtils";
import { Button } from "./ui/button";
@@ -19,8 +17,6 @@ import {
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
const PACKAGE_MANAGERS: SkillPackageManager[] = ["npm", "pnpm", "bun"];
const PROMPT_OPTIONS: Array<{
description: string;
label: string;
@@ -56,7 +52,6 @@ export function SkillInstallSurface({
clawdis,
}: SkillInstallSurfaceProps) {
const headingId = useId();
const [packageManager, setPackageManager] = useState<SkillPackageManager>("npm");
const [promptMode, setPromptMode] = useState<SkillPromptMode>("install-and-setup");
const [promptCopyState, setPromptCopyState] = useState<PromptCopyState>("idle");
const promptResetTimeoutRef = useRef<number | null>(null);
@@ -81,10 +76,9 @@ export function SkillInstallSurface({
}, 2000);
};
const selectedPrompt = PROMPT_OPTIONS.find((option) => option.mode === promptMode) ?? PROMPT_OPTIONS[1];
const selectedPrompt =
PROMPT_OPTIONS.find((option) => option.mode === promptMode) ?? PROMPT_OPTIONS[1];
const installTarget = buildSkillInstallTarget(ownerHandle, ownerId, slug);
const openClawCommand = formatOpenClawInstallCommand(slug);
const clawHubCommand = formatClawHubInstallCommand(slug, packageManager);
const promptPreview = formatOpenClawPrompt({
mode: promptMode,
skillName: displayName,
@@ -130,109 +124,120 @@ export function SkillInstallSurface({
Install
</h2>
<div className="skill-install-grid">
<article className="skill-install-panel">
<div className="skill-install-panel-header">
<p className="skill-install-kicker">OpenClaw Prompt Flow</p>
<h3 className="skill-install-panel-title">Install with OpenClaw</h3>
<p className="skill-install-panel-copy">
Best for remote or guided setup. Copy the exact prompt, then paste it into OpenClaw
for <code translate="no">{installTarget}</code>.
</p>
</div>
<article className="skill-install-panel">
<div className="skill-install-panel-header">
<p className="skill-install-kicker">OpenClaw Prompt Flow</p>
<h3 className="skill-install-panel-title">Install with OpenClaw</h3>
<p className="skill-install-panel-copy">
Best for remote or guided setup. Copy the exact prompt, then paste it into OpenClaw
for <code translate="no">{installTarget}</code>.
</p>
</div>
<div className="skill-install-actions">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" className="skill-install-prompt-trigger">
<span>Copy Prompt</span>
<ChevronDown className="h-4 w-4" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="skill-install-menu">
{PROMPT_OPTIONS.map((option) => (
<DropdownMenuItem key={option.mode} onSelect={() => selectPromptMode(option.mode)}>
<div className="skill-install-menu-copy">
<span className="skill-install-menu-label">{option.label}</span>
<span className="skill-install-menu-description">{option.description}</span>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<span className="skill-install-copy-feedback" aria-live="polite">
{promptFeedback}
</span>
</div>
<div className="skill-install-preview-meta">
<span className="skill-install-preview-label">Prompt Preview</span>
<span className="skill-install-preview-mode">{selectedPrompt.label}</span>
</div>
<pre className="skill-install-prompt-preview">
<code translate="no">{promptPreview}</code>
</pre>
</article>
<article className="skill-install-panel">
<div className="skill-install-panel-header">
<p className="skill-install-kicker">Command Line</p>
<h3 className="skill-install-panel-title">CLI Commands</h3>
<p className="skill-install-panel-copy">
Use the direct CLI path if you want to install manually and keep every step visible.
</p>
</div>
<div className="skill-install-command-card">
<div className="skill-install-command-header">
<div className="skill-install-command-copy">
<p className="skill-install-command-label">OpenClaw CLI</p>
<p className="skill-install-command-caption">Bare skill slug</p>
</div>
<InstallCopyButton
text={openClawCommand}
ariaLabel="Copy OpenClaw CLI command"
/>
</div>
<pre className="skill-install-command">
<code translate="no">{openClawCommand}</code>
</pre>
</div>
<div className="skill-install-command-card">
<div className="skill-install-command-header">
<div className="skill-install-command-copy">
<p className="skill-install-command-label">ClawHub CLI</p>
<p className="skill-install-command-caption">Package manager switcher</p>
</div>
<InstallCopyButton
text={clawHubCommand}
ariaLabel="Copy ClawHub CLI command"
/>
</div>
<div className="install-switcher-toggle" aria-label="ClawHub install command">
{PACKAGE_MANAGERS.map((entry) => (
<button
key={entry}
type="button"
aria-label={`Use ${entry} for ClawHub install command`}
aria-pressed={packageManager === entry}
className={`install-switcher-pill${packageManager === entry ? " is-active" : ""}`}
onClick={() => setPackageManager(entry)}
>
{entry}
</button>
<div className="skill-install-actions">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" className="skill-install-prompt-trigger">
<span>Copy Prompt</span>
<ChevronDown className="h-4 w-4" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="skill-install-menu">
{PROMPT_OPTIONS.map((option) => (
<DropdownMenuItem key={option.mode} onSelect={() => selectPromptMode(option.mode)}>
<div className="skill-install-menu-copy">
<span className="skill-install-menu-label">{option.label}</span>
<span className="skill-install-menu-description">{option.description}</span>
</div>
</DropdownMenuItem>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
<span className="skill-install-copy-feedback" aria-live="polite">
{promptFeedback}
</span>
</div>
<pre className="skill-install-command">
<code translate="no">{clawHubCommand}</code>
</pre>
</div>
</article>
</div>
<div className="skill-install-preview-meta">
<span className="skill-install-preview-label">Prompt Preview</span>
<span className="skill-install-preview-mode">{selectedPrompt.label}</span>
</div>
<pre className="skill-install-prompt-preview">
<code translate="no">{promptPreview}</code>
</pre>
</article>
</section>
);
}
export function SkillCommandLineCard({
slug,
displayName,
ownerHandle,
ownerId,
clawdis,
}: SkillInstallSurfaceProps) {
const [activeInstallTab, setActiveInstallTab] = useState<"cli" | "prompt">("cli");
const openClawCommand = formatOpenClawInstallCommand(slug);
const promptPreview = formatOpenClawPrompt({
mode: "install-and-setup",
skillName: displayName,
slug,
ownerHandle,
ownerId,
clawdis,
});
return (
<article className="skill-install-command-card">
<div className="skill-install-command-header">
<h3 className="skill-install-panel-title">Install</h3>
<div className="install-switcher-toggle" role="tablist" aria-label="Install option">
<button
type="button"
role="tab"
aria-selected={activeInstallTab === "cli"}
className={`install-switcher-pill${activeInstallTab === "cli" ? " is-active" : ""}`}
onClick={() => setActiveInstallTab("cli")}
>
CLI
</button>
<button
type="button"
role="tab"
aria-selected={activeInstallTab === "prompt"}
className={`install-switcher-pill${
activeInstallTab === "prompt" ? " is-active" : ""
}`}
onClick={() => setActiveInstallTab("prompt")}
>
Prompt
</button>
</div>
</div>
<div className="skill-install-command-wrap">
<pre
className={`skill-install-command${
activeInstallTab === "prompt" ? " skill-install-prompt-compact" : ""
}`}
>
<code translate="no">
{activeInstallTab === "prompt" ? promptPreview : openClawCommand}
</code>
</pre>
<InstallCopyButton
text={activeInstallTab === "prompt" ? promptPreview : openClawCommand}
ariaLabel={
activeInstallTab === "prompt"
? "Copy OpenClaw prompt"
: "Copy OpenClaw CLI command"
}
className="skill-install-command-inline-button"
showLabel={false}
/>
</div>
</article>
);
}
-118
View File
@@ -1,118 +0,0 @@
import type { ClawdisSkillMetadata } from "clawhub-schema";
import { PLATFORM_SKILL_LICENSE } from "clawhub-schema/licenseConstants";
import { Calendar, Download, Package, Scale, Star, Tag } from "lucide-react";
import type { Id } from "../../convex/_generated/dataModel";
import { formatCompactStat } from "../lib/numberFormat";
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
import { getRuntimeEnv } from "../lib/runtimeEnv";
import { timeAgo } from "../lib/timeAgo";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { UserBadge } from "./UserBadge";
type SkillMetadataSidebarProps = {
skill: PublicSkill;
latestVersion: { version?: string; _id: Id<"skillVersions"> } | null;
owner: PublicPublisher | null;
ownerHandle: string | null;
clawdis?: ClawdisSkillMetadata;
osLabels: string[];
tagEntries: Array<[string, Id<"skillVersions">]>;
isMalwareBlocked?: boolean;
isRemoved?: boolean;
nixPlugin?: string;
};
export function SkillMetadataSidebar({
skill,
latestVersion,
owner,
ownerHandle,
clawdis: _clawdis,
osLabels,
tagEntries,
isMalwareBlocked,
isRemoved,
nixPlugin,
}: SkillMetadataSidebarProps) {
const convexSiteUrl = getRuntimeEnv("VITE_CONVEX_SITE_URL") ?? "https://clawhub.ai";
const showDownload = !nixPlugin && !isMalwareBlocked && !isRemoved;
return (
<div className="detail-meta-bar">
{/* Stats row */}
<div className="meta-bar-stats">
<div className="meta-stat">
<Download size={14} aria-hidden="true" />
<span className="meta-stat-value">{formatCompactStat(skill.stats.downloads)}</span>
<span className="meta-stat-label">downloads</span>
</div>
<div className="meta-stat">
<Star size={14} aria-hidden="true" />
<span className="meta-stat-value">{formatCompactStat(skill.stats.stars)}</span>
<span className="meta-stat-label">stars</span>
</div>
<div className="meta-stat">
<Package size={14} aria-hidden="true" />
<span className="meta-stat-value">{formatCompactStat(skill.stats.versions ?? 0)}</span>
<span className="meta-stat-label">versions</span>
</div>
</div>
{/* Details row */}
<div className="meta-bar-details">
<div className="meta-detail">
<Calendar size={12} aria-hidden="true" />
<span>Updated {timeAgo(skill.updatedAt)}</span>
</div>
{latestVersion?.version ? (
<div className="meta-detail">
<Tag size={12} aria-hidden="true" />
<span>v{latestVersion.version}</span>
</div>
) : null}
<div className="meta-detail">
<Scale size={12} aria-hidden="true" />
<span>{PLATFORM_SKILL_LICENSE}</span>
</div>
{osLabels.length > 0 ? (
<div className="meta-detail">
<span>{osLabels.join(", ")}</span>
</div>
) : null}
</div>
{/* Tags and Publisher row */}
<div className="meta-bar-footer">
<div className="meta-bar-publisher">
<UserBadge
user={owner}
fallbackHandle={ownerHandle}
prefix=""
size="sm"
showName
/>
</div>
{tagEntries.length > 0 ? (
<div className="meta-bar-tags">
{tagEntries.map(([tag]) => (
<Badge key={tag} variant="compact">
{tag}
</Badge>
))}
</div>
) : null}
{showDownload ? (
<Button asChild variant="primary" size="sm">
<a href={`${convexSiteUrl}/api/v1/download?slug=${skill.slug}`}>
<Download size={14} aria-hidden="true" />
Download
</a>
</Button>
) : null}
</div>
</div>
);
}
+61 -40
View File
@@ -1,3 +1,4 @@
import { ShieldCheck } from "lucide-react";
import { useState } from "react";
import { Badge } from "./ui/badge";
@@ -53,10 +54,11 @@ type SecurityScanResultsProps = {
llmAnalysis?: LlmAnalysis | null;
staticFindings?: StaticFinding[];
capabilityTags?: string[] | null;
scannerBasePath?: string | null;
variant?: "panel" | "badge";
};
function VirusTotalIcon({ className }: { className?: string }) {
export function VirusTotalIcon({ className }: { className?: string }) {
return (
<svg
className={className}
@@ -76,36 +78,11 @@ function VirusTotalIcon({ className }: { className?: string }) {
);
}
function OpenClawIcon({ className }: { className?: string }) {
return (
<svg
className={className}
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="none"
aria-label="OpenClaw"
>
<title>OpenClaw</title>
<path
d="M12 2C8.5 2 5.5 4 4 7c-2 4-1 8 2 11 1.5 1.5 3.5 2.5 6 2.5s4.5-1 6-2.5c3-3 4-7 2-11-1.5-3-4.5-5-8-5z"
fill="currentColor"
opacity="0.2"
/>
<path
d="M9 8c1-2 3-3 5-2s3 3 2 5l-3 4-2-1 3-4c.5-1 0-2-1-2.5S11 7 10.5 8L8 12l-2-1 3-4z"
fill="currentColor"
/>
<path
d="M15 8c-1-2-3-3-5-2s-3 3-2 5l3 4 2-1-3-4c-.5-1 0-2 1-2.5S14 7 14.5 8L17 12l2-1-4-3z"
fill="currentColor"
opacity="0.6"
/>
</svg>
);
export function ClawScanIcon({ className }: { className?: string }) {
return <ShieldCheck className={className} aria-label="ClawScan" />;
}
function getScanStatusInfo(status: string) {
export function getScanStatusInfo(status: string) {
switch (status.toLowerCase()) {
case "benign":
case "clean":
@@ -250,7 +227,7 @@ function getStaticGuidance(findings: StaticFinding[], vtStatus?: string, llmStat
return {
className: "benign",
label: "Confirmed safe by external scanners",
text: "Static analysis detected API credential-access patterns, but both VirusTotal and OpenClaw confirmed this skill is safe. These patterns are common in legitimate API integration skills.",
text: "Static analysis detected API credential-access patterns, but both VirusTotal and ClawScan confirmed this skill is safe. These patterns are common in legitimate API integration skills.",
};
}
const hasCritical = findings.some((f) => f.severity === "critical");
@@ -258,13 +235,13 @@ function getStaticGuidance(findings: StaticFinding[], vtStatus?: string, llmStat
return {
className: "suspicious",
label: "Patterns worth reviewing",
text: "These patterns may indicate risky behavior. Check the VirusTotal and OpenClaw results above for context-aware analysis before installing.",
text: "These patterns may indicate risky behavior. Check the VirusTotal and ClawScan results above for context-aware analysis before installing.",
};
}
return {
className: "benign",
label: "About static analysis",
text: "These patterns were detected by automated regex scanning. They may be normal for skills that integrate with external APIs. Check the VirusTotal and OpenClaw results above for context-aware analysis.",
text: "These patterns were detected by automated regex scanning. They may be normal for skills that integrate with external APIs. Check the VirusTotal and ClawScan results above for context-aware analysis.",
};
}
@@ -334,6 +311,7 @@ export function SecurityScanResults({
llmAnalysis,
staticFindings,
capabilityTags,
scannerBasePath,
variant = "panel",
}: SecurityScanResultsProps) {
const visibleCapabilityTags = (capabilityTags ?? []).filter(Boolean);
@@ -369,12 +347,30 @@ export function SecurityScanResults({
</a>
) : null}
{scannerBasePath ? (
<a
href={`${scannerBasePath}/virustotal`}
className="version-scan-link"
onClick={(event) => event.stopPropagation()}
>
Details
</a>
) : null}
</div>
) : null}
{llmStatusInfo ? (
<div className="version-scan-badge">
<OpenClawIcon className="version-scan-icon version-scan-icon-oc" />
<ClawScanIcon className="version-scan-icon version-scan-icon-oc" />
<span className={llmStatusInfo.className}>{llmStatusInfo.label}</span>
{scannerBasePath ? (
<a
href={`${scannerBasePath}/openclaw`}
className="version-scan-link"
onClick={(event) => event.stopPropagation()}
>
Details
</a>
) : null}
</div>
) : null}
</>
@@ -420,6 +416,11 @@ export function SecurityScanResults({
View report
</a>
) : null}
{scannerBasePath ? (
<a href={`${scannerBasePath}/virustotal`} className="scan-result-link">
Details
</a>
) : null}
</div>
) : null}
{isCodeInsight && aiAnalysis && (vtStatus === "malicious" || vtStatus === "suspicious") ? (
@@ -431,8 +432,8 @@ export function SecurityScanResults({
{llmStatusInfo && llmAnalysis ? (
<div className="scan-result-row">
<div className="scan-result-scanner">
<OpenClawIcon className="scan-result-icon scan-result-icon-oc" />
<span className="scan-result-scanner-name">OpenClaw</span>
<ClawScanIcon className="scan-result-icon scan-result-icon-oc" />
<span className="scan-result-scanner-name">ClawScan</span>
</div>
<div className={`scan-result-status ${llmStatusInfo.className}`}>
{llmStatusInfo.label}
@@ -440,6 +441,11 @@ export function SecurityScanResults({
{llmAnalysis.confidence ? (
<span className="scan-result-confidence">{llmAnalysis.confidence} confidence</span>
) : null}
{scannerBasePath ? (
<a href={`${scannerBasePath}/openclaw`} className="scan-result-link">
Details
</a>
) : null}
</div>
) : null}
{llmAnalysis &&
@@ -449,11 +455,26 @@ export function SecurityScanResults({
<LlmAnalysisDetail analysis={llmAnalysis} />
) : null}
{staticFindings && staticFindings.length > 0 ? (
<StaticAnalysisDetail
findings={staticFindings}
vtStatus={vtStatus}
llmStatus={llmVerdict}
/>
<>
{scannerBasePath ? (
<div className="scan-result-row">
<div className="scan-result-scanner">
<span className="scan-result-scanner-name">Static analysis</span>
</div>
<div className="scan-result-status scan-status-suspicious">
{staticFindings.length} finding{staticFindings.length === 1 ? "" : "s"}
</div>
<a href={`${scannerBasePath}/static-analysis`} className="scan-result-link">
Details
</a>
</div>
) : null}
<StaticAnalysisDetail
findings={staticFindings}
vtStatus={vtStatus}
llmStatus={llmVerdict}
/>
</>
) : null}
</div>
</div>
+24 -25
View File
@@ -2,38 +2,37 @@ import { Skeleton } from "../ui/skeleton";
export function DashboardSkeleton() {
return (
<div className="mx-auto max-w-page-max px-7 py-10">
{/* Header */}
<div className="mb-8 flex items-center justify-between">
<Skeleton className="h-8 w-52" />
<div className="flex gap-3">
<Skeleton className="h-[44px] w-32 rounded-[var(--radius-pill)]" />
<Skeleton className="h-[44px] w-36 rounded-[var(--radius-pill)]" />
<main className="section">
<div className="dashboard-header">
<div className="grid gap-2">
<Skeleton className="h-8 w-36" />
<Skeleton className="h-4 w-72 max-w-full" />
</div>
</div>
{/* Stats row */}
<div className="mb-8 grid grid-cols-2 gap-4 sm:grid-cols-4">
{Array.from({ length: 4 }, (_, i) => (
<div className="dashboard-owner-grid">
{["skills", "plugins"].map((section) => (
<div
key={i}
className="flex flex-col gap-2 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5"
key={section}
className="dashboard-owner-panel flex w-full flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-space-5"
>
<Skeleton className="h-4 w-20" />
<Skeleton className="h-7 w-16" />
<div className="dashboard-section-header">
<Skeleton className="h-7 w-24" />
<Skeleton className="h-[34px] w-28 rounded-[var(--r-btn)]" />
</div>
<div className="dashboard-list">
{Array.from({ length: section === "skills" ? 2 : 3 }, (_, index) => (
<div key={index} className="dashboard-list-row">
<Skeleton className="h-5 w-48 max-w-full" />
<Skeleton className="h-5 w-96 max-w-full" />
<Skeleton className="h-8 w-24 rounded-[var(--radius-pill)]" />
<Skeleton className="h-8 w-8 rounded-[var(--r-btn)]" />
</div>
))}
</div>
</div>
))}
</div>
{/* Publisher tabs */}
<Skeleton className="mb-6 h-[44px] w-64 rounded-[var(--radius-pill)]" />
{/* Table skeleton */}
<div className="flex flex-col gap-3">
{Array.from({ length: 5 }, (_, i) => (
<Skeleton key={i} className="h-16 w-full rounded-[var(--radius-sm)]" />
))}
</div>
</div>
</main>
);
}
@@ -2,71 +2,64 @@ import { Skeleton } from "../ui/skeleton";
export function SkillDetailSkeleton() {
return (
<div className="mx-auto max-w-page-max px-7 py-10">
{/* Breadcrumb */}
<Skeleton className="mb-6 h-4 w-48" />
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[1fr_340px]">
{/* Main column */}
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-6 w-16 rounded-[var(--radius-pill)]" />
<div className="skill-detail-stack">
<div className="rounded-[var(--r-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5">
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 flex-1 space-y-4">
<Skeleton className="h-4 w-48" />
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
<Skeleton className="h-9 w-full max-w-[360px]" />
<Skeleton className="h-6 w-20 rounded-[var(--r-pill)]" />
</div>
<Skeleton className="h-5 w-full max-w-[680px]" />
<Skeleton className="h-5 w-3/4 max-w-[520px]" />
</div>
<Skeleton className="h-5 w-full max-w-lg" />
{/* Meta row */}
<div className="flex items-center gap-4">
<Skeleton className="h-6 w-6 rounded-full" />
<div className="flex flex-wrap items-center gap-4">
<Skeleton className="h-8 w-8 rounded-full" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-28" />
<Skeleton className="h-4 w-20" />
</div>
</div>
{/* README skeleton */}
<div className="flex flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-6">
<Skeleton className="h-6 w-32" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="mt-2 h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-2/3" />
</div>
{/* Tabs skeleton */}
<div className="flex gap-2">
<Skeleton className="h-[44px] w-24 rounded-[var(--radius-pill)]" />
<Skeleton className="h-[44px] w-28 rounded-[var(--radius-pill)]" />
<Skeleton className="h-[44px] w-24 rounded-[var(--radius-pill)]" />
</div>
</div>
{/* Sidebar */}
<div className="flex flex-col gap-5">
{/* Install card */}
<div className="flex flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-10 w-full rounded-[var(--radius-sm)]" />
<Skeleton className="h-10 w-full rounded-[var(--radius-pill)]" />
</div>
{/* Stats card */}
<div className="flex flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5">
<Skeleton className="h-4 w-16" />
<div className="w-full space-y-3 lg:max-w-[360px]">
<Skeleton className="h-12 w-full rounded-[var(--r-sm)]" />
<Skeleton className="h-12 w-full rounded-[var(--r-pill)]" />
<div className="grid grid-cols-3 gap-3">
<Skeleton className="h-12" />
<Skeleton className="h-12" />
<Skeleton className="h-12" />
<Skeleton className="h-14" />
<Skeleton className="h-14" />
<Skeleton className="h-14" />
</div>
</div>
{/* Security card */}
<div className="flex flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-8 w-full" />
</div>
</div>
<div className="detail-layout">
<div className="detail-main">
<div className="rounded-[var(--r-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5">
<div className="mb-4 flex flex-wrap gap-2">
<Skeleton className="h-10 w-24 rounded-[var(--r-pill)]" />
<Skeleton className="h-10 w-20 rounded-[var(--r-pill)]" />
<Skeleton className="h-10 w-24 rounded-[var(--r-pill)]" />
</div>
<div className="space-y-3">
<Skeleton className="h-6 w-40" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-11/12" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="mt-5 h-5 w-52" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
<div className="rounded-[var(--r-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5">
<Skeleton className="mb-3 h-6 w-28" />
<Skeleton className="h-4 w-2/3" />
</div>
</div>
</div>
</div>
);
+8 -7
View File
@@ -7,24 +7,25 @@ const buttonVariants = cva(
[
"inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap font-semibold transition-all duration-200 ease-out",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]/35 focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg)]",
"disabled:pointer-events-none disabled:opacity-60",
"cursor-pointer disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-60",
"!no-underline hover:!no-underline",
"[&_svg]:pointer-events-none [&_svg]:shrink-0",
],
{
variants: {
variant: {
default:
"border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)] hover:not-disabled:-translate-y-px hover:not-disabled:shadow-hover",
"border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)] hover:not-disabled:bg-[color:var(--surface-muted)]",
primary:
"border border-accent bg-accent/10 text-[color:var(--ink)] hover:not-disabled:-translate-y-px hover:not-disabled:shadow-hover",
"border border-[color:var(--border-ui)] bg-[color:var(--surface-muted)] text-[color:var(--ink)] hover:not-disabled:bg-[color:var(--hover-bg)]",
secondary:
"border border-[color:var(--line)] bg-[color:var(--surface-muted)] text-[color:var(--ink)] hover:not-disabled:-translate-y-px hover:not-disabled:shadow-hover",
"border border-[color:var(--line)] bg-[color:var(--surface-muted)] text-[color:var(--ink)] hover:not-disabled:bg-[color:var(--hover-bg)]",
destructive:
"border border-status-error-fg/20 bg-status-error-bg text-status-error-fg hover:not-disabled:-translate-y-px hover:not-disabled:bg-active-bg hover:not-disabled:shadow-hover",
"border border-status-error-fg/20 bg-status-error-bg text-status-error-fg hover:not-disabled:bg-active-bg",
ghost:
"border border-transparent bg-transparent text-[color:var(--ink-soft)] hover:not-disabled:bg-[color:var(--surface-muted)] hover:not-disabled:text-[color:var(--ink)]",
outline:
"border border-[color:var(--border-ui)] bg-transparent text-[color:var(--ink)] hover:not-disabled:-translate-y-px hover:not-disabled:border-[color:var(--border-ui-hover)] hover:not-disabled:bg-[color:var(--surface)] hover:not-disabled:shadow-hover",
"border border-[color:var(--border-ui)] bg-transparent text-[color:var(--ink)] hover:not-disabled:border-[color:var(--border-ui-hover)] hover:not-disabled:bg-[color:var(--surface)]",
link: "h-auto border border-transparent bg-transparent p-0 text-[color:var(--accent-deep)] underline-offset-4 hover:underline disabled:opacity-60",
},
size: {
@@ -78,7 +79,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
{...props}
>
{loading && (
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-current/25 border-t-current" />
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-current/25 border-t-current [animation-duration:2.4s]" />
)}
<Slottable>{children}</Slottable>
</Comp>
+1 -60
View File
@@ -53,10 +53,6 @@ const SOULS_SEARCH = {
focus: undefined,
} as const;
const USERS_SEARCH = { q: undefined } as const;
const MANAGEMENT_SEARCH = { skill: undefined } as const;
// ---------------------------------------------------------------------------
// Primary nav items (desktop tabs row + mobile dropdown top section)
// These map to the "content-type" tabs: Skills | Plugins | Souls
@@ -100,55 +96,6 @@ export const PRIMARY_NAV_ITEMS: NavItem[] = [
},
];
// ---------------------------------------------------------------------------
// Secondary nav items (secondary tabs row + mobile dropdown lower section)
// ---------------------------------------------------------------------------
export const SECONDARY_NAV_ITEMS: NavItem[] = [
{
label: "Users",
to: "/users",
search: USERS_SEARCH,
authRequired: false,
staffOnly: false,
soulModeOnly: false,
soulModeHide: true,
},
{
label: "About",
to: "/about",
authRequired: false,
staffOnly: false,
soulModeOnly: false,
soulModeHide: true,
},
{
label: "Stars",
to: "/stars",
authRequired: true,
staffOnly: false,
soulModeOnly: false,
soulModeHide: false,
},
{
label: "Dashboard",
to: "/dashboard",
authRequired: true,
staffOnly: false,
soulModeOnly: false,
soulModeHide: false,
},
{
label: "Management",
to: "/management",
search: MANAGEMENT_SEARCH,
authRequired: true,
staffOnly: true,
soulModeOnly: false,
soulModeHide: false,
},
];
// ---------------------------------------------------------------------------
// Footer sections
// ---------------------------------------------------------------------------
@@ -199,17 +146,11 @@ export const FOOTER_NAV_SECTIONS: FooterNavSection[] = [
{
title: "Community",
items: [
{ kind: "link", label: "About", to: "/about" },
{ kind: "external", label: "GitHub", href: "https://github.com/openclaw/clawhub" },
{ kind: "external", label: "OpenClaw", href: "https://openclaw.ai" },
],
},
{
title: "Platform",
items: [
{ kind: "external", label: "Deployed on Vercel", href: "https://vercel.com" },
{ kind: "external", label: "Powered by Convex", href: "https://www.convex.dev" },
],
},
];
// ---------------------------------------------------------------------------
+1 -1
View File
@@ -388,7 +388,7 @@ export async function fetchPackageReadme(name: string, version?: string | null):
if (version) url.searchParams.set("version", version);
const response = await packageFetch(url, "text/plain");
if (response.ok) return await response.text();
if (response.status === 404 || response.status === 423) {
if (response.status === 403 || response.status === 404 || response.status === 423) {
return null;
}
throw await createPackageApiError(response);
+95 -10
View File
@@ -35,6 +35,9 @@ import { Route as PackagesNameRouteImport } from './routes/packages/$name'
import { Route as OrgsHandleRouteImport } from './routes/orgs/$handle'
import { Route as CliAuthRouteImport } from './routes/cli/auth'
import { Route as OwnerSlugRouteImport } from './routes/$owner/$slug'
import { Route as OwnerSlugSettingsRouteImport } from './routes/$owner/$slug/settings'
import { Route as PluginsNameSecurityScannerRouteImport } from './routes/plugins/$name/security/$scanner'
import { Route as OwnerSlugSecurityScannerRouteImport } from './routes/$owner/$slug/security/$scanner'
const UploadRoute = UploadRouteImport.update({
id: '/upload',
@@ -166,6 +169,23 @@ const OwnerSlugRoute = OwnerSlugRouteImport.update({
path: '/$owner/$slug',
getParentRoute: () => rootRouteImport,
} as any)
const OwnerSlugSettingsRoute = OwnerSlugSettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => OwnerSlugRoute,
} as any)
const PluginsNameSecurityScannerRoute =
PluginsNameSecurityScannerRouteImport.update({
id: '/security/$scanner',
path: '/security/$scanner',
getParentRoute: () => PluginsNameRoute,
} as any)
const OwnerSlugSecurityScannerRoute =
OwnerSlugSecurityScannerRouteImport.update({
id: '/security/$scanner',
path: '/security/$scanner',
getParentRoute: () => OwnerSlugRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
@@ -180,12 +200,12 @@ export interface FileRoutesByFullPath {
'/settings': typeof SettingsRoute
'/stars': typeof StarsRoute
'/upload': typeof UploadRoute
'/$owner/$slug': typeof OwnerSlugRoute
'/$owner/$slug': typeof OwnerSlugRouteWithChildren
'/cli/auth': typeof CliAuthRoute
'/orgs/$handle': typeof OrgsHandleRoute
'/packages/$name': typeof PackagesNameRoute
'/packages/new': typeof PackagesNewRoute
'/plugins/$name': typeof PluginsNameRoute
'/plugins/$name': typeof PluginsNameRouteWithChildren
'/plugins/new': typeof PluginsNewRoute
'/souls/$slug': typeof SoulsSlugRoute
'/u/$handle': typeof UHandleRoute
@@ -194,6 +214,9 @@ export interface FileRoutesByFullPath {
'/skills/': typeof SkillsIndexRoute
'/souls/': typeof SoulsIndexRoute
'/users/': typeof UsersIndexRoute
'/$owner/$slug/settings': typeof OwnerSlugSettingsRoute
'/$owner/$slug/security/$scanner': typeof OwnerSlugSecurityScannerRoute
'/plugins/$name/security/$scanner': typeof PluginsNameSecurityScannerRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
@@ -208,12 +231,12 @@ export interface FileRoutesByTo {
'/settings': typeof SettingsRoute
'/stars': typeof StarsRoute
'/upload': typeof UploadRoute
'/$owner/$slug': typeof OwnerSlugRoute
'/$owner/$slug': typeof OwnerSlugRouteWithChildren
'/cli/auth': typeof CliAuthRoute
'/orgs/$handle': typeof OrgsHandleRoute
'/packages/$name': typeof PackagesNameRoute
'/packages/new': typeof PackagesNewRoute
'/plugins/$name': typeof PluginsNameRoute
'/plugins/$name': typeof PluginsNameRouteWithChildren
'/plugins/new': typeof PluginsNewRoute
'/souls/$slug': typeof SoulsSlugRoute
'/u/$handle': typeof UHandleRoute
@@ -222,6 +245,9 @@ export interface FileRoutesByTo {
'/skills': typeof SkillsIndexRoute
'/souls': typeof SoulsIndexRoute
'/users': typeof UsersIndexRoute
'/$owner/$slug/settings': typeof OwnerSlugSettingsRoute
'/$owner/$slug/security/$scanner': typeof OwnerSlugSecurityScannerRoute
'/plugins/$name/security/$scanner': typeof PluginsNameSecurityScannerRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -237,12 +263,12 @@ export interface FileRoutesById {
'/settings': typeof SettingsRoute
'/stars': typeof StarsRoute
'/upload': typeof UploadRoute
'/$owner/$slug': typeof OwnerSlugRoute
'/$owner/$slug': typeof OwnerSlugRouteWithChildren
'/cli/auth': typeof CliAuthRoute
'/orgs/$handle': typeof OrgsHandleRoute
'/packages/$name': typeof PackagesNameRoute
'/packages/new': typeof PackagesNewRoute
'/plugins/$name': typeof PluginsNameRoute
'/plugins/$name': typeof PluginsNameRouteWithChildren
'/plugins/new': typeof PluginsNewRoute
'/souls/$slug': typeof SoulsSlugRoute
'/u/$handle': typeof UHandleRoute
@@ -251,6 +277,9 @@ export interface FileRoutesById {
'/skills/': typeof SkillsIndexRoute
'/souls/': typeof SoulsIndexRoute
'/users/': typeof UsersIndexRoute
'/$owner/$slug/settings': typeof OwnerSlugSettingsRoute
'/$owner/$slug/security/$scanner': typeof OwnerSlugSecurityScannerRoute
'/plugins/$name/security/$scanner': typeof PluginsNameSecurityScannerRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -281,6 +310,9 @@ export interface FileRouteTypes {
| '/skills/'
| '/souls/'
| '/users/'
| '/$owner/$slug/settings'
| '/$owner/$slug/security/$scanner'
| '/plugins/$name/security/$scanner'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@@ -309,6 +341,9 @@ export interface FileRouteTypes {
| '/skills'
| '/souls'
| '/users'
| '/$owner/$slug/settings'
| '/$owner/$slug/security/$scanner'
| '/plugins/$name/security/$scanner'
id:
| '__root__'
| '/'
@@ -337,6 +372,9 @@ export interface FileRouteTypes {
| '/skills/'
| '/souls/'
| '/users/'
| '/$owner/$slug/settings'
| '/$owner/$slug/security/$scanner'
| '/plugins/$name/security/$scanner'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -352,12 +390,12 @@ export interface RootRouteChildren {
SettingsRoute: typeof SettingsRoute
StarsRoute: typeof StarsRoute
UploadRoute: typeof UploadRoute
OwnerSlugRoute: typeof OwnerSlugRoute
OwnerSlugRoute: typeof OwnerSlugRouteWithChildren
CliAuthRoute: typeof CliAuthRoute
OrgsHandleRoute: typeof OrgsHandleRoute
PackagesNameRoute: typeof PackagesNameRoute
PackagesNewRoute: typeof PackagesNewRoute
PluginsNameRoute: typeof PluginsNameRoute
PluginsNameRoute: typeof PluginsNameRouteWithChildren
PluginsNewRoute: typeof PluginsNewRoute
SoulsSlugRoute: typeof SoulsSlugRoute
UHandleRoute: typeof UHandleRoute
@@ -552,9 +590,56 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof OwnerSlugRouteImport
parentRoute: typeof rootRouteImport
}
'/$owner/$slug/settings': {
id: '/$owner/$slug/settings'
path: '/settings'
fullPath: '/$owner/$slug/settings'
preLoaderRoute: typeof OwnerSlugSettingsRouteImport
parentRoute: typeof OwnerSlugRoute
}
'/plugins/$name/security/$scanner': {
id: '/plugins/$name/security/$scanner'
path: '/security/$scanner'
fullPath: '/plugins/$name/security/$scanner'
preLoaderRoute: typeof PluginsNameSecurityScannerRouteImport
parentRoute: typeof PluginsNameRoute
}
'/$owner/$slug/security/$scanner': {
id: '/$owner/$slug/security/$scanner'
path: '/security/$scanner'
fullPath: '/$owner/$slug/security/$scanner'
preLoaderRoute: typeof OwnerSlugSecurityScannerRouteImport
parentRoute: typeof OwnerSlugRoute
}
}
}
interface OwnerSlugRouteChildren {
OwnerSlugSettingsRoute: typeof OwnerSlugSettingsRoute
OwnerSlugSecurityScannerRoute: typeof OwnerSlugSecurityScannerRoute
}
const OwnerSlugRouteChildren: OwnerSlugRouteChildren = {
OwnerSlugSettingsRoute: OwnerSlugSettingsRoute,
OwnerSlugSecurityScannerRoute: OwnerSlugSecurityScannerRoute,
}
const OwnerSlugRouteWithChildren = OwnerSlugRoute._addFileChildren(
OwnerSlugRouteChildren,
)
interface PluginsNameRouteChildren {
PluginsNameSecurityScannerRoute: typeof PluginsNameSecurityScannerRoute
}
const PluginsNameRouteChildren: PluginsNameRouteChildren = {
PluginsNameSecurityScannerRoute: PluginsNameSecurityScannerRoute,
}
const PluginsNameRouteWithChildren = PluginsNameRoute._addFileChildren(
PluginsNameRouteChildren,
)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AboutRoute: AboutRoute,
@@ -568,12 +653,12 @@ const rootRouteChildren: RootRouteChildren = {
SettingsRoute: SettingsRoute,
StarsRoute: StarsRoute,
UploadRoute: UploadRoute,
OwnerSlugRoute: OwnerSlugRoute,
OwnerSlugRoute: OwnerSlugRouteWithChildren,
CliAuthRoute: CliAuthRoute,
OrgsHandleRoute: OrgsHandleRoute,
PackagesNameRoute: PackagesNameRoute,
PackagesNewRoute: PackagesNewRoute,
PluginsNameRoute: PluginsNameRoute,
PluginsNameRoute: PluginsNameRouteWithChildren,
PluginsNewRoute: PluginsNewRoute,
SoulsSlugRoute: SoulsSlugRoute,
UHandleRoute: UHandleRoute,
+14 -1
View File
@@ -1,4 +1,10 @@
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
import {
createFileRoute,
notFound,
Outlet,
redirect,
useRouterState,
} from "@tanstack/react-router";
import { SkillDetailPage } from "../../components/SkillDetailPage";
import { buildSkillMeta } from "../../lib/og";
import { fetchSkillPageData } from "../../lib/skillPage";
@@ -72,5 +78,12 @@ export const Route = createFileRoute("/$owner/$slug")({
function OwnerSkill() {
const { owner, slug } = Route.useParams();
const { initialData } = Route.useLoaderData();
const pathname = useRouterState({ select: (state) => state.location.pathname });
if (
pathname.includes(`/${encodeURIComponent(slug)}/security/`) ||
pathname.endsWith(`/${encodeURIComponent(slug)}/settings`)
) {
return <Outlet />;
}
return <SkillDetailPage slug={slug} canonicalOwner={owner} initialData={initialData} />;
}
@@ -0,0 +1,121 @@
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
import { useQuery } from "convex/react";
import { api } from "../../../../../convex/_generated/api";
import {
SecurityScannerPage,
type ScannerSlug,
} from "../../../../components/SecurityScannerPage";
import { buildSkillMeta } from "../../../../lib/og";
import { fetchSkillPageData } from "../../../../lib/skillPage";
const SCANNERS = new Set<ScannerSlug>(["virustotal", "openclaw", "static-analysis"]);
function parseScanner(scanner: string): ScannerSlug {
if (SCANNERS.has(scanner as ScannerSlug)) return scanner as ScannerSlug;
throw notFound();
}
export const Route = createFileRoute("/$owner/$slug/security/$scanner")({
beforeLoad: ({ params }) => {
const isHandle = /^[a-zA-Z0-9_][a-zA-Z0-9_-]*$/.test(params.owner);
const isOwnerId = params.owner.startsWith("users:") || params.owner.startsWith("publishers:");
if (!isHandle && !isOwnerId) {
throw notFound();
}
parseScanner(params.scanner);
},
loader: async ({ params }) => {
const data = await fetchSkillPageData(params.slug);
const canonicalOwner = data.initialData?.result?.owner?.handle ?? null;
const canonicalSlug = data.initialData?.result?.resolvedSlug ?? params.slug;
if (canonicalOwner && (canonicalOwner !== params.owner || canonicalSlug !== params.slug)) {
throw redirect({
to: "/$owner/$slug/security/$scanner",
params: { owner: canonicalOwner, slug: canonicalSlug, scanner: params.scanner },
replace: true,
});
}
return {
owner: data?.owner ?? params.owner,
displayName: data?.displayName ?? null,
summary: data?.summary ?? null,
version: data?.version ?? null,
initialData: data.initialData,
};
},
head: ({ params, loaderData }) => {
const scanner = parseScanner(params.scanner);
const scannerLabel =
scanner === "virustotal"
? "VirusTotal"
: scanner === "openclaw"
? "ClawScan"
: "Static analysis";
const meta = buildSkillMeta({
slug: params.slug,
owner: loaderData?.owner ?? params.owner,
displayName: loaderData?.displayName,
summary: loaderData?.summary,
version: loaderData?.version ?? null,
});
return {
meta: [
{ title: `${scannerLabel} security · ${meta.title}` },
{
name: "description",
content: `${scannerLabel} security details for ${loaderData?.displayName ?? params.slug}.`,
},
],
};
},
component: SkillSecurityScannerRoute,
});
function SkillSecurityScannerRoute() {
const { owner, slug, scanner } = Route.useParams();
const { initialData } = Route.useLoaderData();
const liveResult = useQuery(api.skills.getBySlug, { slug });
const result = liveResult === undefined ? initialData?.result : liveResult;
const skill = result?.skill;
const latestVersion = result?.latestVersion;
if (result === undefined) {
return (
<main className="section">
<div className="card">Loading security details...</div>
</main>
);
}
if (!skill || !latestVersion) {
return (
<main className="section">
<div className="card">Security details are unavailable for this skill.</div>
</main>
);
}
const ownerSegment = result?.owner?.handle ?? result?.owner?._id ?? owner;
return (
<SecurityScannerPage
scanner={parseScanner(scanner)}
entity={{
kind: "skill",
title: skill.displayName,
name: slug,
version: latestVersion.version,
owner: result?.owner ?? null,
ownerUserId: skill.ownerUserId,
ownerPublisherId: skill.ownerPublisherId ?? null,
detailPath: `/${encodeURIComponent(ownerSegment)}/${encodeURIComponent(slug)}`,
}}
sha256hash={latestVersion.sha256hash ?? null}
vtAnalysis={latestVersion.vtAnalysis ?? null}
llmAnalysis={latestVersion.llmAnalysis ?? null}
staticScan={latestVersion.staticScan ?? null}
/>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
import { SkillDetailPage } from "../../../components/SkillDetailPage";
import { buildSkillMeta } from "../../../lib/og";
import { fetchSkillPageData } from "../../../lib/skillPage";
export const Route = createFileRoute("/$owner/$slug/settings")({
beforeLoad: ({ params }) => {
const isHandle = /^[a-zA-Z0-9_][a-zA-Z0-9_-]*$/.test(params.owner);
const isOwnerId = params.owner.startsWith("users:") || params.owner.startsWith("publishers:");
if (!isHandle && !isOwnerId) {
throw notFound();
}
},
loader: async ({ params }) => {
const data = await fetchSkillPageData(params.slug);
const canonicalOwner = data.initialData?.result?.owner?.handle ?? null;
const canonicalSlug = data.initialData?.result?.resolvedSlug ?? params.slug;
if (canonicalOwner && (canonicalOwner !== params.owner || canonicalSlug !== params.slug)) {
throw redirect({
to: "/$owner/$slug/settings",
params: { owner: canonicalOwner, slug: canonicalSlug },
replace: true,
});
}
return {
owner: data?.owner ?? params.owner,
displayName: data?.displayName ?? null,
summary: data?.summary ?? null,
version: data?.version ?? null,
initialData: data.initialData,
};
},
head: ({ params, loaderData }) => {
const meta = buildSkillMeta({
slug: params.slug,
owner: loaderData?.owner ?? params.owner,
displayName: loaderData?.displayName,
summary: loaderData?.summary,
version: loaderData?.version ?? null,
});
return {
meta: [
{ title: `Settings · ${meta.title}` },
{
name: "description",
content: `Owner settings for ${loaderData?.displayName ?? params.slug}.`,
},
],
};
},
component: SkillSettingsRoute,
});
function SkillSettingsRoute() {
const { owner, slug } = Route.useParams();
const { initialData } = Route.useLoaderData();
return (
<SkillDetailPage slug={slug} canonicalOwner={owner} initialData={initialData} mode="settings" />
);
}
+1 -6
View File
@@ -149,12 +149,7 @@ export function CliAuth({ navigate = (url: string) => window.location.assign(url
</button>
</p>
) : null}
<SignInButton
variant="primary"
disabled={isLoading}
>
Sign in with GitHub
</SignInButton>
<SignInButton disabled={isLoading} />
</CardContent>
</Card>
</Container>
+293
View File
@@ -0,0 +1,293 @@
/* @vitest-environment jsdom */
import { render, screen } from "@testing-library/react";
import type React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Id } from "../../convex/_generated/dataModel";
import { TooltipProvider } from "../components/ui/tooltip";
import { Dashboard } from "./dashboard";
const mocks = vi.hoisted(() => ({
useQuery: vi.fn(),
useMutation: vi.fn(),
toastSuccess: vi.fn(),
toastError: vi.fn(),
}));
vi.mock("convex/react", () => ({
useQuery: (...args: unknown[]) => mocks.useQuery(...args),
useMutation: (...args: unknown[]) => mocks.useMutation(...args),
}));
vi.mock("sonner", () => ({
toast: {
success: (...args: unknown[]) => mocks.toastSuccess(...args),
error: (...args: unknown[]) => mocks.toastError(...args),
},
}));
vi.mock("@tanstack/react-router", () => ({
createFileRoute: () => (config: unknown) => config,
Link: ({
children,
...props
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & { children: React.ReactNode }) => (
<a href="/test" {...props}>
{children}
</a>
),
}));
type TestSkill = {
_id: Id<"skills">;
_creationTime: number;
slug: string;
displayName: string;
summary: string;
ownerUserId: Id<"users">;
ownerPublisherId: Id<"publishers">;
tags: {};
badges: {};
stats: {
downloads: number;
stars: number;
versions: number;
};
moderationVerdict?: "suspicious" | "malicious";
moderationFlags?: string[];
isSuspicious?: boolean;
createdAt: number;
updatedAt: number;
latestVersion: {
version: string;
createdAt: number;
vtStatus: string | null;
llmStatus: string | null;
staticScanStatus: "clean" | "suspicious" | "malicious" | null;
};
rescanState: {
maxRequests: number;
requestCount: number;
remainingRequests: number;
canRequest: boolean;
inProgressRequest: null | { _id: string; status: "in_progress"; targetVersion: string };
latestRequest: null | { _id: string; status: "completed" | "failed"; targetVersion: string };
};
};
type TestPackage = {
_id: Id<"packages">;
name: string;
displayName: string;
family: "code-plugin";
channel: "community";
isOfficial: false;
runtimeId: string | null;
sourceRepo: string | null;
summary: string;
latestVersion: string;
stats: {
downloads: number;
installs: number;
stars: number;
versions: number;
};
verification: null;
scanStatus: "suspicious" | "malicious";
latestRelease: {
version: string;
createdAt: number;
vtStatus: string | null;
llmStatus: string | null;
staticScanStatus: "clean" | "suspicious" | "malicious" | null;
};
rescanState: TestSkill["rescanState"];
};
const me = {
_id: "users:local" as Id<"users">,
handle: "local",
name: "Local Dev",
displayName: "Local Dev",
};
const publishers = [
{
publisher: {
_id: "publishers:local" as Id<"publishers">,
handle: "local",
displayName: "Local",
kind: "user" as const,
},
role: "owner" as const,
},
];
function createSkill(overrides?: Partial<TestSkill>): TestSkill {
return {
_id: "skills:below-cap" as Id<"skills">,
_creationTime: 1,
slug: "local-flagged-skill",
displayName: "Local Flagged Skill",
summary: "Flagged skill fixture.",
ownerUserId: me._id,
ownerPublisherId: publishers[0].publisher._id,
tags: {},
badges: {},
stats: { downloads: 0, stars: 0, versions: 1 },
moderationVerdict: "suspicious",
moderationFlags: ["flagged.suspicious"],
isSuspicious: true,
createdAt: 1,
updatedAt: 1,
latestVersion: {
version: "1.0.0",
createdAt: 1,
vtStatus: "suspicious",
llmStatus: "suspicious",
staticScanStatus: "suspicious",
},
rescanState: {
maxRequests: 3,
requestCount: 1,
remainingRequests: 2,
canRequest: true,
inProgressRequest: null,
latestRequest: null,
},
...overrides,
};
}
function createPackage(overrides?: Partial<TestPackage>): TestPackage {
return {
_id: "packages:at-cap" as Id<"packages">,
name: "local-flagged-runtime-plugin",
displayName: "Local Flagged Runtime Plugin",
family: "code-plugin",
channel: "community",
isOfficial: false,
runtimeId: null,
sourceRepo: null,
summary: "Flagged plugin fixture.",
latestVersion: "1.0.0",
stats: { downloads: 0, installs: 0, stars: 0, versions: 1 },
verification: null,
scanStatus: "malicious",
latestRelease: {
version: "1.0.0",
createdAt: 1,
vtStatus: "malicious",
llmStatus: "malicious",
staticScanStatus: "malicious",
},
rescanState: {
maxRequests: 3,
requestCount: 3,
remainingRequests: 0,
canRequest: false,
inProgressRequest: null,
latestRequest: null,
},
...overrides,
};
}
function arrangeDashboard({
skills = [],
packages = [],
}: {
skills?: TestSkill[];
packages?: TestPackage[];
}) {
let unscopedQueryCount = 0;
let scopedQueryCount = 0;
mocks.useQuery.mockImplementation((_fn: unknown, args: unknown) => {
if (args === "skip") return undefined;
if (args === undefined) {
unscopedQueryCount += 1;
return unscopedQueryCount % 2 === 1 ? me : publishers;
}
scopedQueryCount += 1;
return scopedQueryCount % 2 === 1 ? skills : packages;
});
}
function renderDashboard() {
return render(
<TooltipProvider>
<Dashboard />
</TooltipProvider>,
);
}
describe("Dashboard minimal rows", () => {
beforeEach(() => {
mocks.useQuery.mockReset();
mocks.useMutation.mockReset();
mocks.useMutation.mockReturnValue(vi.fn().mockResolvedValue({}));
mocks.toastSuccess.mockReset();
mocks.toastError.mockReset();
});
it("renders entry links, summaries, and aggregate statuses only", () => {
arrangeDashboard({ skills: [createSkill()], packages: [createPackage()] });
renderDashboard();
expect(screen.getByRole("link", { name: "Local Flagged Skill" })).toBeTruthy();
expect(screen.getByRole("link", { name: "Local Flagged Runtime Plugin" })).toBeTruthy();
expect(screen.getByText("Flagged skill fixture.")).toBeTruthy();
expect(screen.getByText("Flagged plugin fixture.")).toBeTruthy();
expect(screen.getByText("Suspicious")).toBeTruthy();
expect(screen.getByText("Blocked")).toBeTruthy();
expect(screen.getByRole("button", { name: "Suspicious status reason" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Blocked status reason" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Open actions for Local Flagged Skill" })).toBeTruthy();
expect(
screen.getByRole("button", { name: "Open actions for Local Flagged Runtime Plugin" }),
).toBeTruthy();
});
it("renders a skeleton while auth state is loading", () => {
mocks.useQuery.mockReturnValue(undefined);
renderDashboard();
expect(screen.queryByText("Sign in to access your dashboard.")).toBeNull();
expect(screen.queryByText("Dashboard")).toBeNull();
expect(document.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
});
it("does not render row-level actions", () => {
arrangeDashboard({ skills: [createSkill()], packages: [createPackage()] });
renderDashboard();
expect(screen.queryByRole("button", { name: /request rescan/i })).toBeNull();
expect(screen.queryByRole("link", { name: /new version/i })).toBeNull();
expect(screen.queryByRole("link", { name: /new release/i })).toBeNull();
expect(screen.queryByRole("link", { name: /^view$/i })).toBeNull();
});
it("does not render column titles, scanner details, or plugin metadata chips", () => {
arrangeDashboard({ skills: [createSkill()], packages: [createPackage()] });
renderDashboard();
expect(screen.queryByText("Skill")).toBeNull();
expect(screen.queryByText("Plugin")).toBeNull();
expect(screen.queryByText("Summary")).toBeNull();
expect(screen.queryByText("Status")).toBeNull();
expect(screen.queryByText(/^VT:/)).toBeNull();
expect(screen.queryByText(/^LLM:/)).toBeNull();
expect(screen.queryByText(/^ClawScan:/)).toBeNull();
expect(screen.queryByText(/^Static/)).toBeNull();
expect(screen.queryByText(/public surfaces warn or suppress it/i)).toBeNull();
expect(screen.queryByText(/automated security checks found malicious content/i)).toBeNull();
expect(screen.queryByText("Code Plugin")).toBeNull();
expect(screen.queryByText("community")).toBeNull();
expect(screen.queryByText("2/3 rescans left")).toBeNull();
expect(screen.queryByText("Limit reached (3/3)")).toBeNull();
});
});
+385 -290
View File
@@ -1,27 +1,22 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery } from "convex/react";
import {
AlertTriangle,
ArrowDownToLine,
CheckCircle2,
Clock,
GitBranch,
Package,
Plug,
ShieldCheck,
Star,
Upload,
} from "lucide-react";
import { useMutation, useQuery } from "convex/react";
import { Clock, Info, MoreVertical, Plus, RotateCw, Settings } from "lucide-react";
import { useEffect, useState } from "react";
import semver from "semver";
import { toast } from "sonner";
import { api } from "../../convex/_generated/api";
import type { Doc } from "../../convex/_generated/dataModel";
import { DashboardSkeleton } from "../components/skeletons/DashboardSkeleton";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import { formatCompactStat } from "../lib/numberFormat";
import { familyLabel } from "../lib/packageLabels";
import type { PublicSkill } from "../lib/publicUser";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "../components/ui/tooltip";
import { getUserFacingConvexError } from "../lib/convexError";
const emptyPluginPublishSearch = {
ownerHandle: undefined,
@@ -32,7 +27,41 @@ const emptyPluginPublishSearch = {
sourceRepo: undefined,
} as const;
type DashboardSkill = PublicSkill & { pendingReview?: boolean };
type DashboardSkill = Pick<
Doc<"skills">,
| "_id"
| "_creationTime"
| "slug"
| "displayName"
| "summary"
| "ownerUserId"
| "ownerPublisherId"
| "canonicalSkillId"
| "forkOf"
| "latestVersionId"
| "tags"
| "capabilityTags"
| "badges"
| "stats"
| "moderationStatus"
| "moderationReason"
| "moderationVerdict"
| "moderationFlags"
| "isSuspicious"
| "createdAt"
| "updatedAt"
> & {
pendingReview?: boolean;
qualityDecision?: "pass" | "quarantine" | "reject";
latestVersion: {
version: string;
createdAt: number;
vtStatus: string | null;
llmStatus: string | null;
staticScanStatus: "clean" | "suspicious" | "malicious" | null;
} | null;
rescanState?: DashboardRescanState | null;
};
type DashboardPackage = {
_id: string;
@@ -63,13 +92,35 @@ type DashboardPackage = {
llmStatus: string | null;
staticScanStatus: "clean" | "suspicious" | "malicious" | null;
} | null;
rescanState?: DashboardRescanState | null;
};
type DashboardRescanState = {
maxRequests: number;
requestCount: number;
remainingRequests: number;
canRequest: boolean;
inProgressRequest: DashboardRescanRequest | null;
latestRequest: DashboardRescanRequest | null;
};
type DashboardRescanRequest = {
_id: string;
targetKind: "skill" | "plugin";
targetVersion: string;
requestedByUserId: string;
status: "in_progress" | "completed" | "failed";
error?: string;
createdAt: number;
updatedAt: number;
completedAt?: number;
};
export const Route = createFileRoute("/dashboard")({
component: Dashboard,
});
function Dashboard() {
export function Dashboard() {
const me = useQuery(api.users.me) as Doc<"users"> | null | undefined;
const publishers = useQuery(api.publishers.listMine) as
| Array<{
@@ -107,13 +158,18 @@ function Dashboard() {
useEffect(() => {
if (selectedPublisherId) return;
const personal = publishers?.find((entry) => entry.publisher.kind === "user") ?? publishers?.[0];
const personal =
publishers?.find((entry) => entry.publisher.kind === "user") ?? publishers?.[0];
if (personal?.publisher._id) {
setSelectedPublisherId(personal.publisher._id);
}
}, [publishers, selectedPublisherId]);
if (!me) {
if (me === undefined) {
return <DashboardSkeleton />;
}
if (me === null) {
return (
<main className="section">
<Card>Sign in to access your dashboard.</Card>
@@ -136,7 +192,8 @@ function Dashboard() {
Welcome to ClawHub
</h1>
<p className="empty-state-body">
You're signed in as @{ownerHandle}. Get started by publishing your first skill or plugin.
You're signed in as @{ownerHandle}. Get started by publishing your first skill or
plugin.
</p>
<div className="flex gap-3 justify-center">
<Button asChild variant="primary">
@@ -170,262 +227,193 @@ function Dashboard() {
<main className="section">
<div className="dashboard-header">
<div>
<h1 className="section-title m-0">
Publisher Dashboard
</h1>
<h1 className="section-title m-0">Dashboard</h1>
<p className="section-subtitle m-0">
Manage your published skills and plugins.
View your published skills and plugins.
</p>
</div>
<div className="flex gap-2 flex-wrap">
{publishers && publishers.length > 0 ? (
<select
className="input"
value={selectedPublisherId}
onChange={(event) => setSelectedPublisherId(event.target.value)}
>
{publishers.map((entry) => (
<option key={entry.publisher._id} value={entry.publisher._id}>
@{entry.publisher.handle} · {entry.role}
</option>
))}
</select>
) : null}
<Button asChild variant="primary">
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
<Upload className="h-4 w-4" aria-hidden="true" />
Publish Skill
</Link>
</Button>
<Button asChild>
<Link
to="/publish-plugin"
search={{ ...emptyPluginPublishSearch, ownerHandle }}
>
<Plug className="h-4 w-4" aria-hidden="true" />
Publish Plugin
</Link>
</Button>
</div>
</div>
<Card className="dashboard-owner-panel">
<div className="dashboard-owner-grid">
<div className="dashboard-owner-grid">
<Card className="dashboard-owner-panel">
<section className="dashboard-collection-block">
<div className="dashboard-section-header">
<div>
<h2 className="dashboard-collection-title">Publisher Skills</h2>
<p className="section-subtitle mt-1.5 mb-0 mx-0">
Hidden skill versions remain visible here while checks are pending.
</p>
</div>
<h2 className="dashboard-collection-title">Skills</h2>
<Button asChild size="sm" className="dashboard-section-action">
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
<Plus className="h-4 w-4" aria-hidden="true" />
New Skill
</Link>
</Button>
</div>
{skills.length === 0 ? (
<div className="dashboard-inline-empty">
<div className="dashboard-inline-empty-copy">
<strong>No skills yet.</strong> Publish your first skill to share it with the community.
<strong>No skills yet.</strong> Publish your first skill to share it with the
community.
</div>
<Button asChild variant="primary">
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
<Upload className="h-4 w-4" aria-hidden="true" />
Publish Skill
</Link>
</Button>
</div>
) : (
<div className="dashboard-list">
<div className="dashboard-list-header">
<span>Skill</span>
<span>Summary</span>
<span>Status</span>
<span>Actions</span>
</div>
{skills.map((skill) => (
<SkillRow key={skill._id} skill={skill} ownerHandle={ownerHandle} />
))}
</div>
)}
</section>
</Card>
<Card className="dashboard-owner-panel">
<section className="dashboard-collection-block">
<div className="dashboard-section-header">
<div>
<h2 className="dashboard-collection-title">Publisher Plugins</h2>
<p className="section-subtitle mt-1.5 mb-0 mx-0">
Owner-only package view with VirusTotal, static scan, and verification state.
</p>
</div>
<h2 className="dashboard-collection-title">Plugins</h2>
<Button asChild size="sm" className="dashboard-section-action">
<Link to="/publish-plugin" search={{ ...emptyPluginPublishSearch, ownerHandle }}>
<Plus className="h-4 w-4" aria-hidden="true" />
New Plugin
</Link>
</Button>
</div>
{packages.length === 0 ? (
<div className="dashboard-inline-empty">
<div className="dashboard-inline-empty-copy">
<strong>No plugins yet.</strong> Publish your first plugin release to validate and distribute it.
<strong>No plugins yet.</strong> Publish your first plugin release to validate and
distribute it.
</div>
<Button asChild variant="primary">
<Link
to="/publish-plugin"
search={{ ...emptyPluginPublishSearch, ownerHandle }}
>
<Plug className="h-4 w-4" aria-hidden="true" />
Publish Plugin
</Link>
</Button>
</div>
) : (
<div className="dashboard-list">
<div className="dashboard-list-header">
<span>Plugin</span>
<span>Summary</span>
<span>Status</span>
<span>Actions</span>
</div>
{packages.map((pkg) => (
<PackageRow key={pkg._id} pkg={pkg} ownerHandle={ownerHandle} />
))}
</div>
)}
</section>
</div>
</Card>
</Card>
</div>
</main>
);
}
function SkillRow({ skill, ownerHandle }: { skill: DashboardSkill; ownerHandle: string | null }) {
const status = skillDashboardStatus(skill);
const detailParams = { owner: ownerHandle ?? "unknown", slug: skill.slug };
const settingsHref = `/${encodeURIComponent(detailParams.owner)}/${encodeURIComponent(
skill.slug,
)}/settings`;
return (
<div className="dashboard-list-row">
<div className="dashboard-list-primary">
<div className="dashboard-list-title">
<Link
to="/$owner/$slug"
params={{ owner: ownerHandle ?? "unknown", slug: skill.slug }}
params={detailParams}
className="dashboard-skill-name"
>
{skill.displayName}
</Link>
<span className="dashboard-list-id">/{skill.slug}</span>
{skill.pendingReview ? (
<Badge variant="pending">
<Clock className="h-3 w-3" aria-hidden="true" />
Pending checks
</Badge>
) : null}
</div>
<div className="dashboard-inline-metrics">
<span>
<ArrowDownToLine size={13} aria-hidden="true" /> {formatCompactStat(skill.stats.downloads)}
</span>
<span>
<Star size={13} aria-hidden="true" /> {formatCompactStat(skill.stats.stars)}
</span>
<span>
<Package size={13} aria-hidden="true" /> {skill.stats.versions}
</span>
</div>
</div>
<div className="dashboard-list-summary">{skill.summary ?? "No summary provided."}</div>
<div className="dashboard-list-status">
{skill.pendingReview ? (
<>
<span className="dashboard-inline-status-item">
<ShieldCheck size={13} aria-hidden="true" />
VT pending
</span>
<span className="dashboard-inline-status-note">
Hidden until verification checks finish.
</span>
</>
) : (
<span className="dashboard-inline-status-note">Visible</span>
)}
</div>
<div className="dashboard-row-actions">
<Button asChild size="sm">
<Link to="/publish-skill" search={{ updateSlug: skill.slug }}>
<Upload className="h-3 w-3" aria-hidden="true" />
New Version
</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link
to="/$owner/$slug"
params={{ owner: ownerHandle ?? "unknown", slug: skill.slug }}
>
View
</Link>
</Button>
<StatusChipWithTooltip status={status} />
</div>
<RowMenu
kind="skill"
targetId={skill._id}
targetLabel={skill.displayName}
settingsHref={settingsHref}
statusLabel={status.label}
rescanState={skill.rescanState ?? null}
/>
</div>
);
}
function scanStatusLabel(status: string | null | undefined) {
switch (status) {
case "pending":
return "Pending scan";
case "clean":
return "Scan clean";
case "suspicious":
return "Suspicious";
case "malicious":
return "Blocked";
case "not-run":
return "Scan not run";
default:
return null;
}
}
function releaseStatusLabel(
label: string,
status: string | null | undefined,
emptyLabel = "not started",
) {
return `${label}: ${status?.trim() ? status : emptyLabel}`;
}
function PackageStatusTag({
label,
tone,
function StatusChipWithTooltip({
status,
}: {
label: string;
tone: "default" | "pending" | "warning" | "danger" | "success";
status: {
key?: string;
label: string;
description: string;
variant: "default" | "pending" | "warning" | "destructive" | "success";
};
}) {
const variant =
tone === "pending"
? "pending"
: tone === "warning"
? "warning"
: tone === "danger"
? "destructive"
: tone === "success"
? "success"
: "default";
return <Badge variant={variant}>{label}</Badge>;
const showInfo = status.label !== "Visible";
return (
<Badge variant={status.variant} className="dashboard-status-chip">
{status.key === "pending" ? <Clock className="h-3 w-3" aria-hidden="true" /> : null}
{status.label}
{showInfo ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="dashboard-status-info"
aria-label={`${status.label} status reason`}
>
<Info className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent side="top" align="end">
{status.description}
</TooltipContent>
</Tooltip>
) : null}
</Badge>
);
}
function PackageRow({ pkg, ownerHandle }: { pkg: DashboardPackage; ownerHandle: string }) {
const scanLabel = scanStatusLabel(pkg.scanStatus);
const nextVersion = pkg.latestVersion ? semver.inc(pkg.latestVersion, "patch") : null;
const sourceLabel = pkg.sourceRepo?.replace(/^https?:\/\/github\.com\//, "").replace(/\.git$/, "");
const scanTone =
pkg.scanStatus === "pending"
? "pending"
: pkg.scanStatus === "suspicious"
? "warning"
: pkg.scanStatus === "malicious"
? "danger"
: pkg.scanStatus === "clean"
? "success"
: "default";
const staticTone =
pkg.latestRelease?.staticScanStatus === "suspicious"
? "warning"
: pkg.latestRelease?.staticScanStatus === "malicious"
? "danger"
: pkg.latestRelease?.staticScanStatus === "clean"
? "success"
: "default";
function packageDashboardStatus(pkg: DashboardPackage): {
label: string;
description: string;
variant: "default" | "pending" | "warning" | "destructive" | "success";
} {
const releaseStatuses = new Set([
pkg.latestRelease?.vtStatus,
pkg.latestRelease?.llmStatus,
pkg.latestRelease?.staticScanStatus,
]);
if (pkg.scanStatus === "malicious" || releaseStatuses.has("malicious")) {
return {
label: "Blocked",
description: "Security checks found malicious content.",
variant: "destructive",
};
}
if (pkg.scanStatus === "suspicious" || releaseStatuses.has("suspicious")) {
return {
label: "Suspicious",
description: "Security checks flagged this plugin for review.",
variant: "warning",
};
}
if (pkg.scanStatus === "pending" || pkg.pendingReview) {
return {
label: "Pending checks",
description: "Security verification is still running.",
variant: "pending",
};
}
if (pkg.scanStatus === "clean") {
return {
label: "Visible",
description: "Available on public catalog surfaces.",
variant: "success",
};
}
return {
label: "Unknown",
description: "Open the plugin for the latest release and security details.",
variant: "default",
};
}
function PackageRow({ pkg }: { pkg: DashboardPackage; ownerHandle: string }) {
const status = packageDashboardStatus(pkg);
return (
<div className="dashboard-list-row">
@@ -434,89 +422,196 @@ function PackageRow({ pkg, ownerHandle }: { pkg: DashboardPackage; ownerHandle:
<Link to="/plugins/$name" params={{ name: pkg.name }} className="dashboard-skill-name">
{pkg.displayName}
</Link>
<span className="dashboard-list-id">{pkg.name}</span>
</div>
<div className="dashboard-inline-tags">
<PackageStatusTag label={familyLabel(pkg.family)} tone="default" />
<PackageStatusTag label={pkg.channel} tone="default" />
{scanLabel ? <PackageStatusTag label={scanLabel} tone={scanTone} /> : null}
{pkg.verification?.tier ? (
<PackageStatusTag label={pkg.verification.tier} tone="default" />
) : null}
{pkg.latestRelease?.staticScanStatus ? (
<PackageStatusTag
label={`Static ${pkg.latestRelease.staticScanStatus}`}
tone={staticTone}
/>
) : null}
</div>
<div className="dashboard-inline-metrics">
<span>
<ArrowDownToLine size={13} aria-hidden="true" /> {formatCompactStat(pkg.stats.downloads)}
</span>
<span>
<Star size={13} aria-hidden="true" /> {formatCompactStat(pkg.stats.stars)}
</span>
<span>
<Package size={13} aria-hidden="true" /> {pkg.stats.versions}
</span>
<span>
<GitBranch size={13} aria-hidden="true" /> {pkg.latestVersion ?? "No tag"}
</span>
{pkg.runtimeId ? (
<span>
<Plug size={13} aria-hidden="true" /> {pkg.runtimeId}
</span>
) : null}
{sourceLabel ? (
<span>
<ShieldCheck size={13} aria-hidden="true" /> {sourceLabel}
</span>
) : null}
</div>
</div>
<div className="dashboard-list-summary">{pkg.summary ?? "No summary provided."}</div>
<div className="dashboard-list-status">
<span className="dashboard-inline-status-item">
<ShieldCheck size={13} aria-hidden="true" />{" "}
{releaseStatusLabel(
"VT",
pkg.latestRelease?.vtStatus,
pkg.scanStatus === "pending" ? "pending" : "unknown",
)}
</span>
<span className="dashboard-inline-status-item">
<CheckCircle2 size={13} aria-hidden="true" />{" "}
{releaseStatusLabel("LLM", pkg.latestRelease?.llmStatus)}
</span>
<span className="dashboard-inline-status-item">
<AlertTriangle size={13} aria-hidden="true" />{" "}
{releaseStatusLabel("Static", pkg.latestRelease?.staticScanStatus)}
</span>
</div>
<div className="dashboard-row-actions">
<Button asChild size="sm">
<Link
to="/publish-plugin"
search={{
ownerHandle,
name: pkg.name,
displayName: pkg.displayName,
family: pkg.family === "bundle-plugin" ? "bundle-plugin" : "code-plugin",
nextVersion: nextVersion ?? undefined,
sourceRepo: pkg.sourceRepo ?? undefined,
}}
>
<Upload className="h-3 w-3" aria-hidden="true" />
New Release
</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link to="/plugins/$name" params={{ name: pkg.name }}>
View
</Link>
</Button>
<StatusChipWithTooltip status={status} />
</div>
<RowMenu
kind="plugin"
targetId={pkg._id}
targetLabel={pkg.displayName}
settingsHref={`/plugins/${encodeURIComponent(pkg.name)}`}
statusLabel={status.label}
rescanState={pkg.rescanState ?? null}
/>
</div>
);
}
function canShowDashboardRescan(statusLabel: string, state: DashboardRescanState | null) {
if (statusLabel === "Visible") return false;
if (!state) return true;
return state.canRequest && !state.inProgressRequest && state.remainingRequests > 0;
}
function RowMenu({
kind,
targetId,
targetLabel,
settingsHref,
statusLabel,
rescanState,
}: {
kind: "skill" | "plugin";
targetId: string;
targetLabel: string;
settingsHref: string;
statusLabel: string;
rescanState: DashboardRescanState | null;
}) {
const requestSkillRescan = useMutation(api.skills.requestRescan);
const requestPluginRescan = useMutation(api.packages.requestRescan);
const [isRequesting, setIsRequesting] = useState(false);
const isScanInProgress = Boolean(rescanState?.inProgressRequest);
const showRescan = canShowDashboardRescan(statusLabel, rescanState);
const showRescanItem = showRescan || isScanInProgress;
const rescanLabel = isScanInProgress
? "Scan in progress"
: isRequesting
? "Requesting..."
: "Request rescan";
async function requestRescan() {
if (!showRescan || isRequesting) return;
setIsRequesting(true);
try {
if (kind === "skill") {
await requestSkillRescan({ skillId: targetId as Doc<"skills">["_id"] });
} else {
await requestPluginRescan({ packageId: targetId as Doc<"packages">["_id"] });
}
toast.success(`Rescan requested for ${targetLabel}.`);
} catch (error) {
toast.error(getUserFacingConvexError(error, "Could not request a rescan."));
} finally {
setIsRequesting(false);
}
}
return (
<div className="dashboard-row-menu">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Open actions for ${targetLabel}`}
>
<MoreVertical className="h-4 w-4" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="dashboard-row-menu-content">
<DropdownMenuItem asChild>
<a href={settingsHref}>
<Settings className="h-4 w-4" aria-hidden="true" />
Settings
</a>
</DropdownMenuItem>
{showRescanItem ? (
<DropdownMenuItem
disabled={isRequesting || isScanInProgress}
onSelect={() => void requestRescan()}
>
<RotateCw
className={
isRequesting || isScanInProgress
? "h-4 w-4 animate-spin [animation-duration:2.4s]"
: "h-4 w-4"
}
aria-hidden="true"
/>
{rescanLabel}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
function skillDashboardStatus(skill: DashboardSkill): {
key: "visible" | "pending" | "suspicious" | "blocked" | "hidden" | "removed" | "quality";
label: string;
description: string;
variant: "default" | "pending" | "warning" | "destructive" | "success";
} {
const flags = skill.moderationFlags ?? [];
const reason = skill.moderationReason ?? "";
const versionStatuses = new Set([
skill.latestVersion?.vtStatus,
skill.latestVersion?.llmStatus,
skill.latestVersion?.staticScanStatus,
]);
if (skill.moderationStatus === "removed") {
return {
key: "removed",
label: "Removed",
description: "Removed from public inventory by moderation.",
variant: "destructive",
};
}
if (
flags.includes("blocked.malware") ||
skill.moderationVerdict === "malicious" ||
versionStatuses.has("malicious")
) {
return {
key: "blocked",
label: "Blocked",
description:
"Unavailable publicly because automated security checks found malicious content.",
variant: "destructive",
};
}
if (skill.pendingReview || reason === "pending.scan" || reason === "pending.scan.stale") {
return {
key: "pending",
label: "Pending checks",
description: "Hidden until security verification checks finish.",
variant: "pending",
};
}
if (
skill.qualityDecision === "quarantine" ||
skill.qualityDecision === "reject" ||
reason === "quality.low"
) {
return {
key: "quality",
label: "Quality held",
description: "Unavailable while quality review is holding this release.",
variant: "warning",
};
}
if (
skill.isSuspicious ||
flags.includes("flagged.suspicious") ||
skill.moderationVerdict === "suspicious" ||
versionStatuses.has("suspicious")
) {
return {
key: "suspicious",
label: "Suspicious",
description:
"Visible to you, but public surfaces warn or suppress it because it was flagged.",
variant: "warning",
};
}
if (skill.moderationStatus === "hidden") {
return {
key: "hidden",
label: "Hidden",
description: "Hidden from public catalog surfaces.",
variant: "warning",
};
}
return {
key: "visible",
label: "Visible",
description: "Available on public catalog surfaces.",
variant: "success",
};
}
+1 -1
View File
@@ -227,7 +227,7 @@ export function ImportGitHub() {
description="You need to be signed in to import skills from GitHub."
>
{!isLoading ? (
<SignInButton variant="outline">Sign in with GitHub</SignInButton>
<SignInButton />
) : null}
</EmptyState>
</Container>
+343 -331
View File
@@ -1,13 +1,20 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { AlertTriangle, ExternalLink, Copy, Check, Download } from "lucide-react";
import { useState } from "react";
import { createFileRoute, Link, Outlet, useRouterState } from "@tanstack/react-router";
import { useMutation, useQuery } from "convex/react";
import { AlertTriangle, ExternalLink, Download } from "lucide-react";
import type { ComponentProps } from "react";
import { toast } from "sonner";
import { api } from "../../../convex/_generated/api";
import type { Id } from "../../../convex/_generated/dataModel";
import { DetailHero, DetailPageShell } from "../../components/DetailPageShell";
import { DetailSecuritySummary } from "../../components/DetailSecuritySummary";
import { EmptyState } from "../../components/EmptyState";
import { InstallCopyButton } from "../../components/InstallCopyButton";
import { Container } from "../../components/layout/Container";
import { MarkdownPreview } from "../../components/MarkdownPreview";
import { SecurityScanResults } from "../../components/SkillSecurityScanResults";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card";
import { getUserFacingConvexError } from "../../lib/convexError";
import { formatRetryDelay } from "../../lib/formatRetryDelay";
import {
fetchPackageDetail,
@@ -19,13 +26,12 @@ import {
type PackageVersionDetail,
} from "../../lib/packageApi";
import { familyLabel } from "../../lib/packageLabels";
import { useAuthStatus } from "../../lib/useAuthStatus";
type PluginDetailRateLimitState =
| {
scope: "detail" | "metadata";
retryAfterSeconds: number | null;
}
| null;
type PluginDetailRateLimitState = {
scope: "detail" | "metadata";
retryAfterSeconds: number | null;
} | null;
type PluginDetailLoaderData = {
detail: PackageDetailResponse;
@@ -143,63 +149,6 @@ function VerifiedBadge() {
);
}
function fallbackCopy(text: string): boolean {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
try {
const ok = document.execCommand("copy");
return ok;
} catch {
return false;
} finally {
document.body.removeChild(textarea);
}
}
function CopyButton({ text }: { text: string }) {
const [state, setState] = useState<"idle" | "copied" | "failed">("idle");
return (
<Button
variant="outline"
size="sm"
className="w-full shrink-0 sm:w-auto"
onClick={() => {
if (navigator.clipboard?.writeText) {
void navigator.clipboard
.writeText(text)
.then(() => {
setState("copied");
setTimeout(() => setState("idle"), 2000);
})
.catch(() => {
if (fallbackCopy(text)) {
setState("copied");
setTimeout(() => setState("idle"), 2000);
} else {
setState("failed");
setTimeout(() => setState("idle"), 2000);
}
});
} else if (fallbackCopy(text)) {
setState("copied");
setTimeout(() => setState("idle"), 2000);
} else {
setState("failed");
setTimeout(() => setState("idle"), 2000);
}
}}
aria-label="Copy to clipboard"
>
{state === "copied" ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{state === "copied" ? "Copied" : state === "failed" ? "Failed" : "Copy"}
</Button>
);
}
const CAPABILITY_LABELS: Record<string, string> = {
executesCode: "Executes code",
runtimeId: "Runtime ID",
@@ -234,6 +183,17 @@ function isEmptyObject(obj: unknown): boolean {
function PluginDetailRoute() {
const { name } = Route.useParams();
const { detail, version, readme, rateLimited } = Route.useLoaderData() as PluginDetailLoaderData;
const pathname = useRouterState({ select: (state) => state.location.pathname });
const { isAuthenticated } = useAuthStatus();
const requestPluginRescan = useMutation(api.packages.requestRescan);
const rescanState = useQuery(
api.packages.getOwnerRescanStateByName,
isAuthenticated && detail.package ? { name: detail.package.name } : "skip",
) as ComponentProps<typeof DetailSecuritySummary>["rescanState"] | undefined;
if (pathname.includes("/security/")) {
return <Outlet />;
}
if (rateLimited?.scope === "detail") {
return (
@@ -271,6 +231,11 @@ function PluginDetailRoute() {
const pkg = detail.package;
const owner = detail.owner;
const latestRelease = version?.version ?? null;
const isDownloadBlocked =
pkg.verification?.scanStatus === "malicious" ||
latestRelease?.verification?.scanStatus === "malicious" ||
latestRelease?.vtAnalysis?.status === "malicious" ||
latestRelease?.vtAnalysis?.verdict === "malicious";
const installSnippet =
pkg.family === "code-plugin"
? `openclaw plugins install clawhub:${pkg.name}`
@@ -281,6 +246,26 @@ function PluginDetailRoute() {
const capabilities = latestRelease?.capabilities ?? pkg.capabilities;
const compatibility = latestRelease?.compatibility ?? pkg.compatibility;
const verification = latestRelease?.verification ?? pkg.verification;
const requestRescan = async () => {
const packageId = (pkg as { _id?: Id<"packages"> })._id;
if (!packageId) {
toast.error("Could not request a rescan for this plugin.");
return;
}
try {
await requestPluginRescan({ packageId });
toast.success("Rescan requested.", {
action: {
label: "Dashboard",
onClick: () => {
window.location.href = "/dashboard";
},
},
});
} catch (error) {
toast.error(getUserFacingConvexError(error, "Could not request a rescan."));
}
};
const capEntries = capabilities
? Object.entries(capabilities).filter(
@@ -294,12 +279,61 @@ function PluginDetailRoute() {
: [];
return (
<main className="section">
<div className="flex min-w-0 flex-col gap-5">
{/* Header card */}
<Card>
<CardContent>
<div className="flex flex-wrap gap-1.5 mb-2">
<main className="section detail-page-section">
<DetailPageShell>
<DetailHero
main={
<div className="skill-hero-title">
<div className="skill-hero-title-row">
<h1 className="skill-page-title">{pkg.displayName}</h1>
{pkg.latestVersion ? (
<span className="plugin-version-badge">v{pkg.latestVersion}</span>
) : null}
{pkg.latestVersion && !isDownloadBlocked ? (
<div className="skill-title-actions">
<Button asChild variant="outline" size="sm" className="no-underline">
<a href={getPackageDownloadPath(name, pkg.latestVersion)}>
<Download className="h-3.5 w-3.5" aria-hidden="true" />
Download
</a>
</Button>
</div>
) : null}
{isDownloadBlocked ? (
<div className="skill-title-actions">
<Badge variant="destructive">Download blocked</Badge>
</div>
) : null}
</div>
<p className="section-subtitle">{pkg.summary ?? "No summary provided."}</p>
<div className="skill-hero-inline-meta">
<div className="skill-hero-stats-row">
<span className="stat font-mono text-xs">{pkg.name}</span>
{pkg.runtimeId ? (
<>
<span className="text-ink-soft opacity-40">·</span>
<span className="stat">
runtime <span className="font-mono text-xs">{pkg.runtimeId}</span>
</span>
</>
) : null}
{owner?.handle ? (
<>
<span className="text-ink-soft opacity-40">·</span>
<Link
to="/u/$handle"
params={{ handle: owner.handle }}
className="text-[color:var(--accent)] hover:underline"
>
by @{owner.handle}
</Link>
</>
) : null}
</div>
</div>
<div className="skill-hero-badges">
<Badge>{familyLabel(pkg.family)}</Badge>
{verification?.tier ? (
<Badge variant="compact">{verification.tier.replace(/-/g, " ")}</Badge>
@@ -312,273 +346,251 @@ function PluginDetailRoute() {
<VerifiedBadge />
</Badge>
) : null}
</div>
<h1 className="mb-1 break-words font-display text-2xl font-bold text-[color:var(--ink)]">
{pkg.displayName}
{pkg.latestVersion ? (
<span className="ml-2 inline-block rounded-[var(--radius-pill)] bg-[color:var(--surface-muted)] px-2 py-0.5 text-xs font-semibold text-[color:var(--ink-soft)]">
v{pkg.latestVersion}
</span>
) : null}
</h1>
<p className="mb-2 break-words text-sm text-[color:var(--ink-soft)]">
{pkg.summary ?? "No summary provided."}
</p>
<div className="flex min-w-0 flex-wrap items-center gap-2 text-sm text-[color:var(--ink-soft)]">
<span className="break-all font-mono text-xs">{pkg.name}</span>
{pkg.runtimeId ? (
<>
<span className="opacity-40">&middot;</span>
<span>
runtime <span className="break-all font-mono text-xs">{pkg.runtimeId}</span>
</span>
</>
) : null}
{owner?.handle ? (
<>
<span className="opacity-40">&middot;</span>
<Link
to="/u/$handle"
params={{ handle: owner.handle }}
className="text-[color:var(--accent)] hover:underline"
>
by @{owner.handle}
</Link>
</>
{pkg.family === "code-plugin" && !pkg.isOfficial ? (
<Badge variant="accent">
Community code plugin. Review compatibility and verification before install.
</Badge>
) : null}
</div>
{pkg.family === "code-plugin" && !pkg.isOfficial ? (
<Badge variant="accent" className="mt-3 self-start">
Community code plugin. Review compatibility and verification before install.
</Badge>
) : null}
{/* Install */}
<div className="mt-4">
<div className="flex flex-col gap-3 rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] p-3 sm:flex-row sm:items-center sm:gap-2">
<pre className="plugin-detail-code-block min-w-0 flex-1 font-mono text-xs text-[color:var(--ink)]">
<code>{installSnippet}</code>
</pre>
<CopyButton text={installSnippet} />
</div>
</div>
{/* Latest Release */}
{pkg.latestVersion ? (
<div className="mt-3 flex flex-col gap-3 rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] px-3 py-3 sm:flex-row sm:items-center sm:justify-between sm:py-2">
<span className="text-sm">
Latest release: <strong>v{pkg.latestVersion}</strong>
</span>
<Button asChild variant="outline" size="sm" className="w-full no-underline sm:w-auto">
<a href={getPackageDownloadPath(name, pkg.latestVersion)}>
<Download className="h-3.5 w-3.5" aria-hidden="true" />
Download zip
</a>
</Button>
</div>
) : null}
</CardContent>
</Card>
{/* Capabilities */}
{capEntries.length > 0 ? (
<Card>
<CardHeader className="gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardTitle>Capabilities</CardTitle>
<CopyButton text={JSON.stringify(capabilities, null, 2)} />
</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>
<CopyButton text={JSON.stringify(compatibility, null, 2)} />
</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}
{/* Security Scan */}
{latestRelease ? (
<Card>
<CardContent>
<SecurityScanResults
sha256hash={latestRelease.sha256hash ?? undefined}
vtAnalysis={latestRelease.vtAnalysis ?? undefined}
llmAnalysis={latestRelease.llmAnalysis ?? undefined}
staticFindings={latestRelease.staticScan?.findings ?? []}
</div>
}
sidebar={
<>
{latestRelease ? (
<DetailSecuritySummary
scannerBasePath={`/plugins/${encodeURIComponent(name)}/security`}
sha256hash={latestRelease.sha256hash ?? null}
vtAnalysis={latestRelease.vtAnalysis ?? null}
llmAnalysis={latestRelease.llmAnalysis ?? null}
staticScan={latestRelease.staticScan ?? null}
rescanState={rescanState ?? null}
onRequestRescan={rescanState ? requestRescan : null}
/>
</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}
{/* Readme */}
) : null}
<Card className="skill-install-command-card">
<CardHeader>
<CardTitle>Install</CardTitle>
</CardHeader>
<CardContent>
<div className="skill-install-command-wrap">
<pre className="skill-install-command">
<code>{installSnippet}</code>
</pre>
<InstallCopyButton
text={installSnippet}
ariaLabel="Copy plugin install command"
showLabel={false}
className="skill-install-command-inline-button"
/>
</div>
</CardContent>
</Card>
</>
}
>
{readme ? (
<Card>
<Card className="tab-card">
<CardHeader>
<CardTitle>README</CardTitle>
</CardHeader>
<CardContent>
<MarkdownPreview>{readme}</MarkdownPreview>
</CardContent>
</Card>
) : null}
</div>
{/* 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}
{/* 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}
{/* 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>
);
}
@@ -0,0 +1,140 @@
import { createFileRoute, notFound } from "@tanstack/react-router";
import {
SecurityScannerPage,
type ScannerSlug,
} from "../../../../components/SecurityScannerPage";
import {
fetchPackageDetail,
fetchPackageVersion,
isRateLimitedPackageApiError,
type PackageDetailResponse,
type PackageVersionDetail,
} from "../../../../lib/packageApi";
const SCANNERS = new Set<ScannerSlug>(["virustotal", "openclaw", "static-analysis"]);
type PluginSecurityLoaderData = {
detail: PackageDetailResponse;
version: PackageVersionDetail | null;
resolvedName: string;
rateLimited: boolean;
};
function parseScanner(scanner: string): ScannerSlug {
if (SCANNERS.has(scanner as ScannerSlug)) return scanner as ScannerSlug;
throw notFound();
}
export const Route = createFileRoute("/plugins/$name/security/$scanner")({
beforeLoad: ({ params }) => {
parseScanner(params.scanner);
},
loader: async ({ params }): Promise<PluginSecurityLoaderData> => {
const requestedName = params.name;
const candidateNames = requestedName.includes("/")
? [requestedName]
: [requestedName, `@openclaw/${requestedName}`];
let resolvedName = requestedName;
let detail: PackageDetailResponse = { package: null, owner: null };
for (const candidateName of candidateNames) {
try {
const candidateDetail = await fetchPackageDetail(candidateName);
if (candidateDetail.package) {
detail = candidateDetail;
resolvedName = candidateName;
break;
}
detail = candidateDetail;
} catch (error) {
if (isRateLimitedPackageApiError(error)) {
return { detail, version: null, resolvedName, rateLimited: true };
}
throw error;
}
}
if (!detail.package?.latestVersion) {
return { detail, version: null, resolvedName, rateLimited: false };
}
try {
const version = await fetchPackageVersion(resolvedName, detail.package.latestVersion);
return { detail, version, resolvedName, rateLimited: false };
} catch (error) {
if (isRateLimitedPackageApiError(error)) {
return { detail, version: null, resolvedName, rateLimited: true };
}
throw error;
}
},
head: ({ params, loaderData }) => {
const scanner = parseScanner(params.scanner);
const scannerLabel =
scanner === "virustotal"
? "VirusTotal"
: scanner === "openclaw"
? "ClawScan"
: "Static analysis";
return {
meta: [
{
title: `${scannerLabel} security · ${
loaderData?.detail.package?.displayName ?? params.name
}`,
},
{
name: "description",
content: `${scannerLabel} security details for ${
loaderData?.detail.package?.displayName ?? params.name
}.`,
},
],
};
},
component: PluginSecurityScannerRoute,
});
function PluginSecurityScannerRoute() {
const { name, scanner } = Route.useParams();
const { detail, version, resolvedName, rateLimited } = Route.useLoaderData();
const pkg = detail.package;
const release = version?.version ?? null;
if (rateLimited) {
return (
<main className="section">
<div className="card">Plugin security details are temporarily unavailable.</div>
</main>
);
}
if (!pkg || !release) {
return (
<main className="section">
<div className="card">Security details are unavailable for this plugin.</div>
</main>
);
}
return (
<SecurityScannerPage
scanner={parseScanner(scanner)}
entity={{
kind: "plugin",
title: pkg.displayName,
name: resolvedName,
version: release.version,
owner: detail.owner ?? null,
ownerUserId: null,
ownerPublisherId: null,
detailPath: `/plugins/${encodeURIComponent(name)}`,
}}
sha256hash={release.sha256hash ?? null}
vtAnalysis={release.vtAnalysis ?? null}
llmAnalysis={release.llmAnalysis ?? null}
staticScan={release.staticScan ?? null}
/>
);
}
+1 -1
View File
@@ -349,7 +349,7 @@ export function Upload() {
title={`Sign in to publish a ${contentLabel}`}
description="You need to be signed in to publish skills on ClawHub."
>
<SignInButton variant="outline">Sign in with GitHub</SignInButton>
<SignInButton />
</EmptyState>
</Container>
</main>
+1 -1
View File
@@ -165,7 +165,7 @@ export function Settings() {
<Card>
<CardContent className="flex flex-col items-start gap-3">
<span>Sign in to access settings.</span>
<SignInButton variant="outline">Sign in with GitHub</SignInButton>
<SignInButton />
</CardContent>
</Card>
</Container>
+1 -1
View File
@@ -32,7 +32,7 @@ function Stars() {
title="Sign in to see your highlights"
description="Star skills for quick access later."
>
<SignInButton variant="outline">Sign in with GitHub</SignInButton>
<SignInButton />
</EmptyState>
</div>
</main>
+729 -423
View File
File diff suppressed because it is too large Load Diff