mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
Merge pull request #1861 from openclaw/pe/rescan
feat: add owner rescan security surfaces
This commit is contained in:
Vendored
+8
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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, {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() },
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)`;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Vendored
+1
@@ -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<{
|
||||
|
||||
Vendored
+1
@@ -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({
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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">·</span>
|
||||
<span>
|
||||
runtime <span className="break-all font-mono text-xs">{pkg.runtimeId}</span>
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
{owner?.handle ? (
|
||||
<>
|
||||
<span className="opacity-40">·</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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user