mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-15 09:22:08 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e3858a31d | ||
|
|
fe4acc8e10 | ||
|
|
f2e6e87756 | ||
|
|
3de69919de | ||
|
|
7629433248 | ||
|
|
b82ad43eae | ||
|
|
b7ee5c8e62 | ||
|
|
1c8543ade6 | ||
|
|
7cd0ef362d | ||
|
|
9cf9e12bdd | ||
|
|
112e25e28c | ||
|
|
d786374b77 | ||
|
|
3fbe27560e | ||
|
|
33539b6df3 | ||
|
|
354f12a033 | ||
|
|
b0ea80df6c |
@@ -16,5 +16,11 @@ AUTH_GITHUB_SECRET=
|
||||
JWT_PRIVATE_KEY=
|
||||
JWKS=
|
||||
|
||||
# Local dev personas
|
||||
DEV_AUTH_ENABLED=
|
||||
DEV_AUTH_CONVEX_DEPLOYMENT=
|
||||
DEV_AUTH_SITE_URL=
|
||||
DEV_AUTH_SECRET=
|
||||
|
||||
# Embeddings
|
||||
OPENAI_API_KEY=
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
name: ClawHub Scheduled Live Checks
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 5 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
github-repo:
|
||||
description: GitHub skills repo to use for the source-backed canary
|
||||
required: false
|
||||
default: openclaw/agent-skills
|
||||
github-skill:
|
||||
description: Skill slug to verify from the GitHub skills repo
|
||||
required: false
|
||||
default: handoff
|
||||
|
||||
concurrency:
|
||||
group: clawhub-scheduled-live-checks-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
VITE_CONVEX_URL: https://example.invalid
|
||||
|
||||
jobs:
|
||||
github-backed-skills:
|
||||
name: GitHub-backed skills canary
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Run GitHub-backed skills live canary
|
||||
env:
|
||||
CLAWHUB_LIVE_GITHUB_CANARY: "1"
|
||||
CLAWHUB_LIVE_GITHUB_REPO: ${{ inputs.github-repo || 'openclaw/agent-skills' }}
|
||||
CLAWHUB_LIVE_GITHUB_SKILL: ${{ inputs.github-skill || 'handoff' }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: bunx vitest run convex/githubSkillSync.live.test.ts
|
||||
|
||||
open-failure-issue:
|
||||
name: Open failure issue
|
||||
needs: github-backed-skills
|
||||
if: ${{ always() && needs.github-backed-skills.result == 'failure' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Open or update failure issue
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
WORKFLOW_NAME: ${{ github.workflow }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
marker_token="clawhub-scheduled-live-checks-failure"
|
||||
marker="<!-- $marker_token -->"
|
||||
title="ClawHub scheduled live checks failing"
|
||||
issue_number="$(gh issue list \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--state open \
|
||||
--search "$marker_token in:body" \
|
||||
--json number \
|
||||
--jq '.[0].number // empty')"
|
||||
|
||||
body_file="$(mktemp)"
|
||||
cat > "$body_file" <<EOF
|
||||
$marker
|
||||
The scheduled ClawHub live checks failed.
|
||||
|
||||
Workflow: $WORKFLOW_NAME
|
||||
Run: $RUN_URL
|
||||
EOF
|
||||
|
||||
if [[ -n "$issue_number" ]]; then
|
||||
gh issue comment "$issue_number" --repo "$GITHUB_REPOSITORY" --body-file "$body_file"
|
||||
else
|
||||
gh issue create --repo "$GITHUB_REPOSITORY" --title "$title" --body-file "$body_file"
|
||||
fi
|
||||
@@ -3,6 +3,7 @@ node_modules
|
||||
.bun-build
|
||||
*.bun-build
|
||||
.artifacts/
|
||||
artifacts/
|
||||
.cache/
|
||||
.data/
|
||||
bin/docs-list
|
||||
|
||||
@@ -51,6 +51,7 @@ Specialized corpus, scanner, security-worker, UI proof, proof publishing, Crabbo
|
||||
- Tests live in `src/**` and `convex/lib/**`.
|
||||
- Coverage threshold: 80% global (lines/functions/branches/statements).
|
||||
- Example: `convex/lib/skills.test.ts`.
|
||||
- For local UI state testing, prefer creating realistic backend state through seed logic plus a DevPersonaFab entry for the associated test user. Avoid one-off manual DB edits when the state is likely to be reused, such as org membership, official publisher access, moderation holds, or publishing permissions.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 0.19.1 - 2026-06-05
|
||||
|
||||
### Fixes
|
||||
|
||||
- CLI: install source-backed GitHub skills from the deployed `/api/v1/skills/:slug/install` resolver so `clawhub install` works for skills without hosted ClawHub versions.
|
||||
|
||||
## 0.19.0 - 2026-06-03
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
},
|
||||
"packages/clawhub": {
|
||||
"name": "clawhub",
|
||||
"version": "0.19.0",
|
||||
"version": "0.19.1",
|
||||
"bin": {
|
||||
"clawdhub": "bin/clawdhub.js",
|
||||
"clawhub": "bin/clawdhub.js",
|
||||
|
||||
Vendored
+20
@@ -17,6 +17,7 @@ import type * as crons from "../crons.js";
|
||||
import type * as depRegistryScan from "../depRegistryScan.js";
|
||||
import type * as devSeed from "../devSeed.js";
|
||||
import type * as devSeedExtra from "../devSeedExtra.js";
|
||||
import type * as downloadMetrics from "../downloadMetrics.js";
|
||||
import type * as downloads from "../downloads.js";
|
||||
import type * as functions from "../functions.js";
|
||||
import type * as githubAccountAgeBackfill from "../githubAccountAgeBackfill.js";
|
||||
@@ -26,6 +27,8 @@ import type * as githubIdentity from "../githubIdentity.js";
|
||||
import type * as githubImport from "../githubImport.js";
|
||||
import type * as githubRestore from "../githubRestore.js";
|
||||
import type * as githubRestoreMutations from "../githubRestoreMutations.js";
|
||||
import type * as githubSkillSources from "../githubSkillSources.js";
|
||||
import type * as githubSkillSync from "../githubSkillSync.js";
|
||||
import type * as githubSoulBackups from "../githubSoulBackups.js";
|
||||
import type * as githubSoulBackupsNode from "../githubSoulBackupsNode.js";
|
||||
import type * as http from "../http.js";
|
||||
@@ -55,6 +58,7 @@ import type * as lib_commentScamPrompt from "../lib/commentScamPrompt.js";
|
||||
import type * as lib_contentTypes from "../lib/contentTypes.js";
|
||||
import type * as lib_depRegistryScan from "../lib/depRegistryScan.js";
|
||||
import type * as lib_devAuth from "../lib/devAuth.js";
|
||||
import type * as lib_devSeed from "../lib/devSeed.js";
|
||||
import type * as lib_embeddingVisibility from "../lib/embeddingVisibility.js";
|
||||
import type * as lib_embeddings from "../lib/embeddings.js";
|
||||
import type * as lib_githubAccount from "../lib/githubAccount.js";
|
||||
@@ -65,16 +69,19 @@ import type * as lib_githubIdentity from "../lib/githubIdentity.js";
|
||||
import type * as lib_githubImport from "../lib/githubImport.js";
|
||||
import type * as lib_githubProfileSync from "../lib/githubProfileSync.js";
|
||||
import type * as lib_githubRestoreHelpers from "../lib/githubRestoreHelpers.js";
|
||||
import type * as lib_githubSkillSync from "../lib/githubSkillSync.js";
|
||||
import type * as lib_githubSoulBackup from "../lib/githubSoulBackup.js";
|
||||
import type * as lib_globalStats from "../lib/globalStats.js";
|
||||
import type * as lib_httpHeaders from "../lib/httpHeaders.js";
|
||||
import type * as lib_httpRateLimit from "../lib/httpRateLimit.js";
|
||||
import type * as lib_httpUtils from "../lib/httpUtils.js";
|
||||
import type * as lib_installResolver from "../lib/installResolver.js";
|
||||
import type * as lib_leaderboards from "../lib/leaderboards.js";
|
||||
import type * as lib_manualOverrides from "../lib/manualOverrides.js";
|
||||
import type * as lib_moderation from "../lib/moderation.js";
|
||||
import type * as lib_moderationEngine from "../lib/moderationEngine.js";
|
||||
import type * as lib_moderationReasonCodes from "../lib/moderationReasonCodes.js";
|
||||
import type * as lib_observabilityEvents from "../lib/observabilityEvents.js";
|
||||
import type * as lib_officialPublishers from "../lib/officialPublishers.js";
|
||||
import type * as lib_openaiResponse from "../lib/openaiResponse.js";
|
||||
import type * as lib_packageRegistry from "../lib/packageRegistry.js";
|
||||
@@ -85,6 +92,7 @@ import type * as lib_public from "../lib/public.js";
|
||||
import type * as lib_publicRouteReservations from "../lib/publicRouteReservations.js";
|
||||
import type * as lib_publishLimits from "../lib/publishLimits.js";
|
||||
import type * as lib_publisherAbuseScoring from "../lib/publisherAbuseScoring.js";
|
||||
import type * as lib_publisherCatalogDisplay from "../lib/publisherCatalogDisplay.js";
|
||||
import type * as lib_publisherStats from "../lib/publisherStats.js";
|
||||
import type * as lib_publishers from "../lib/publishers.js";
|
||||
import type * as lib_reporting from "../lib/reporting.js";
|
||||
@@ -115,9 +123,11 @@ 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 managementDevSeed from "../managementDevSeed.js";
|
||||
import type * as packagePublishTokens from "../packagePublishTokens.js";
|
||||
import type * as packages from "../packages.js";
|
||||
import type * as publisherAbuse from "../publisherAbuse.js";
|
||||
import type * as publisherAbuseDevSeed from "../publisherAbuseDevSeed.js";
|
||||
import type * as publishers from "../publishers.js";
|
||||
import type * as rateLimits from "../rateLimits.js";
|
||||
import type * as search from "../search.js";
|
||||
@@ -159,6 +169,7 @@ declare const fullApi: ApiFromModules<{
|
||||
depRegistryScan: typeof depRegistryScan;
|
||||
devSeed: typeof devSeed;
|
||||
devSeedExtra: typeof devSeedExtra;
|
||||
downloadMetrics: typeof downloadMetrics;
|
||||
downloads: typeof downloads;
|
||||
functions: typeof functions;
|
||||
githubAccountAgeBackfill: typeof githubAccountAgeBackfill;
|
||||
@@ -168,6 +179,8 @@ declare const fullApi: ApiFromModules<{
|
||||
githubImport: typeof githubImport;
|
||||
githubRestore: typeof githubRestore;
|
||||
githubRestoreMutations: typeof githubRestoreMutations;
|
||||
githubSkillSources: typeof githubSkillSources;
|
||||
githubSkillSync: typeof githubSkillSync;
|
||||
githubSoulBackups: typeof githubSoulBackups;
|
||||
githubSoulBackupsNode: typeof githubSoulBackupsNode;
|
||||
http: typeof http;
|
||||
@@ -197,6 +210,7 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/contentTypes": typeof lib_contentTypes;
|
||||
"lib/depRegistryScan": typeof lib_depRegistryScan;
|
||||
"lib/devAuth": typeof lib_devAuth;
|
||||
"lib/devSeed": typeof lib_devSeed;
|
||||
"lib/embeddingVisibility": typeof lib_embeddingVisibility;
|
||||
"lib/embeddings": typeof lib_embeddings;
|
||||
"lib/githubAccount": typeof lib_githubAccount;
|
||||
@@ -207,16 +221,19 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/githubImport": typeof lib_githubImport;
|
||||
"lib/githubProfileSync": typeof lib_githubProfileSync;
|
||||
"lib/githubRestoreHelpers": typeof lib_githubRestoreHelpers;
|
||||
"lib/githubSkillSync": typeof lib_githubSkillSync;
|
||||
"lib/githubSoulBackup": typeof lib_githubSoulBackup;
|
||||
"lib/globalStats": typeof lib_globalStats;
|
||||
"lib/httpHeaders": typeof lib_httpHeaders;
|
||||
"lib/httpRateLimit": typeof lib_httpRateLimit;
|
||||
"lib/httpUtils": typeof lib_httpUtils;
|
||||
"lib/installResolver": typeof lib_installResolver;
|
||||
"lib/leaderboards": typeof lib_leaderboards;
|
||||
"lib/manualOverrides": typeof lib_manualOverrides;
|
||||
"lib/moderation": typeof lib_moderation;
|
||||
"lib/moderationEngine": typeof lib_moderationEngine;
|
||||
"lib/moderationReasonCodes": typeof lib_moderationReasonCodes;
|
||||
"lib/observabilityEvents": typeof lib_observabilityEvents;
|
||||
"lib/officialPublishers": typeof lib_officialPublishers;
|
||||
"lib/openaiResponse": typeof lib_openaiResponse;
|
||||
"lib/packageRegistry": typeof lib_packageRegistry;
|
||||
@@ -227,6 +244,7 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/publicRouteReservations": typeof lib_publicRouteReservations;
|
||||
"lib/publishLimits": typeof lib_publishLimits;
|
||||
"lib/publisherAbuseScoring": typeof lib_publisherAbuseScoring;
|
||||
"lib/publisherCatalogDisplay": typeof lib_publisherCatalogDisplay;
|
||||
"lib/publisherStats": typeof lib_publisherStats;
|
||||
"lib/publishers": typeof lib_publishers;
|
||||
"lib/reporting": typeof lib_reporting;
|
||||
@@ -257,9 +275,11 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/webhooks": typeof lib_webhooks;
|
||||
llmEval: typeof llmEval;
|
||||
maintenance: typeof maintenance;
|
||||
managementDevSeed: typeof managementDevSeed;
|
||||
packagePublishTokens: typeof packagePublishTokens;
|
||||
packages: typeof packages;
|
||||
publisherAbuse: typeof publisherAbuse;
|
||||
publisherAbuseDevSeed: typeof publisherAbuseDevSeed;
|
||||
publishers: typeof publishers;
|
||||
rateLimits: typeof rateLimits;
|
||||
search: typeof search;
|
||||
|
||||
+9
-4
@@ -9,12 +9,12 @@ import { isLocalDevAuthEnabled } from "./lib/devAuth";
|
||||
import { shouldScheduleGitHubProfileSync } from "./lib/githubProfileSync";
|
||||
|
||||
export const BANNED_REAUTH_MESSAGE =
|
||||
"This account has been banned and cannot sign in. If you believe this is a mistake, please contact security@openclaw.ai and we will review it.";
|
||||
"This account has been banned and cannot sign in. If you believe this is a mistake, open a GitHub issue: https://github.com/openclaw/clawhub/issues/new.";
|
||||
export const DELETED_ACCOUNT_REAUTH_MESSAGE =
|
||||
"This account has been permanently deleted and cannot be restored.";
|
||||
|
||||
const REAUTH_BLOCKING_BAN_ACTIONS = new Set(["user.ban", "user.autoban.malware"]);
|
||||
const DEV_PERSONAS = new Set(["owner", "user", "admin"]);
|
||||
const DEV_PERSONAS = new Set(["owner", "user", "admin", "officialOrgMember"]);
|
||||
|
||||
function getBannedReauthMessage(reason: string | undefined) {
|
||||
const normalizedReason = reason?.trim();
|
||||
@@ -90,11 +90,16 @@ export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
|
||||
ConvexCredentials({
|
||||
id: "dev-persona",
|
||||
authorize: async (credentials, ctx) => {
|
||||
if (!isLocalDevAuthEnabled()) throw new Error("Dev auth is disabled");
|
||||
const devAuthSecret =
|
||||
typeof credentials.devAuthSecret === "string" ? credentials.devAuthSecret : undefined;
|
||||
if (!isLocalDevAuthEnabled(process.env, devAuthSecret)) {
|
||||
throw new Error("Dev auth is disabled");
|
||||
}
|
||||
const persona = typeof credentials.persona === "string" ? credentials.persona : "";
|
||||
if (!DEV_PERSONAS.has(persona)) throw new Error("Unknown dev persona");
|
||||
const userId: Id<"users"> = await ctx.runMutation(internal.users.upsertDevPersonaInternal, {
|
||||
persona: persona as "owner" | "user" | "admin",
|
||||
persona: persona as "owner" | "user" | "admin" | "officialOrgMember",
|
||||
devAuthSecret,
|
||||
});
|
||||
return { userId };
|
||||
},
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/* @vitest-environment node */
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const interval = vi.fn();
|
||||
const githubSkillSyncRef = Symbol("github-skill-source-sync");
|
||||
return { interval, githubSkillSyncRef };
|
||||
});
|
||||
|
||||
vi.mock("convex/server", () => ({
|
||||
cronJobs: () => ({
|
||||
interval: mocks.interval,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./_generated/api", () => ({
|
||||
internal: {
|
||||
githubBackupsNode: { syncGitHubBackupsInternal: Symbol("github-backup-sync") },
|
||||
githubSkillSync: { syncGitHubSkillSourcesInternal: mocks.githubSkillSyncRef },
|
||||
leaderboards: { rebuildTrendingLeaderboardAction: Symbol("trending-leaderboard") },
|
||||
statsMaintenance: {
|
||||
runSkillStatBackfillInternal: Symbol("skill-stats-backfill"),
|
||||
updateGlobalStatsAction: Symbol("global-stats-update"),
|
||||
},
|
||||
skillStatEvents: { processSkillStatEventsAction: Symbol("skill-stat-events") },
|
||||
packages: {
|
||||
processPackageStatEventsInternal: Symbol("package-stat-events"),
|
||||
backfillPackageReleaseScansInternal: Symbol("package-scan-backfill"),
|
||||
},
|
||||
publisherAbuse: {
|
||||
runPublisherAbuseScoreRunInternal: Symbol("publisher-abuse-score-refresh"),
|
||||
},
|
||||
vt: {
|
||||
pollPendingScans: Symbol("vt-pending-scans"),
|
||||
backfillActiveSkillsVTCache: Symbol("vt-cache-backfill"),
|
||||
},
|
||||
securityScan: {
|
||||
pruneExpiredSkillScanRequestsInternal: Symbol("skill-scan-request-prune"),
|
||||
},
|
||||
downloads: { pruneDownloadDedupesInternal: Symbol("download-dedupe-prune") },
|
||||
downloadMetrics: {
|
||||
pruneDownloadMetricDedupesInternal: Symbol("download-metric-dedupe-prune"),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("crons", () => {
|
||||
it("runs GitHub skill source sync every 15 minutes", async () => {
|
||||
await import("./crons");
|
||||
|
||||
expect(mocks.interval).toHaveBeenCalledWith(
|
||||
"github-skill-source-sync",
|
||||
{ minutes: 15 },
|
||||
mocks.githubSkillSyncRef,
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,13 @@ crons.interval(
|
||||
{ batchSize: 50, maxBatches: 5 },
|
||||
);
|
||||
|
||||
crons.interval(
|
||||
"github-skill-source-sync",
|
||||
{ minutes: 15 },
|
||||
internal.githubSkillSync.syncGitHubSkillSourcesInternal,
|
||||
{},
|
||||
);
|
||||
|
||||
crons.interval(
|
||||
"trending-leaderboard",
|
||||
{ minutes: 60 },
|
||||
@@ -93,4 +100,11 @@ crons.interval(
|
||||
{},
|
||||
);
|
||||
|
||||
crons.interval(
|
||||
"download-metric-dedupe-prune",
|
||||
{ hours: 24 },
|
||||
internal.downloadMetrics.pruneDownloadMetricDedupesInternal,
|
||||
{},
|
||||
);
|
||||
|
||||
export default crons;
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
currentUserSeedPackageName,
|
||||
currentUserSeedSkillSlug,
|
||||
seedFeaturedPluginPackagesMutation,
|
||||
seedGitHubBackedSkillSourceMutation,
|
||||
seedLocalFixtures,
|
||||
seedLocalModerationFixturesHandler,
|
||||
seedSkillMutation,
|
||||
} from "./devSeed";
|
||||
@@ -18,6 +20,12 @@ const seedSkillMutationHandler = (
|
||||
const seedFeaturedPluginPackagesHandler = (
|
||||
seedFeaturedPluginPackagesMutation as unknown as WrappedHandler<Record<string, unknown>>
|
||||
)._handler;
|
||||
const seedGitHubBackedSkillSourceHandler = (
|
||||
seedGitHubBackedSkillSourceMutation as unknown as WrappedHandler<Record<string, unknown>>
|
||||
)._handler;
|
||||
const seedLocalFixturesHandler = (
|
||||
seedLocalFixtures as unknown as WrappedHandler<{ reset?: boolean }>
|
||||
)._handler;
|
||||
|
||||
function chainEq(constraints: Record<string, unknown>) {
|
||||
return {
|
||||
@@ -146,6 +154,33 @@ function seedSkillArgs(storageId: string) {
|
||||
}
|
||||
|
||||
describe("devSeed local fixtures", () => {
|
||||
it("does not preconfigure GitHub-backed source fixtures in the local seed action", async () => {
|
||||
const mutationCalls: Array<{ args: Record<string, unknown> }> = [];
|
||||
let storageCounter = 0;
|
||||
const ctx = {
|
||||
storage: {
|
||||
store: async () => `storage:${++storageCounter}`,
|
||||
},
|
||||
runMutation: async (_ref: unknown, args: Record<string, unknown>) => {
|
||||
mutationCalls.push({ args });
|
||||
return { ok: true, seeded: ["local-moderation-fixtures"], skipped: [] };
|
||||
},
|
||||
};
|
||||
|
||||
const result = await seedLocalFixturesHandler(ctx as never, { reset: true });
|
||||
|
||||
expect(mutationCalls).toHaveLength(1);
|
||||
expect(mutationCalls[0]?.args).toMatchObject({
|
||||
reset: true,
|
||||
});
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
ok: true,
|
||||
results: [expect.objectContaining({ slug: "local-moderation-fixtures" })],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("seeds core skill fixtures for an explicit local user without creating @local", async () => {
|
||||
const { db, tables } = createDb();
|
||||
const userId = (await db.insert("users", {
|
||||
@@ -190,6 +225,176 @@ describe("devSeed local fixtures", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("seeds a GitHub-backed source and skills without creating mirrored versions", async () => {
|
||||
const { db, tables } = createDb();
|
||||
const userId = (await db.insert("users", {
|
||||
handle: "nvidia-dev",
|
||||
displayName: "NVIDIA Dev",
|
||||
role: "user",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
})) as Id<"users">;
|
||||
|
||||
const result = await seedGitHubBackedSkillSourceHandler(
|
||||
createMutationCtx(db) as never,
|
||||
{
|
||||
ownerUserId: userId,
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
displayManifestKind: "skills.sh",
|
||||
displayManifestHash: "manifest-sha256",
|
||||
displayManifestCommit: "0".repeat(40),
|
||||
displayManifestFetchedAt: 123,
|
||||
displayManifestStatus: "ok",
|
||||
displayManifest: {
|
||||
notGrouped: "bottom",
|
||||
groupings: [
|
||||
{
|
||||
title: "Agentic AI",
|
||||
description: "Agentic AI skills.",
|
||||
skills: ["aiq-deploy", "nemoclaw-user-configure-security"],
|
||||
},
|
||||
],
|
||||
},
|
||||
skills: [
|
||||
{
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
summary: "Deploy AgentIQ workflows.",
|
||||
githubPath: "skills/aiq-deploy",
|
||||
githubCurrentCommit: "1".repeat(40),
|
||||
githubCurrentContentHash: "hash-aiq-deploy",
|
||||
githubScanStatus: "clean",
|
||||
githubCurrentCheckedAt: 456,
|
||||
},
|
||||
{
|
||||
slug: "nemoclaw-user-configure-security",
|
||||
displayName: "NeMoClaw User Configure Security",
|
||||
summary: "Configure NeMoClaw user security.",
|
||||
githubPath: "skills/nemoclaw-user-configure-security",
|
||||
githubCurrentCommit: "2".repeat(40),
|
||||
githubCurrentContentHash: "hash-nemoclaw",
|
||||
githubScanStatus: "clean",
|
||||
githubCurrentCheckedAt: 789,
|
||||
githubRemovedAt: 900,
|
||||
},
|
||||
],
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
seeded: ["aiq-deploy", "nemoclaw-user-configure-security"],
|
||||
skipped: [],
|
||||
});
|
||||
expect(tables.githubSkillSources).toHaveLength(1);
|
||||
expect(tables.githubSkillSources?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
repo: "NVIDIA/skills",
|
||||
ownerPublisherId: tables.publishers?.[0]?._id,
|
||||
defaultBranch: "main",
|
||||
displayManifestKind: "skills.sh",
|
||||
displayManifestHash: "manifest-sha256",
|
||||
displayManifestCommit: "0".repeat(40),
|
||||
displayManifestFetchedAt: 123,
|
||||
displayManifestStatus: "ok",
|
||||
displayManifest: {
|
||||
notGrouped: "bottom",
|
||||
groupings: [
|
||||
{
|
||||
title: "Agentic AI",
|
||||
description: "Agentic AI skills.",
|
||||
skills: ["aiq-deploy", "nemoclaw-user-configure-security"],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(tables.skills).toHaveLength(2);
|
||||
expect(tables.skills).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
slug: "aiq-deploy",
|
||||
installKind: "github",
|
||||
githubSourceId: tables.githubSkillSources?.[0]?._id,
|
||||
githubPath: "skills/aiq-deploy",
|
||||
githubCurrentCommit: "1".repeat(40),
|
||||
githubCurrentContentHash: "hash-aiq-deploy",
|
||||
githubScanStatus: "clean",
|
||||
githubCurrentCheckedAt: 456,
|
||||
latestVersionId: undefined,
|
||||
latestVersionSummary: undefined,
|
||||
tags: {},
|
||||
stats: expect.objectContaining({ versions: 0 }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
slug: "nemoclaw-user-configure-security",
|
||||
installKind: "github",
|
||||
githubSourceId: tables.githubSkillSources?.[0]?._id,
|
||||
githubPath: "skills/nemoclaw-user-configure-security",
|
||||
githubCurrentContentHash: "hash-nemoclaw",
|
||||
githubRemovedAt: 900,
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "github.upstream.removed",
|
||||
moderationVerdict: undefined,
|
||||
isSuspicious: false,
|
||||
latestVersionId: undefined,
|
||||
tags: {},
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(tables.skillVersions ?? []).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps unscanned GitHub-backed skills hidden from public listings", async () => {
|
||||
const { db, tables } = createDb();
|
||||
|
||||
await seedGitHubBackedSkillSourceHandler(
|
||||
createMutationCtx(db) as never,
|
||||
{
|
||||
repo: "NVIDIA/skills",
|
||||
displayManifestStatus: "ok",
|
||||
skills: [
|
||||
{
|
||||
slug: "pending-github-skill",
|
||||
displayName: "Pending GitHub Skill",
|
||||
githubPath: "skills/pending-github-skill",
|
||||
githubCurrentCommit: "1".repeat(40),
|
||||
githubCurrentContentHash: "hash-pending",
|
||||
githubScanStatus: "pending",
|
||||
},
|
||||
{
|
||||
slug: "failed-scan-github-skill",
|
||||
displayName: "Failed Scan GitHub Skill",
|
||||
githubPath: "skills/failed-scan-github-skill",
|
||||
githubCurrentCommit: "2".repeat(40),
|
||||
githubCurrentContentHash: "hash-failed-scan",
|
||||
githubScanStatus: "failed",
|
||||
},
|
||||
],
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(tables.skills).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
slug: "pending-github-skill",
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "pending.scan",
|
||||
moderationVerdict: undefined,
|
||||
isSuspicious: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
slug: "failed-scan-github-skill",
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "scanner.failed",
|
||||
moderationVerdict: undefined,
|
||||
isSuspicious: false,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("seeds moderation and plugin fixtures for an explicit local user with scoped identifiers", async () => {
|
||||
const { db, tables } = createDb();
|
||||
const userId = (await db.insert("users", {
|
||||
|
||||
+337
-1
@@ -31,6 +31,67 @@ type SeedActionResult = {
|
||||
|
||||
type SeedMutationResult = Record<string, unknown>;
|
||||
|
||||
const displayManifestStatusValidator = v.union(
|
||||
v.literal("ok"),
|
||||
v.literal("missing"),
|
||||
v.literal("invalid"),
|
||||
v.literal("failed"),
|
||||
);
|
||||
|
||||
const displayManifestValidator = v.object({
|
||||
notGrouped: v.optional(v.union(v.literal("top"), v.literal("bottom"))),
|
||||
groupings: v.array(
|
||||
v.object({
|
||||
title: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
skills: v.array(v.string()),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const githubSkillScanStatusValidator = v.union(
|
||||
v.literal("clean"),
|
||||
v.literal("suspicious"),
|
||||
v.literal("malicious"),
|
||||
v.literal("pending"),
|
||||
v.literal("failed"),
|
||||
);
|
||||
|
||||
type GitHubSkillScanStatus = "clean" | "suspicious" | "malicious" | "pending" | "failed";
|
||||
|
||||
type SeedGitHubBackedSkillSourceArgs = {
|
||||
reset?: boolean;
|
||||
ownerUserId?: Id<"users">;
|
||||
repo: string;
|
||||
defaultBranch?: string;
|
||||
displayManifestKind?: "skills.sh";
|
||||
displayManifestHash?: string;
|
||||
displayManifestCommit?: string;
|
||||
displayManifestFetchedAt?: number;
|
||||
displayManifestStatus?: "ok" | "missing" | "invalid" | "failed";
|
||||
displayManifest?: {
|
||||
notGrouped?: "top" | "bottom";
|
||||
groupings: Array<{
|
||||
title: string;
|
||||
description?: string;
|
||||
skills: string[];
|
||||
}>;
|
||||
};
|
||||
skills: Array<{
|
||||
slug: string;
|
||||
displayName: string;
|
||||
summary?: string;
|
||||
githubPath: string;
|
||||
githubCurrentCommit: string;
|
||||
githubCurrentContentHash: string;
|
||||
githubCurrentStatus?: "present" | "missing" | "unknown";
|
||||
githubCurrentCheckedAt?: number;
|
||||
githubScanStatus: GitHubSkillScanStatus;
|
||||
githubRemovedAt?: number;
|
||||
capabilityTags?: string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
type PublicCorpusDummyOwner = {
|
||||
handle: string;
|
||||
displayName: string;
|
||||
@@ -734,7 +795,10 @@ async function seedLocalFixturesHandler(
|
||||
},
|
||||
);
|
||||
|
||||
return { ok: true, results: [{ slug: "local-moderation-fixtures", ...fixtureResult }] };
|
||||
return {
|
||||
ok: true,
|
||||
results: [{ slug: "local-moderation-fixtures", ...fixtureResult }],
|
||||
};
|
||||
}
|
||||
|
||||
export const seedLocalFixtures: ReturnType<typeof internalAction> = internalAction({
|
||||
@@ -2432,6 +2496,278 @@ export const seedLocalModerationFixturesMutation = internalMutation({
|
||||
handler: seedLocalModerationFixturesHandler,
|
||||
});
|
||||
|
||||
function githubBackedSkillModeration(scanStatus: GitHubSkillScanStatus, removedAt?: number) {
|
||||
if (typeof removedAt === "number") {
|
||||
return {
|
||||
moderationStatus: "hidden" as const,
|
||||
moderationReason: "github.upstream.removed",
|
||||
moderationVerdict: undefined,
|
||||
moderationFlags: [],
|
||||
isSuspicious: false,
|
||||
};
|
||||
}
|
||||
if (scanStatus === "pending") {
|
||||
return {
|
||||
moderationStatus: "hidden" as const,
|
||||
moderationReason: "pending.scan",
|
||||
moderationVerdict: undefined,
|
||||
moderationFlags: [],
|
||||
isSuspicious: false,
|
||||
};
|
||||
}
|
||||
if (scanStatus === "failed") {
|
||||
return {
|
||||
moderationStatus: "hidden" as const,
|
||||
moderationReason: "scanner.failed",
|
||||
moderationVerdict: undefined,
|
||||
moderationFlags: [],
|
||||
isSuspicious: false,
|
||||
};
|
||||
}
|
||||
if (scanStatus === "malicious") {
|
||||
return {
|
||||
moderationStatus: "hidden" as const,
|
||||
moderationReason: "scanner.llm.malicious",
|
||||
moderationVerdict: "malicious" as const,
|
||||
moderationFlags: ["blocked.malware"],
|
||||
isSuspicious: true,
|
||||
};
|
||||
}
|
||||
if (scanStatus === "suspicious") {
|
||||
return {
|
||||
moderationStatus: "active" as const,
|
||||
moderationReason: "scanner.llm.suspicious",
|
||||
moderationVerdict: "suspicious" as const,
|
||||
moderationFlags: ["flagged.suspicious"],
|
||||
isSuspicious: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
moderationStatus: "active" as const,
|
||||
moderationReason: undefined,
|
||||
moderationVerdict: "clean" as const,
|
||||
moderationFlags: [],
|
||||
isSuspicious: false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function seedGitHubBackedSkillSourceHandler(
|
||||
ctx: MutationCtx,
|
||||
args: SeedGitHubBackedSkillSourceArgs,
|
||||
) {
|
||||
const now = Date.now();
|
||||
const { userId, publisherId } = await ensureSeedOwner(ctx, args.ownerUserId);
|
||||
const existingSource = await ctx.db
|
||||
.query("githubSkillSources")
|
||||
.withIndex("by_repo", (q) => q.eq("repo", args.repo))
|
||||
.unique();
|
||||
const sourcePatch = {
|
||||
repo: args.repo,
|
||||
ownerPublisherId: publisherId,
|
||||
defaultBranch: args.defaultBranch,
|
||||
displayManifestKind: args.displayManifestKind,
|
||||
displayManifestHash: args.displayManifestHash,
|
||||
displayManifestCommit: args.displayManifestCommit,
|
||||
displayManifestFetchedAt: args.displayManifestFetchedAt,
|
||||
displayManifestStatus: args.displayManifestStatus,
|
||||
displayManifest: args.displayManifest,
|
||||
updatedAt: now,
|
||||
};
|
||||
const sourceId =
|
||||
existingSource?._id ??
|
||||
(await ctx.db.insert("githubSkillSources", {
|
||||
...sourcePatch,
|
||||
createdAt: now,
|
||||
}));
|
||||
if (existingSource) await ctx.db.patch(existingSource._id, sourcePatch);
|
||||
|
||||
const seeded: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
|
||||
for (const spec of args.skills) {
|
||||
const existing = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_slug", (q) => q.eq("slug", spec.slug))
|
||||
.unique();
|
||||
if (existing && !args.reset) {
|
||||
skipped.push(spec.slug);
|
||||
continue;
|
||||
}
|
||||
if (existing && args.reset) await deleteSkillAndVersions(ctx, existing._id);
|
||||
|
||||
const moderation = githubBackedSkillModeration(spec.githubScanStatus, spec.githubRemovedAt);
|
||||
const skillId = await ctx.db.insert("skills", {
|
||||
slug: spec.slug,
|
||||
displayName: spec.displayName,
|
||||
summary: spec.summary,
|
||||
ownerUserId: userId,
|
||||
ownerPublisherId: publisherId,
|
||||
installKind: "github",
|
||||
githubSourceId: sourceId,
|
||||
githubPath: spec.githubPath,
|
||||
githubCurrentCommit: spec.githubCurrentCommit,
|
||||
githubCurrentContentHash: spec.githubCurrentContentHash,
|
||||
githubCurrentStatus:
|
||||
spec.githubCurrentStatus ?? (spec.githubRemovedAt ? "missing" : "present"),
|
||||
githubCurrentCheckedAt: spec.githubCurrentCheckedAt,
|
||||
githubScanStatus: spec.githubScanStatus,
|
||||
githubRemovedAt: spec.githubRemovedAt,
|
||||
latestVersionId: undefined,
|
||||
latestVersionSummary: undefined,
|
||||
tags: {},
|
||||
capabilityTags: spec.capabilityTags ?? [],
|
||||
softDeletedAt: undefined,
|
||||
badges: { highlighted: { byUserId: userId, at: now }, redactionApproved: undefined },
|
||||
moderationStatus: moderation.moderationStatus,
|
||||
moderationReason: moderation.moderationReason,
|
||||
moderationVerdict: moderation.moderationVerdict,
|
||||
moderationFlags: moderation.moderationFlags,
|
||||
isSuspicious: moderation.isSuspicious,
|
||||
statsDownloads: 0,
|
||||
statsStars: 0,
|
||||
statsInstallsCurrent: 0,
|
||||
statsInstallsAllTime: 0,
|
||||
stats: {
|
||||
downloads: 0,
|
||||
installsCurrent: 0,
|
||||
installsAllTime: 0,
|
||||
stars: 0,
|
||||
versions: 0,
|
||||
comments: 0,
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await ensureHighlightedSkillBadge(ctx, skillId, userId, now);
|
||||
seeded.push(spec.slug);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
sourceId,
|
||||
ownerUserId: userId,
|
||||
ownerPublisherId: publisherId,
|
||||
seeded,
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
|
||||
export const seedGitHubBackedSkillSourceMutation = internalMutation({
|
||||
args: {
|
||||
reset: v.optional(v.boolean()),
|
||||
ownerUserId: v.optional(v.id("users")),
|
||||
repo: v.string(),
|
||||
defaultBranch: v.optional(v.string()),
|
||||
displayManifestKind: v.optional(v.literal("skills.sh")),
|
||||
displayManifestHash: v.optional(v.string()),
|
||||
displayManifestCommit: v.optional(v.string()),
|
||||
displayManifestFetchedAt: v.optional(v.number()),
|
||||
displayManifestStatus: v.optional(displayManifestStatusValidator),
|
||||
displayManifest: v.optional(displayManifestValidator),
|
||||
skills: v.array(
|
||||
v.object({
|
||||
slug: v.string(),
|
||||
displayName: v.string(),
|
||||
summary: v.optional(v.string()),
|
||||
githubPath: v.string(),
|
||||
githubCurrentCommit: v.string(),
|
||||
githubCurrentContentHash: v.string(),
|
||||
githubCurrentStatus: v.optional(
|
||||
v.union(v.literal("present"), v.literal("missing"), v.literal("unknown")),
|
||||
),
|
||||
githubCurrentCheckedAt: v.optional(v.number()),
|
||||
githubScanStatus: githubSkillScanStatusValidator,
|
||||
githubRemovedAt: v.optional(v.number()),
|
||||
capabilityTags: v.optional(v.array(v.string())),
|
||||
}),
|
||||
),
|
||||
},
|
||||
handler: seedGitHubBackedSkillSourceHandler,
|
||||
});
|
||||
|
||||
export const seedGitHubSourceInvalidSkillsPreviewMutation = internalMutation({
|
||||
args: {
|
||||
repo: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const source = await ctx.db
|
||||
.query("githubSkillSources")
|
||||
.withIndex("by_repo", (q) => q.eq("repo", args.repo))
|
||||
.unique();
|
||||
|
||||
if (!source) {
|
||||
return { ok: false as const, reason: "source_not_found" as const };
|
||||
}
|
||||
|
||||
const overlongSlug = "preview-" + "x".repeat(97);
|
||||
await ctx.db.patch(source._id, {
|
||||
lastSyncIssues: [
|
||||
{
|
||||
slug: overlongSlug,
|
||||
path: `skills/${overlongSlug}`,
|
||||
displayName: "Preview Invalid Skill",
|
||||
kind: "invalid_slug",
|
||||
severity: "error",
|
||||
message: "Slug must be at most 96 characters.",
|
||||
},
|
||||
],
|
||||
lastSyncInvalidSkills: [
|
||||
{
|
||||
slug: overlongSlug,
|
||||
path: `skills/${overlongSlug}`,
|
||||
displayName: "Preview Invalid Skill",
|
||||
error: "Slug must be at most 96 characters.",
|
||||
},
|
||||
],
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
|
||||
return { ok: true as const, sourceId: source._id };
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteGitHubBackedSkillSourceSeedMutation = internalMutation({
|
||||
args: {
|
||||
repo: v.optional(v.string()),
|
||||
sourceId: v.optional(v.id("githubSkillSources")),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const source = args.sourceId
|
||||
? await ctx.db.get(args.sourceId)
|
||||
: args.repo
|
||||
? await ctx.db
|
||||
.query("githubSkillSources")
|
||||
.withIndex("by_repo", (q) => q.eq("repo", args.repo as string))
|
||||
.unique()
|
||||
: null;
|
||||
const sourceId = source?._id ?? args.sourceId;
|
||||
if (!sourceId) {
|
||||
return { ok: true as const, deletedSource: false, deletedSkills: 0, deletedContents: 0 };
|
||||
}
|
||||
|
||||
const contents = await ctx.db
|
||||
.query("githubSkillContents")
|
||||
.withIndex("by_github_source", (q) => q.eq("githubSourceId", sourceId))
|
||||
.collect();
|
||||
for (const content of contents) await ctx.db.delete(content._id);
|
||||
|
||||
const skills = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_github_source", (q) => q.eq("githubSourceId", sourceId))
|
||||
.collect();
|
||||
for (const skill of skills) await deleteSkillAndVersions(ctx, skill._id);
|
||||
|
||||
if (source) await ctx.db.delete(source._id);
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
deletedSource: Boolean(source),
|
||||
deletedSkills: skills.length,
|
||||
deletedContents: contents.length,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const seedFeaturedPluginPackagesMutation = internalMutation({
|
||||
args: {
|
||||
reset: v.optional(v.boolean()),
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__test,
|
||||
pruneDownloadMetricDedupesInternal,
|
||||
recordDownloadMetricInternal,
|
||||
} from "./downloadMetrics";
|
||||
|
||||
type WrappedHandler<TArgs, TResult> = {
|
||||
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
|
||||
};
|
||||
|
||||
const recordDownloadMetricHandler = (
|
||||
recordDownloadMetricInternal as unknown as WrappedHandler<
|
||||
{
|
||||
target: { kind: "skill"; id: string } | { kind: "package"; id: string };
|
||||
identityKind: "user" | "ip";
|
||||
identityHash: string;
|
||||
dayStart: number;
|
||||
occurredAt?: number;
|
||||
},
|
||||
void
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const pruneDownloadMetricDedupesHandler = (
|
||||
pruneDownloadMetricDedupesInternal as unknown as WrappedHandler<
|
||||
Record<string, never>,
|
||||
{ deleted: number; hasMore: boolean }
|
||||
>
|
||||
)._handler;
|
||||
|
||||
function makeQueryBuilder() {
|
||||
const builder = {
|
||||
eq: vi.fn(() => builder),
|
||||
lt: vi.fn(() => builder),
|
||||
};
|
||||
return builder;
|
||||
}
|
||||
|
||||
type QueryBuilder = ReturnType<typeof makeQueryBuilder>;
|
||||
|
||||
function makeDb(
|
||||
existingByTable: Record<string, unknown> = {},
|
||||
rowsByTable: Record<string, Array<{ _id: string }>> = {},
|
||||
) {
|
||||
const indexCalls: Array<{ table: string; indexName: string; builder: QueryBuilder }> = [];
|
||||
const insert = vi.fn();
|
||||
const unique = vi.fn(async function uniqueForTable(this: { table: string }) {
|
||||
return existingByTable[this.table] ?? null;
|
||||
});
|
||||
const take = vi.fn(async function takeForTable(this: { table: string }, limit: number) {
|
||||
return (rowsByTable[this.table] ?? []).slice(0, limit);
|
||||
});
|
||||
const query = vi.fn((table: string) => ({
|
||||
withIndex: vi.fn((indexName: string, buildQuery: (q: unknown) => unknown) => {
|
||||
const builder = makeQueryBuilder();
|
||||
buildQuery(builder);
|
||||
indexCalls.push({ table, indexName, builder });
|
||||
return {
|
||||
unique: unique.bind({ table }),
|
||||
take: take.bind({ table }),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
const delete_ = vi.fn();
|
||||
return {
|
||||
db: {
|
||||
query,
|
||||
get: vi.fn(),
|
||||
insert,
|
||||
patch: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
delete: delete_,
|
||||
normalizeId: vi.fn(),
|
||||
system: {
|
||||
get: vi.fn(),
|
||||
query: vi.fn(),
|
||||
},
|
||||
},
|
||||
insert,
|
||||
delete_,
|
||||
take,
|
||||
indexCalls,
|
||||
};
|
||||
}
|
||||
|
||||
describe("download metric helpers", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("uses a day bucket for download dedupe", () => {
|
||||
expect(__test.getDayStart(86_400_000 - 1)).toBe(0);
|
||||
expect(__test.getDayStart(86_400_000)).toBe(86_400_000);
|
||||
});
|
||||
|
||||
it("prefers user identity and falls back to IP identity", () => {
|
||||
const request = new Request("https://example.com", {
|
||||
headers: { "cf-connecting-ip": "203.0.113.10" },
|
||||
});
|
||||
|
||||
expect(__test.getDownloadIdentity(request, "users:one")).toEqual({
|
||||
identityKind: "user",
|
||||
identityValue: "users:one",
|
||||
});
|
||||
expect(__test.getDownloadIdentity(request, null)).toEqual({
|
||||
identityKind: "ip",
|
||||
identityValue: "203.0.113.10",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not create a metering identity when user and IP are missing", () => {
|
||||
expect(__test.getDownloadIdentity(new Request("https://example.com"), null)).toBeNull();
|
||||
});
|
||||
|
||||
it("records one authenticated skill download and emits the existing skill stat event", async () => {
|
||||
const { db, insert, indexCalls } = makeDb();
|
||||
|
||||
await recordDownloadMetricHandler(
|
||||
{ db },
|
||||
{
|
||||
target: { kind: "skill", id: "skills:one" },
|
||||
identityKind: "user",
|
||||
identityHash: "hash-user",
|
||||
dayStart: 86_400_000,
|
||||
occurredAt: 86_500_000,
|
||||
},
|
||||
);
|
||||
|
||||
expect(indexCalls[0]?.table).toBe("downloadMetricDedupes");
|
||||
expect(indexCalls[0]?.indexName).toBe("by_target_identity_day");
|
||||
expect(indexCalls[0]?.builder.eq).toHaveBeenCalledWith("targetKind", "skill");
|
||||
expect(indexCalls[0]?.builder.eq).toHaveBeenCalledWith("targetId", "skills:one");
|
||||
expect(indexCalls[0]?.builder.eq).toHaveBeenCalledWith("identityKind", "user");
|
||||
expect(indexCalls[0]?.builder.eq).toHaveBeenCalledWith("identityHash", "hash-user");
|
||||
expect(indexCalls[0]?.builder.eq).toHaveBeenCalledWith("dayStart", 86_400_000);
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"downloadMetricDedupes",
|
||||
expect.objectContaining({
|
||||
targetKind: "skill",
|
||||
targetId: "skills:one",
|
||||
identityKind: "user",
|
||||
identityHash: "hash-user",
|
||||
dayStart: 86_400_000,
|
||||
}),
|
||||
);
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"skillStatEvents",
|
||||
expect.objectContaining({
|
||||
skillId: "skills:one",
|
||||
kind: "download",
|
||||
occurredAt: 86_500_000,
|
||||
}),
|
||||
);
|
||||
expect(insert).not.toHaveBeenCalledWith("packageStatEvents", expect.anything());
|
||||
});
|
||||
|
||||
it("records one anonymous package download and emits the existing package stat event", async () => {
|
||||
const { db, insert } = makeDb();
|
||||
|
||||
await recordDownloadMetricHandler(
|
||||
{ db },
|
||||
{
|
||||
target: { kind: "package", id: "packages:one" },
|
||||
identityKind: "ip",
|
||||
identityHash: "hash-ip",
|
||||
dayStart: 86_400_000,
|
||||
occurredAt: 86_500_000,
|
||||
},
|
||||
);
|
||||
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"downloadMetricDedupes",
|
||||
expect.objectContaining({
|
||||
targetKind: "package",
|
||||
targetId: "packages:one",
|
||||
identityKind: "ip",
|
||||
identityHash: "hash-ip",
|
||||
dayStart: 86_400_000,
|
||||
}),
|
||||
);
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"packageStatEvents",
|
||||
expect.objectContaining({
|
||||
packageId: "packages:one",
|
||||
kind: "download",
|
||||
occurredAt: 86_500_000,
|
||||
}),
|
||||
);
|
||||
expect(insert).not.toHaveBeenCalledWith("skillStatEvents", expect.anything());
|
||||
});
|
||||
|
||||
it("ignores duplicate identities in the same target/day bucket", async () => {
|
||||
const { db, insert } = makeDb({
|
||||
downloadMetricDedupes: { _id: "downloadMetricDedupes:existing" },
|
||||
});
|
||||
|
||||
await recordDownloadMetricHandler(
|
||||
{ db },
|
||||
{
|
||||
target: { kind: "skill", id: "skills:one" },
|
||||
identityKind: "ip",
|
||||
identityHash: "hash-ip",
|
||||
dayStart: 86_400_000,
|
||||
},
|
||||
);
|
||||
|
||||
expect(insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prunes stale dedupe rows by day bucket", async () => {
|
||||
vi.setSystemTime(30 * 86_400_000);
|
||||
const { db, delete_, take, indexCalls } = makeDb(
|
||||
{},
|
||||
{
|
||||
downloadMetricDedupes: [
|
||||
{ _id: "downloadMetricDedupes:one" },
|
||||
{ _id: "downloadMetricDedupes:two" },
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const result = await pruneDownloadMetricDedupesHandler({ db }, {});
|
||||
|
||||
expect(result).toEqual({ deleted: 2, hasMore: false });
|
||||
expect(indexCalls[0]?.table).toBe("downloadMetricDedupes");
|
||||
expect(indexCalls[0]?.indexName).toBe("by_day");
|
||||
expect(take).toHaveBeenCalledWith(200);
|
||||
expect(delete_).toHaveBeenCalledWith("downloadMetricDedupes:one");
|
||||
expect(delete_).toHaveBeenCalledWith("downloadMetricDedupes:two");
|
||||
});
|
||||
|
||||
it("reschedules stale dedupe pruning when one bounded batch fills", async () => {
|
||||
vi.setSystemTime(30 * 86_400_000);
|
||||
const rows = Array.from({ length: 200 }, (_, index) => ({
|
||||
_id: `downloadMetricDedupes:${index}`,
|
||||
}));
|
||||
const { db, delete_ } = makeDb({}, { downloadMetricDedupes: rows });
|
||||
const runAfter = vi.fn();
|
||||
|
||||
const result = await pruneDownloadMetricDedupesHandler({ db, scheduler: { runAfter } }, {});
|
||||
|
||||
expect(result).toEqual({ deleted: 200, hasMore: true });
|
||||
expect(delete_).toHaveBeenCalledTimes(200);
|
||||
expect(runAfter).toHaveBeenCalledWith(0, expect.anything(), {});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import { internalMutation } from "./functions";
|
||||
import { getClientIp } from "./lib/httpRateLimit";
|
||||
import { hashToken } from "./lib/tokens";
|
||||
import { insertStatEvent } from "./skillStatEvents";
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
const DEDUPE_RETENTION_MS = 14 * DAY_MS;
|
||||
const PRUNE_BATCH_SIZE = 200;
|
||||
|
||||
const identityKindValidator = v.union(v.literal("user"), v.literal("ip"));
|
||||
|
||||
const targetValidator = v.union(
|
||||
v.object({ kind: v.literal("skill"), id: v.id("skills") }),
|
||||
v.object({ kind: v.literal("package"), id: v.id("packages") }),
|
||||
);
|
||||
|
||||
type DownloadIdentityKind = "user" | "ip";
|
||||
|
||||
type DownloadIdentity = {
|
||||
identityKind: DownloadIdentityKind;
|
||||
identityValue: string;
|
||||
};
|
||||
|
||||
export function getDownloadIdentity(
|
||||
request: Request,
|
||||
userId: string | null,
|
||||
): DownloadIdentity | null {
|
||||
if (userId) return { identityKind: "user", identityValue: userId };
|
||||
const ip = getClientIp(request);
|
||||
if (!ip) return null;
|
||||
return { identityKind: "ip", identityValue: ip };
|
||||
}
|
||||
|
||||
export async function buildDownloadMetricArgs(params: {
|
||||
target: { kind: "skill"; id: Id<"skills"> } | { kind: "package"; id: Id<"packages"> };
|
||||
identity: DownloadIdentity;
|
||||
now: number;
|
||||
}) {
|
||||
return {
|
||||
target: params.target,
|
||||
identityKind: params.identity.identityKind,
|
||||
identityHash: await hashToken(
|
||||
`${params.identity.identityKind}:${params.identity.identityValue}`,
|
||||
),
|
||||
dayStart: getDayStart(params.now),
|
||||
occurredAt: params.now,
|
||||
};
|
||||
}
|
||||
|
||||
export const recordDownloadMetricInternal = internalMutation({
|
||||
args: {
|
||||
target: targetValidator,
|
||||
identityKind: identityKindValidator,
|
||||
identityHash: v.string(),
|
||||
dayStart: v.number(),
|
||||
occurredAt: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const targetId = args.target.id;
|
||||
const existing = await ctx.db
|
||||
.query("downloadMetricDedupes")
|
||||
.withIndex("by_target_identity_day", (q) =>
|
||||
q
|
||||
.eq("targetKind", args.target.kind)
|
||||
.eq("targetId", targetId)
|
||||
.eq("identityKind", args.identityKind)
|
||||
.eq("identityHash", args.identityHash)
|
||||
.eq("dayStart", args.dayStart),
|
||||
)
|
||||
.unique();
|
||||
if (existing) return;
|
||||
|
||||
const now = Date.now();
|
||||
await ctx.db.insert("downloadMetricDedupes", {
|
||||
targetKind: args.target.kind,
|
||||
targetId,
|
||||
identityKind: args.identityKind,
|
||||
identityHash: args.identityHash,
|
||||
dayStart: args.dayStart,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
if (args.target.kind === "skill") {
|
||||
await insertStatEvent(ctx, {
|
||||
skillId: args.target.id,
|
||||
kind: "download",
|
||||
occurredAt: args.occurredAt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.db.insert("packageStatEvents", {
|
||||
packageId: args.target.id,
|
||||
kind: "download",
|
||||
occurredAt: args.occurredAt ?? now,
|
||||
processedAt: undefined,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const pruneDownloadMetricDedupesInternal = internalMutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const cutoffDayStart = getDayStart(Date.now() - DEDUPE_RETENTION_MS);
|
||||
const stale = await ctx.db
|
||||
.query("downloadMetricDedupes")
|
||||
.withIndex("by_day", (q) => q.lt("dayStart", cutoffDayStart))
|
||||
.take(PRUNE_BATCH_SIZE);
|
||||
|
||||
for (const entry of stale) {
|
||||
await ctx.db.delete(entry._id);
|
||||
}
|
||||
|
||||
const hasMore = stale.length === PRUNE_BATCH_SIZE;
|
||||
if (hasMore) {
|
||||
await ctx.scheduler.runAfter(
|
||||
0,
|
||||
internal.downloadMetrics.pruneDownloadMetricDedupesInternal,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
return { deleted: stale.length, hasMore };
|
||||
},
|
||||
});
|
||||
|
||||
function getDayStart(timestamp: number) {
|
||||
return Math.floor(timestamp / DAY_MS) * DAY_MS;
|
||||
}
|
||||
|
||||
export const __test = {
|
||||
getDayStart,
|
||||
getDownloadIdentity,
|
||||
};
|
||||
+150
-14
@@ -21,6 +21,19 @@ const okRate = () => ({
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
function stubZipResponse() {
|
||||
class MockResponse {
|
||||
status: number;
|
||||
headers: Headers;
|
||||
|
||||
constructor(_body?: BodyInit | null, init?: ResponseInit) {
|
||||
this.status = init?.status ?? 200;
|
||||
this.headers = new Headers(init?.headers);
|
||||
}
|
||||
}
|
||||
vi.stubGlobal("Response", MockResponse as unknown as typeof Response);
|
||||
}
|
||||
|
||||
describe("downloads helpers", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
@@ -63,16 +76,7 @@ describe("downloads helpers", () => {
|
||||
});
|
||||
|
||||
it("schedules zip download stats outside the response path", async () => {
|
||||
class MockResponse {
|
||||
status: number;
|
||||
headers: Headers;
|
||||
|
||||
constructor(_body?: BodyInit | null, init?: ResponseInit) {
|
||||
this.status = init?.status ?? 200;
|
||||
this.headers = new Headers(init?.headers);
|
||||
}
|
||||
}
|
||||
vi.stubGlobal("Response", MockResponse as unknown as typeof Response);
|
||||
stubZipResponse();
|
||||
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
@@ -127,9 +131,10 @@ describe("downloads helpers", () => {
|
||||
if (!args || typeof args !== "object") return false;
|
||||
const value = args as Record<string, unknown>;
|
||||
return (
|
||||
value.skillId === "skills:1" &&
|
||||
typeof value.target === "object" &&
|
||||
typeof value.identityHash === "string" &&
|
||||
typeof value.hourStart === "number"
|
||||
value.identityKind === "ip" &&
|
||||
typeof value.dayStart === "number"
|
||||
);
|
||||
});
|
||||
expect(recordCalls).toHaveLength(1);
|
||||
@@ -137,9 +142,11 @@ describe("downloads helpers", () => {
|
||||
expect(recordCalls[0]?.[0]).toBeGreaterThanOrEqual(0);
|
||||
expect(recordCalls[0]?.[0]).toBeLessThan(60_000);
|
||||
expect(recordCalls[0]?.[2]).toEqual({
|
||||
skillId: "skills:1",
|
||||
target: { kind: "skill", id: "skills:1" },
|
||||
identityKind: "ip",
|
||||
identityHash: expect.any(String),
|
||||
hourStart: expect.any(Number),
|
||||
dayStart: expect.any(Number),
|
||||
occurredAt: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -202,4 +209,133 @@ describe("downloads helpers", () => {
|
||||
expect(await response.text()).toBe("Version not found");
|
||||
expect(storageGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses API token user identity for zip download stats when present", async () => {
|
||||
stubZipResponse();
|
||||
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
if ("tokenHash" in args) {
|
||||
return { _id: "apiTokens:1", revokedAt: undefined };
|
||||
}
|
||||
if ("tokenId" in args) {
|
||||
return { _id: "users:token", deletedAt: undefined, deactivatedAt: undefined };
|
||||
}
|
||||
if ("slug" in args) {
|
||||
return {
|
||||
skill: {
|
||||
_id: "skills:1",
|
||||
ownerUserId: "users:1",
|
||||
slug: "demo",
|
||||
tags: {},
|
||||
latestVersionId: "skillVersions:1",
|
||||
},
|
||||
moderationInfo: null,
|
||||
};
|
||||
}
|
||||
if ("versionId" in args) {
|
||||
return {
|
||||
_id: "skillVersions:1",
|
||||
skillId: "skills:1",
|
||||
version: "1.0.0",
|
||||
createdAt: 3,
|
||||
files: [{ path: "SKILL.md", storageId: "_storage:1" }],
|
||||
softDeletedAt: undefined,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
return { tokenTouched: "tokenId" in args };
|
||||
});
|
||||
const runAfter = vi.fn();
|
||||
const storageGet = vi.fn().mockResolvedValue(new Blob(["hello"], { type: "text/markdown" }));
|
||||
|
||||
const response = await downloadZipHandler(
|
||||
{
|
||||
runQuery,
|
||||
runMutation,
|
||||
scheduler: { runAfter },
|
||||
storage: { get: storageGet },
|
||||
} as unknown as ActionCtx,
|
||||
new Request("https://example.com/api/v1/download?slug=demo", {
|
||||
headers: {
|
||||
authorization: "Bearer clh_test",
|
||||
"cf-connecting-ip": "1.2.3.4",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(runAfter).toHaveBeenCalledWith(
|
||||
expect.any(Number),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
target: { kind: "skill", id: "skills:1" },
|
||||
identityKind: "user",
|
||||
identityHash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns zip downloads when download metering is scheduled", async () => {
|
||||
stubZipResponse();
|
||||
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
if ("slug" in args) {
|
||||
return {
|
||||
skill: {
|
||||
_id: "skills:1",
|
||||
ownerUserId: "users:1",
|
||||
slug: "demo",
|
||||
tags: {},
|
||||
latestVersionId: "skillVersions:1",
|
||||
},
|
||||
moderationInfo: null,
|
||||
};
|
||||
}
|
||||
if ("versionId" in args) {
|
||||
return {
|
||||
_id: "skillVersions:1",
|
||||
skillId: "skills:1",
|
||||
version: "1.0.0",
|
||||
createdAt: 3,
|
||||
files: [{ path: "SKILL.md", storageId: "_storage:1" }],
|
||||
softDeletedAt: undefined,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
return { mutationRecorded: true };
|
||||
});
|
||||
const runAfter = vi.fn();
|
||||
const storageGet = vi.fn().mockResolvedValue(new Blob(["hello"], { type: "text/markdown" }));
|
||||
|
||||
const response = await downloadZipHandler(
|
||||
{
|
||||
runQuery,
|
||||
runMutation,
|
||||
scheduler: { runAfter },
|
||||
storage: { get: storageGet },
|
||||
} as unknown as ActionCtx,
|
||||
new Request("https://example.com/api/v1/download?slug=demo", {
|
||||
headers: { "cf-connecting-ip": "1.2.3.4" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(runAfter).toHaveBeenCalledWith(
|
||||
expect.any(Number),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
target: { kind: "skill", id: "skills:1" },
|
||||
identityKind: "ip",
|
||||
identityHash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+20
-9
@@ -1,12 +1,14 @@
|
||||
import { v } from "convex/values";
|
||||
import { api, internal } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import { buildDownloadMetricArgs, getDownloadIdentity } from "./downloadMetrics";
|
||||
import { httpAction, internalMutation } from "./functions";
|
||||
import { getOptionalActiveAuthUserIdFromAction } from "./lib/access";
|
||||
import { getOptionalApiTokenUserId } from "./lib/apiTokenAuth";
|
||||
import { corsHeaders, mergeHeaders } from "./lib/httpHeaders";
|
||||
import { applyRateLimit, getClientIp } from "./lib/httpRateLimit";
|
||||
import { getPublicSkillFileAccessBlock, isSkillVersionForSkill } from "./lib/skillFileAccess";
|
||||
import { buildDeterministicZip } from "./lib/skillZip";
|
||||
import { hashToken } from "./lib/tokens";
|
||||
import { insertStatEvent } from "./skillStatEvents";
|
||||
|
||||
const HOUR_MS = 3_600_000;
|
||||
@@ -98,17 +100,17 @@ export async function downloadZipHandler(
|
||||
const zipBlob = new Blob([zipArray], { type: "application/zip" });
|
||||
|
||||
try {
|
||||
const userId = await getOptionalApiTokenUserId(ctx, request);
|
||||
const identity = getDownloadIdentityValue(request, userId ? String(userId) : null);
|
||||
const userId = await getOptionalDownloadUserId(ctx, request);
|
||||
const identity = getDownloadIdentity(request, userId ? String(userId) : null);
|
||||
if (identity) {
|
||||
await ctx.scheduler.runAfter(
|
||||
Math.floor(Math.random() * DOWNLOAD_STAT_JITTER_MS),
|
||||
internal.downloads.recordDownloadInternal,
|
||||
{
|
||||
skillId: skill._id,
|
||||
identityHash: await hashToken(identity),
|
||||
hourStart: getHourStart(Date.now()),
|
||||
},
|
||||
internal.downloadMetrics.recordDownloadMetricInternal,
|
||||
await buildDownloadMetricArgs({
|
||||
target: { kind: "skill", id: skill._id },
|
||||
identity,
|
||||
now: Date.now(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
@@ -196,6 +198,15 @@ export function getDownloadIdentityValue(request: Request, userId: string | null
|
||||
return `ip:${ip}`;
|
||||
}
|
||||
|
||||
async function getOptionalDownloadUserId(
|
||||
ctx: Parameters<Parameters<typeof httpAction>[0]>[0],
|
||||
request: Request,
|
||||
): Promise<Id<"users"> | null> {
|
||||
const apiTokenUserId = await getOptionalApiTokenUserId(ctx, request);
|
||||
if (apiTokenUserId) return apiTokenUserId;
|
||||
return (await getOptionalActiveAuthUserIdFromAction(ctx)) ?? null;
|
||||
}
|
||||
|
||||
export const __test = {
|
||||
getHourStart,
|
||||
getDownloadIdentityValue,
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { ConvexError } from "convex/values";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./lib/access", () => ({
|
||||
requireUser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./lib/publishers", async () => {
|
||||
const actual = await vi.importActual<typeof import("./lib/publishers")>("./lib/publishers");
|
||||
return {
|
||||
...actual,
|
||||
requirePublisherRole: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const { requireUser } = await import("./lib/access");
|
||||
const { requirePublisherRole } = await import("./lib/publishers");
|
||||
const { deleteForPublisherHandler } = await import("./githubSkillSources");
|
||||
const { buildSkillInstallResolution } = await import("./lib/installResolver");
|
||||
|
||||
type Row = Record<string, unknown> & { _id: string };
|
||||
|
||||
function chainEq(constraints: Record<string, unknown>) {
|
||||
return {
|
||||
eq(field: string, value: unknown) {
|
||||
constraints[field] = value;
|
||||
return chainEq(constraints);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function matches(doc: Row, constraints: Record<string, unknown>) {
|
||||
return Object.entries(constraints).every(([key, value]) => doc[key] === value);
|
||||
}
|
||||
|
||||
function createDb(initial: Record<string, Row[]> = {}) {
|
||||
const tables: Record<string, Row[]> = Object.fromEntries(
|
||||
Object.entries(initial).map(([table, rows]) => [table, [...rows]]),
|
||||
);
|
||||
const list = (table: string) => {
|
||||
tables[table] ??= [];
|
||||
return tables[table];
|
||||
};
|
||||
|
||||
const db = {
|
||||
get: async (id: string) => {
|
||||
const table = id.split(":")[0] ?? "";
|
||||
return list(table).find((row) => row._id === id) ?? null;
|
||||
},
|
||||
patch: async (id: string, patch: Record<string, unknown>) => {
|
||||
const table = id.split(":")[0] ?? "";
|
||||
const row = list(table).find((candidate) => candidate._id === id);
|
||||
if (!row) return;
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === undefined) delete row[key];
|
||||
else row[key] = value;
|
||||
}
|
||||
},
|
||||
insert: async (table: string, doc: Record<string, unknown>) => {
|
||||
const id = `${table}:${list(table).length + 1}`;
|
||||
list(table).push({ _id: id, ...doc });
|
||||
return id;
|
||||
},
|
||||
delete: async (id: string) => {
|
||||
const table = id.split(":")[0] ?? "";
|
||||
const rows = list(table);
|
||||
const index = rows.findIndex((row) => row._id === id);
|
||||
if (index >= 0) rows.splice(index, 1);
|
||||
},
|
||||
query: (table: string) => ({
|
||||
withIndex: (_indexName: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
|
||||
const constraints: Record<string, unknown> = {};
|
||||
build(chainEq(constraints));
|
||||
const matched = () => list(table).filter((row) => matches(row, constraints));
|
||||
return {
|
||||
collect: async () => matched(),
|
||||
unique: async () => matched()[0] ?? null,
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
return { db, tables };
|
||||
}
|
||||
|
||||
describe("githubSkillSources.deleteForPublisherHandler", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(requireUser).mockResolvedValue({ userId: "users:owner" } as never);
|
||||
vi.mocked(requirePublisherRole).mockResolvedValue(undefined as never);
|
||||
});
|
||||
|
||||
it("deletes a source and removes only GitHub-backed skills from that source", async () => {
|
||||
const { db, tables } = createDb({
|
||||
githubSkillSources: [
|
||||
{
|
||||
_id: "githubSkillSources:matt",
|
||||
repo: "mattpocock/skills",
|
||||
ownerPublisherId: "publishers:openclaw",
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
],
|
||||
githubSkillContents: [
|
||||
{
|
||||
_id: "githubSkillContents:one",
|
||||
skillId: "skills:github",
|
||||
githubSourceId: "githubSkillSources:matt",
|
||||
},
|
||||
],
|
||||
skills: [
|
||||
{
|
||||
_id: "skills:github",
|
||||
slug: "source-backed",
|
||||
displayName: "Source Backed",
|
||||
installKind: "github",
|
||||
githubSourceId: "githubSkillSources:matt",
|
||||
githubPath: "skills/source-backed",
|
||||
githubCurrentCommit: "a".repeat(40),
|
||||
githubCurrentContentHash: "hash-source-backed",
|
||||
githubCurrentStatus: "present",
|
||||
githubScanStatus: "clean",
|
||||
ownerPublisherId: "publishers:openclaw",
|
||||
softDeletedAt: undefined,
|
||||
},
|
||||
{
|
||||
_id: "skills:direct",
|
||||
slug: "direct-upload",
|
||||
displayName: "Direct Upload",
|
||||
ownerPublisherId: "publishers:openclaw",
|
||||
softDeletedAt: undefined,
|
||||
},
|
||||
{
|
||||
_id: "skills:other-source",
|
||||
slug: "other-source",
|
||||
displayName: "Other Source",
|
||||
installKind: "github",
|
||||
githubSourceId: "githubSkillSources:other",
|
||||
githubPath: "skills/other-source",
|
||||
githubCurrentCommit: "b".repeat(40),
|
||||
githubCurrentContentHash: "hash-other-source",
|
||||
githubCurrentStatus: "present",
|
||||
githubScanStatus: "clean",
|
||||
ownerPublisherId: "publishers:openclaw",
|
||||
softDeletedAt: undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
deleteForPublisherHandler({ db } as never, {
|
||||
ownerPublisherId: "publishers:openclaw" as never,
|
||||
sourceId: "githubSkillSources:matt" as never,
|
||||
now: 123,
|
||||
}),
|
||||
).resolves.toEqual({ ok: true, deletedSkills: 1 });
|
||||
|
||||
expect(requirePublisherRole).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
publisherId: "publishers:openclaw",
|
||||
userId: "users:owner",
|
||||
allowed: ["admin"],
|
||||
}),
|
||||
);
|
||||
expect(tables.githubSkillSources).toHaveLength(0);
|
||||
expect(tables.githubSkillContents).toHaveLength(0);
|
||||
const deletedSkill = tables.skills.find((skill) => skill._id === "skills:github");
|
||||
expect(deletedSkill).toMatchObject({
|
||||
softDeletedAt: 123,
|
||||
githubRemovedAt: 123,
|
||||
githubCurrentStatus: "missing",
|
||||
updatedAt: 123,
|
||||
});
|
||||
expect(
|
||||
buildSkillInstallResolution({
|
||||
origin: "https://clawhub.ai",
|
||||
skill: deletedSkill as never,
|
||||
source: null,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
reason: "github_upstream_removed",
|
||||
status: 410,
|
||||
});
|
||||
expect(tables.skills.find((skill) => skill._id === "skills:direct")).toMatchObject({
|
||||
softDeletedAt: undefined,
|
||||
});
|
||||
expect(tables.skills.find((skill) => skill._id === "skills:other-source")).toMatchObject({
|
||||
githubCurrentStatus: "present",
|
||||
softDeletedAt: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects deleting a source from another publisher", async () => {
|
||||
const { db } = createDb({
|
||||
githubSkillSources: [
|
||||
{
|
||||
_id: "githubSkillSources:matt",
|
||||
repo: "mattpocock/skills",
|
||||
ownerPublisherId: "publishers:other",
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
deleteForPublisherHandler({ db } as never, {
|
||||
ownerPublisherId: "publishers:openclaw" as never,
|
||||
sourceId: "githubSkillSources:matt" as never,
|
||||
now: 123,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ConvexError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
||||
import { internalQuery, mutation, query } from "./functions";
|
||||
import { requireUser } from "./lib/access";
|
||||
import { adjustGlobalPublicSkillsCount, getPublicSkillVisibilityDelta } from "./lib/globalStats";
|
||||
import { isOfficialPublisher } from "./lib/officialPublishers";
|
||||
import { isPublisherActive, isPublisherRoleAllowed, requirePublisherRole } from "./lib/publishers";
|
||||
|
||||
type PublicGitHubSkillSource = Pick<
|
||||
Doc<"githubSkillSources">,
|
||||
| "_id"
|
||||
| "repo"
|
||||
| "defaultBranch"
|
||||
| "lastSyncStatus"
|
||||
| "lastSyncError"
|
||||
| "lastSyncErrorAt"
|
||||
| "displayManifestStatus"
|
||||
| "displayManifestFetchedAt"
|
||||
| "displayManifestCommit"
|
||||
| "lastSyncIssues"
|
||||
| "lastSyncInvalidSkills"
|
||||
| "createdAt"
|
||||
| "updatedAt"
|
||||
> & {
|
||||
ownerPublisher: Pick<Doc<"publishers">, "_id" | "handle" | "displayName"> | null;
|
||||
skills: Array<
|
||||
Pick<Doc<"skills">, "_id" | "slug" | "displayName" | "githubPath" | "githubCurrentStatus">
|
||||
>;
|
||||
};
|
||||
|
||||
export const getByIdInternal = internalQuery({
|
||||
args: { sourceId: v.id("githubSkillSources") },
|
||||
handler: async (ctx, args) => ctx.db.get(args.sourceId),
|
||||
});
|
||||
|
||||
async function toPublicGitHubSkillSource(
|
||||
ctx: Pick<QueryCtx, "db">,
|
||||
source: Doc<"githubSkillSources">,
|
||||
): Promise<PublicGitHubSkillSource> {
|
||||
const skills = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_github_source", (q) => q.eq("githubSourceId", source._id))
|
||||
.collect();
|
||||
const visibleGitHubSkills = skills
|
||||
.filter((skill) => skill.installKind === "github" && !skill.softDeletedAt)
|
||||
.sort((a, b) => a.displayName.localeCompare(b.displayName))
|
||||
.map((skill) => ({
|
||||
_id: skill._id,
|
||||
slug: skill.slug,
|
||||
displayName: skill.displayName,
|
||||
githubPath: skill.githubPath,
|
||||
githubCurrentStatus: skill.githubCurrentStatus,
|
||||
}));
|
||||
const ownerPublisher = source.ownerPublisherId ? await ctx.db.get(source.ownerPublisherId) : null;
|
||||
|
||||
return {
|
||||
_id: source._id as Id<"githubSkillSources">,
|
||||
repo: source.repo,
|
||||
ownerPublisher: ownerPublisher
|
||||
? {
|
||||
_id: ownerPublisher._id,
|
||||
handle: ownerPublisher.handle,
|
||||
displayName: ownerPublisher.displayName,
|
||||
}
|
||||
: null,
|
||||
defaultBranch: source.defaultBranch,
|
||||
lastSyncStatus: source.lastSyncStatus,
|
||||
lastSyncError: source.lastSyncError,
|
||||
lastSyncErrorAt: source.lastSyncErrorAt,
|
||||
displayManifestStatus: source.displayManifestStatus,
|
||||
displayManifestFetchedAt: source.displayManifestFetchedAt,
|
||||
displayManifestCommit: source.displayManifestCommit,
|
||||
lastSyncIssues: source.lastSyncIssues,
|
||||
lastSyncInvalidSkills: source.lastSyncInvalidSkills,
|
||||
createdAt: source.createdAt,
|
||||
updatedAt: source.updatedAt,
|
||||
skills: visibleGitHubSkills,
|
||||
};
|
||||
}
|
||||
|
||||
export const listForPublisher = query({
|
||||
args: { ownerPublisherId: v.id("publishers") },
|
||||
handler: async (ctx, args): Promise<PublicGitHubSkillSource[]> => {
|
||||
const { userId } = await requireUser(ctx);
|
||||
await requirePublisherRole(ctx, {
|
||||
publisherId: args.ownerPublisherId,
|
||||
userId,
|
||||
allowed: ["admin"],
|
||||
});
|
||||
const sources = await ctx.db
|
||||
.query("githubSkillSources")
|
||||
.withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", args.ownerPublisherId))
|
||||
.collect();
|
||||
const sortedSources = sources.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
return await Promise.all(sortedSources.map((source) => toPublicGitHubSkillSource(ctx, source)));
|
||||
},
|
||||
});
|
||||
|
||||
export const listForManageableOfficialPublishers = query({
|
||||
args: {},
|
||||
handler: async (ctx): Promise<PublicGitHubSkillSource[]> => {
|
||||
const { userId } = await requireUser(ctx);
|
||||
const memberships = await ctx.db
|
||||
.query("publisherMembers")
|
||||
.withIndex("by_user", (q) => q.eq("userId", userId))
|
||||
.collect();
|
||||
const ownerPublisherIds: Id<"publishers">[] = [];
|
||||
for (const membership of memberships) {
|
||||
if (!isPublisherRoleAllowed(membership.role, ["admin"])) continue;
|
||||
const publisher = await ctx.db.get(membership.publisherId);
|
||||
if (
|
||||
!publisher ||
|
||||
publisher.kind !== "org" ||
|
||||
!isPublisherActive(publisher) ||
|
||||
!(await isOfficialPublisher(ctx, publisher))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
ownerPublisherIds.push(publisher._id);
|
||||
}
|
||||
const sourceGroups = await Promise.all(
|
||||
ownerPublisherIds.map((ownerPublisherId) =>
|
||||
ctx.db
|
||||
.query("githubSkillSources")
|
||||
.withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", ownerPublisherId))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
const sortedSources = sourceGroups.flat().sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
return await Promise.all(sortedSources.map((source) => toPublicGitHubSkillSource(ctx, source)));
|
||||
},
|
||||
});
|
||||
|
||||
export async function deleteForPublisherHandler(
|
||||
ctx: MutationCtx,
|
||||
args: {
|
||||
ownerPublisherId: Id<"publishers">;
|
||||
sourceId: Id<"githubSkillSources">;
|
||||
now?: number;
|
||||
},
|
||||
) {
|
||||
const { userId } = await requireUser(ctx);
|
||||
await requirePublisherRole(ctx, {
|
||||
publisherId: args.ownerPublisherId,
|
||||
userId,
|
||||
allowed: ["admin"],
|
||||
});
|
||||
|
||||
const source = await ctx.db.get(args.sourceId);
|
||||
if (!source || source.ownerPublisherId !== args.ownerPublisherId) {
|
||||
throw new ConvexError("GitHub source not found.");
|
||||
}
|
||||
|
||||
const now = args.now ?? Date.now();
|
||||
const contents = await ctx.db
|
||||
.query("githubSkillContents")
|
||||
.withIndex("by_github_source", (q) => q.eq("githubSourceId", args.sourceId))
|
||||
.collect();
|
||||
for (const content of contents) {
|
||||
await ctx.db.delete(content._id);
|
||||
}
|
||||
|
||||
const skills = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_github_source", (q) => q.eq("githubSourceId", args.sourceId))
|
||||
.collect();
|
||||
let deletedSkills = 0;
|
||||
let publicSkillDelta = 0;
|
||||
for (const skill of skills) {
|
||||
if (skill.installKind !== "github") continue;
|
||||
|
||||
const nextSkill: Doc<"skills"> = {
|
||||
...skill,
|
||||
softDeletedAt: skill.softDeletedAt ?? now,
|
||||
githubCurrentStatus: "missing",
|
||||
githubRemovedAt: skill.githubRemovedAt ?? now,
|
||||
updatedAt: now,
|
||||
};
|
||||
publicSkillDelta += getPublicSkillVisibilityDelta(skill, nextSkill);
|
||||
await ctx.db.patch(skill._id, {
|
||||
softDeletedAt: nextSkill.softDeletedAt,
|
||||
githubCurrentStatus: nextSkill.githubCurrentStatus,
|
||||
githubRemovedAt: nextSkill.githubRemovedAt,
|
||||
updatedAt: now,
|
||||
});
|
||||
deletedSkills += 1;
|
||||
}
|
||||
|
||||
if (publicSkillDelta !== 0) {
|
||||
await adjustGlobalPublicSkillsCount(ctx, publicSkillDelta, now);
|
||||
}
|
||||
await ctx.db.delete(args.sourceId);
|
||||
|
||||
return { ok: true as const, deletedSkills };
|
||||
}
|
||||
|
||||
export const deleteForPublisher: ReturnType<typeof mutation> = mutation({
|
||||
args: {
|
||||
ownerPublisherId: v.id("publishers"),
|
||||
sourceId: v.id("githubSkillSources"),
|
||||
},
|
||||
handler: async (ctx, args) => deleteForPublisherHandler(ctx, args),
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
/* @vitest-environment node */
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyGitHubSkillSourceSyncHandler,
|
||||
applyGitHubSkillVerificationResultHandler,
|
||||
configurePublicGitHubSkillSourceHandler,
|
||||
upsertGitHubSkillContentHandler,
|
||||
verifyGitHubSkillHandler,
|
||||
} from "./githubSkillSync";
|
||||
import { buildSkillInstallResolution } from "./lib/installResolver";
|
||||
|
||||
type Row = Record<string, unknown> & { _id: string };
|
||||
|
||||
function chainEq(constraints: Record<string, unknown>) {
|
||||
return {
|
||||
eq(field: string, value: unknown) {
|
||||
constraints[field] = value;
|
||||
return chainEq(constraints);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function matches(doc: Row, constraints: Record<string, unknown>) {
|
||||
return Object.entries(constraints).every(([key, value]) => doc[key] === value);
|
||||
}
|
||||
|
||||
function createDb(initial: Record<string, Row[]> = {}) {
|
||||
const tables: Record<string, Row[]> = Object.fromEntries(
|
||||
Object.entries(initial).map(([table, rows]) => [table, [...rows]]),
|
||||
);
|
||||
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((row) => row._id === id) ?? null;
|
||||
},
|
||||
insert: async (table: string, doc: Record<string, unknown>) => {
|
||||
counters[table] = (counters[table] ?? 0) + 1;
|
||||
const inserted = {
|
||||
_id: `${table}:new-${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 row = list(table).find((candidate) => candidate._id === id);
|
||||
if (!row) return;
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === undefined) delete row[key];
|
||||
else row[key] = value;
|
||||
}
|
||||
},
|
||||
query: (table: string) => ({
|
||||
withIndex: (_indexName: string, build?: (q: ReturnType<typeof chainEq>) => unknown) => {
|
||||
const constraints: Record<string, unknown> = {};
|
||||
build?.(chainEq(constraints));
|
||||
const matched = () => list(table).filter((row) => matches(row, constraints));
|
||||
return {
|
||||
collect: async () => matched(),
|
||||
unique: async () => matched()[0] ?? null,
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
return { db, tables };
|
||||
}
|
||||
|
||||
function getSkill(tables: Record<string, Row[]>, slug: string) {
|
||||
const skill = tables.skills?.find((row) => row.slug === slug);
|
||||
if (!skill) throw new Error(`Live GitHub canary did not discover skill: ${slug}`);
|
||||
return skill;
|
||||
}
|
||||
|
||||
function resolveInstallFromTables(tables: Record<string, Row[]>, slug: string) {
|
||||
const skill = getSkill(tables, slug);
|
||||
const source =
|
||||
typeof skill.githubSourceId === "string"
|
||||
? (tables.githubSkillSources?.find((row) => row._id === skill.githubSourceId) ?? null)
|
||||
: null;
|
||||
return buildSkillInstallResolution({
|
||||
origin: "https://clawhub.ai",
|
||||
skill: skill as never,
|
||||
source: source as never,
|
||||
});
|
||||
}
|
||||
|
||||
const liveCanaryEnabled = process.env.CLAWHUB_LIVE_GITHUB_CANARY === "1";
|
||||
const itIfLive = liveCanaryEnabled ? it : it.skip;
|
||||
|
||||
describe("GitHub-backed skills live canary", () => {
|
||||
itIfLive(
|
||||
"discovers and verifies an installable skill from a real GitHub repo",
|
||||
{ timeout: 45_000 },
|
||||
async () => {
|
||||
const repo = process.env.CLAWHUB_LIVE_GITHUB_REPO?.trim() || "openclaw/agent-skills";
|
||||
const skillSlug = process.env.CLAWHUB_LIVE_GITHUB_SKILL?.trim() || "handoff";
|
||||
const { db, tables } = createDb({
|
||||
globalStats: [
|
||||
{
|
||||
_id: "globalStats:default",
|
||||
key: "default",
|
||||
activeSkillsCount: 0,
|
||||
updatedAt: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
const scheduler = { runAfter: async () => undefined };
|
||||
let now = Date.now();
|
||||
const actionCtx = {
|
||||
runQuery: async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("ownerPublisherId" in args && "actorUserId" in args) {
|
||||
return {
|
||||
ownerUserId: "users:live-owner",
|
||||
existingSource:
|
||||
tables.githubSkillSources?.find((source) => source.repo === repo) ?? null,
|
||||
official: true,
|
||||
};
|
||||
}
|
||||
if ("skillId" in args) {
|
||||
const skill = tables.skills?.find((row) => row._id === args.skillId);
|
||||
const source =
|
||||
skill && typeof skill.githubSourceId === "string"
|
||||
? tables.githubSkillSources?.find((row) => row._id === skill.githubSourceId)
|
||||
: null;
|
||||
return skill && source ? { skill, source } : null;
|
||||
}
|
||||
if ("sourceId" in args) {
|
||||
return (tables.skills ?? []).flatMap((skill) => {
|
||||
if (
|
||||
skill.githubSourceId !== args.sourceId ||
|
||||
skill.installKind !== "github" ||
|
||||
skill.githubCurrentStatus !== "present" ||
|
||||
typeof skill.githubPath !== "string" ||
|
||||
typeof skill.githubCurrentContentHash !== "string"
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
skillId: skill._id,
|
||||
githubPath: skill.githubPath,
|
||||
githubCurrentContentHash: skill.githubCurrentContentHash,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected live canary query args: ${JSON.stringify(args)}`);
|
||||
},
|
||||
runMutation: async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if ("snapshot" in args) {
|
||||
return await applyGitHubSkillSourceSyncHandler(
|
||||
{ db, scheduler } as never,
|
||||
{
|
||||
...args,
|
||||
now,
|
||||
} as never,
|
||||
);
|
||||
}
|
||||
if ("scanStatus" in args && "contentHash" in args) {
|
||||
return await applyGitHubSkillVerificationResultHandler(
|
||||
{ db } as never,
|
||||
{
|
||||
...args,
|
||||
now,
|
||||
} as never,
|
||||
);
|
||||
}
|
||||
if ("discovered" in args && "commit" in args) {
|
||||
return await upsertGitHubSkillContentHandler(
|
||||
{ db } as never,
|
||||
{
|
||||
...args,
|
||||
now,
|
||||
} as never,
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected live canary mutation args: ${JSON.stringify(args)}`);
|
||||
},
|
||||
auth: { getUserIdentity: async () => null },
|
||||
};
|
||||
|
||||
const configured = await configurePublicGitHubSkillSourceHandler(
|
||||
actionCtx as never,
|
||||
{
|
||||
ownerPublisherId: "publishers:live" as never,
|
||||
repo,
|
||||
},
|
||||
fetch,
|
||||
{ userId: "users:live-owner" as never },
|
||||
);
|
||||
|
||||
expect(configured.stats.discovered).toBeGreaterThan(0);
|
||||
expect(configured.manifestStatus === "missing" || configured.manifestStatus === "ok").toBe(
|
||||
true,
|
||||
);
|
||||
expect(configured.commit).toMatch(/^[a-f0-9]{40}$/);
|
||||
|
||||
let skill = getSkill(tables, skillSlug);
|
||||
expect(skill).toMatchObject({
|
||||
installKind: "github",
|
||||
githubPath: `skills/${skillSlug}`,
|
||||
githubCurrentCommit: configured.commit,
|
||||
githubCurrentStatus: "present",
|
||||
githubScanStatus: "pending",
|
||||
});
|
||||
expect(resolveInstallFromTables(tables, skillSlug)).toMatchObject({
|
||||
ok: false,
|
||||
reason: "github_verification_pending",
|
||||
});
|
||||
|
||||
now = Date.now();
|
||||
const verified = await verifyGitHubSkillHandler(
|
||||
actionCtx as never,
|
||||
{
|
||||
skillId: skill._id as never,
|
||||
contentHash: skill.githubCurrentContentHash as string,
|
||||
},
|
||||
fetch,
|
||||
);
|
||||
|
||||
expect(verified).toMatchObject({ ok: true, scanStatus: "clean" });
|
||||
skill = getSkill(tables, skillSlug);
|
||||
expect(skill).toMatchObject({
|
||||
githubCurrentCommit: configured.commit,
|
||||
githubScanStatus: "clean",
|
||||
moderationStatus: "active",
|
||||
});
|
||||
expect(resolveInstallFromTables(tables, skillSlug)).toMatchObject({
|
||||
ok: true,
|
||||
installKind: "github",
|
||||
github: {
|
||||
repo,
|
||||
path: `skills/${skillSlug}`,
|
||||
commit: configured.commit,
|
||||
contentHash: skill.githubCurrentContentHash,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ import {
|
||||
cliDeviceTokenHttp,
|
||||
cliSkillDeleteHttp,
|
||||
cliSkillUndeleteHttp,
|
||||
cliTelemetryInstallHttp,
|
||||
cliTelemetrySyncHttp,
|
||||
cliUploadUrlHttp,
|
||||
cliWhoamiHttp,
|
||||
@@ -50,6 +51,7 @@ import {
|
||||
starsPostRouterV1Http,
|
||||
transfersGetRouterV1Http,
|
||||
banAppealContextV1Http,
|
||||
usersGetRouterV1Http,
|
||||
usersListV1Http,
|
||||
usersPostRouterV1Http,
|
||||
verifyDocsSessionV1Http,
|
||||
@@ -271,6 +273,12 @@ http.route({
|
||||
handler: banAppealContextV1Http,
|
||||
});
|
||||
|
||||
http.route({
|
||||
pathPrefix: `${ApiRoutes.users}/`,
|
||||
method: "GET",
|
||||
handler: usersGetRouterV1Http,
|
||||
});
|
||||
|
||||
http.route({
|
||||
path: ApiRoutes.users,
|
||||
method: "GET",
|
||||
@@ -355,6 +363,12 @@ http.route({
|
||||
handler: cliPublishHttp,
|
||||
});
|
||||
|
||||
http.route({
|
||||
path: LegacyApiRoutes.cliTelemetryInstall,
|
||||
method: "POST",
|
||||
handler: cliTelemetryInstallHttp,
|
||||
});
|
||||
|
||||
http.route({
|
||||
path: LegacyApiRoutes.cliTelemetrySync,
|
||||
method: "POST",
|
||||
|
||||
@@ -243,7 +243,7 @@ describe("httpApi handlers", () => {
|
||||
it("cliWhoamiHttp returns 401 on auth failure", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockRejectedValueOnce(
|
||||
new Error(
|
||||
"Unauthorized: This ClawHub account is not in good standing and cannot use API tokens. If you believe this is a mistake, contact security@openclaw.ai.",
|
||||
"Unauthorized: This ClawHub account is not in good standing and cannot use API tokens. If you believe this is a mistake, open a GitHub issue: https://github.com/openclaw/clawhub/issues/new.",
|
||||
),
|
||||
);
|
||||
const response = await __handlers.cliWhoamiHandler(
|
||||
@@ -267,12 +267,12 @@ describe("httpApi handlers", () => {
|
||||
expect(json.user.handle).toBe("p");
|
||||
});
|
||||
|
||||
it("cliTelemetrySyncHttp forwards roots and returns ok", async () => {
|
||||
it("cliTelemetryInstallHttp forwards roots and returns ok", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: "users:1" } as never);
|
||||
const runMutation = vi.fn().mockResolvedValue(null);
|
||||
const response = await __handlers.cliTelemetrySyncHandler(
|
||||
const response = await __handlers.cliTelemetryInstallHandler(
|
||||
makeCtx({ runMutation }),
|
||||
new Request("https://x/api/cli/telemetry/sync", {
|
||||
new Request("https://x/api/cli/telemetry/install", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -291,6 +291,22 @@ describe("httpApi handlers", () => {
|
||||
expect(runMutation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cliTelemetrySyncHttp remains a backwards-compatible alias", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: "users:1" } as never);
|
||||
const runMutation = vi.fn().mockResolvedValue(null);
|
||||
const response = await __handlers.cliTelemetrySyncHandler(
|
||||
makeCtx({ runMutation }),
|
||||
new Request("https://x/api/cli/telemetry/sync", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ roots: [] }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(runMutation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cliTelemetrySyncHttp returns 400 on invalid payload", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: "users:1" } as never);
|
||||
const response = await __handlers.cliTelemetrySyncHandler(
|
||||
|
||||
+5
-2
@@ -227,7 +227,7 @@ export const cliSkillUndeleteHttp = httpAction((ctx, request) =>
|
||||
cliSkillDeleteHandler(ctx, request, false),
|
||||
);
|
||||
|
||||
async function cliTelemetrySyncHandler(ctx: ActionCtx, request: Request) {
|
||||
async function cliTelemetryInstallHandler(ctx: ActionCtx, request: Request) {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
@@ -258,7 +258,9 @@ async function cliTelemetrySyncHandler(ctx: ActionCtx, request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
export const cliTelemetrySyncHttp = httpAction(cliTelemetrySyncHandler);
|
||||
const cliTelemetrySyncHandler = cliTelemetryInstallHandler;
|
||||
export const cliTelemetryInstallHttp = httpAction(cliTelemetryInstallHandler);
|
||||
export const cliTelemetrySyncHttp = httpAction(cliTelemetryInstallHandler);
|
||||
|
||||
async function cliDeviceCodeHandler(ctx: ActionCtx, request: Request) {
|
||||
if (request.method !== "POST") return text("Method not allowed", 405);
|
||||
@@ -392,6 +394,7 @@ export const __handlers = {
|
||||
cliUploadUrlHandler,
|
||||
cliPublishHandler,
|
||||
cliSkillDeleteHandler,
|
||||
cliTelemetryInstallHandler,
|
||||
cliTelemetrySyncHandler,
|
||||
cliDeviceCodeHandler,
|
||||
cliDeviceTokenHandler,
|
||||
|
||||
@@ -64,10 +64,53 @@ function hasPackageNameArgs(args: unknown): args is { name: string } {
|
||||
return typeof value.name === "string";
|
||||
}
|
||||
|
||||
function hasPackageDownloadMetricTarget(args: unknown, packageId: string) {
|
||||
if (!args || typeof args !== "object") return false;
|
||||
const value = args as Record<string, unknown>;
|
||||
const target = value.target;
|
||||
if (!target || typeof target !== "object") return false;
|
||||
const targetValue = target as Record<string, unknown>;
|
||||
return targetValue.kind === "package" && targetValue.id === packageId;
|
||||
}
|
||||
|
||||
function findRateLimitCallArgs(mock: ReturnType<typeof vi.fn>) {
|
||||
return mock.mock.calls.map(([, args]) => args).find(isRateLimitArgs);
|
||||
}
|
||||
|
||||
function makeInstallResolverRunQuery({
|
||||
skill,
|
||||
source = null,
|
||||
publicVisible = true,
|
||||
}: {
|
||||
skill: Record<string, unknown> | null;
|
||||
source?: Record<string, unknown> | null;
|
||||
publicVisible?: boolean;
|
||||
}) {
|
||||
let slugQueryCount = 0;
|
||||
return vi.fn(async (query: unknown, args: Record<string, unknown>) => {
|
||||
void query;
|
||||
if ("sourceId" in args) return source;
|
||||
if ("slug" in args) {
|
||||
slugQueryCount += 1;
|
||||
if (slugQueryCount === 1) {
|
||||
return skill;
|
||||
}
|
||||
if (slugQueryCount === 2) {
|
||||
return publicVisible && skill
|
||||
? {
|
||||
skill: {
|
||||
_id: skill._id,
|
||||
slug: skill.slug,
|
||||
displayName: skill.displayName,
|
||||
},
|
||||
}
|
||||
: null;
|
||||
}
|
||||
}
|
||||
throw new Error(`unexpected query ${JSON.stringify(args)}`);
|
||||
});
|
||||
}
|
||||
|
||||
function makeCatalogItem(
|
||||
name: string,
|
||||
options: {
|
||||
@@ -893,6 +936,130 @@ describe("httpApiV1 handlers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("users/publisher-official lists official publishers for admin", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:admin",
|
||||
user: { _id: "users:admin", role: "admin" },
|
||||
} as never);
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
return {
|
||||
ok: true,
|
||||
items: [
|
||||
{
|
||||
officialPublisherId: "officialPublishers:openclaw",
|
||||
publisherId: "publishers:openclaw",
|
||||
handle: "openclaw",
|
||||
displayName: "OpenClaw",
|
||||
kind: "org",
|
||||
active: true,
|
||||
reason: "platform-owned publisher",
|
||||
createdByUserId: "users:admin",
|
||||
createdByHandle: "patrick-erichsen-2",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
return null;
|
||||
});
|
||||
|
||||
const response = await __handlers.usersGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runAction: vi.fn(), runMutation }),
|
||||
new Request("https://example.com/api/v1/users/publisher-official", {
|
||||
headers: { Authorization: "Bearer clh_test" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
ok: true,
|
||||
items: [{ handle: "openclaw" }],
|
||||
});
|
||||
expect(runQuery).toHaveBeenCalledWith(internal.publishers.listOfficialPublishersInternal, {
|
||||
actorUserId: "users:admin",
|
||||
});
|
||||
});
|
||||
|
||||
it("users/publisher-official adds official org publishers for admin", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:admin",
|
||||
user: { _id: "users:admin", role: "admin" },
|
||||
} as never);
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
return {
|
||||
ok: true,
|
||||
publisherId: "publishers:nvidia",
|
||||
handle: "nvidia",
|
||||
added: true,
|
||||
officialPublisherId: "officialPublishers:nvidia",
|
||||
};
|
||||
});
|
||||
|
||||
const response = await __handlers.usersPostRouterV1Handler(
|
||||
makeCtx({ runQuery: vi.fn(), runAction: vi.fn(), runMutation }),
|
||||
new Request("https://example.com/api/v1/users/publisher-official", {
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer clh_test" },
|
||||
body: JSON.stringify({
|
||||
action: "add",
|
||||
handle: "NVIDIA",
|
||||
reason: "NVIDIA source-backed catalog",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toMatchObject({ ok: true, added: true });
|
||||
expect(runMutation).toHaveBeenCalledWith(internal.publishers.addOfficialPublisherInternal, {
|
||||
actorUserId: "users:admin",
|
||||
handle: "nvidia",
|
||||
reason: "NVIDIA source-backed catalog",
|
||||
});
|
||||
});
|
||||
|
||||
it("users/publisher-official removes official org publishers for admin", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:admin",
|
||||
user: { _id: "users:admin", role: "admin" },
|
||||
} as never);
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
return {
|
||||
ok: true,
|
||||
publisherId: "publishers:nvidia",
|
||||
handle: "nvidia",
|
||||
removed: true,
|
||||
officialPublisherId: "officialPublishers:nvidia",
|
||||
};
|
||||
});
|
||||
|
||||
const response = await __handlers.usersPostRouterV1Handler(
|
||||
makeCtx({ runQuery: vi.fn(), runAction: vi.fn(), runMutation }),
|
||||
new Request("https://example.com/api/v1/users/publisher-official", {
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer clh_test" },
|
||||
body: JSON.stringify({
|
||||
action: "remove",
|
||||
handle: "NVIDIA",
|
||||
reason: "requested by publisher",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toMatchObject({ ok: true, removed: true });
|
||||
expect(runMutation).toHaveBeenCalledWith(internal.publishers.removeOfficialPublisherInternal, {
|
||||
actorUserId: "users:admin",
|
||||
handle: "nvidia",
|
||||
reason: "requested by publisher",
|
||||
});
|
||||
});
|
||||
|
||||
it("publishers creates a self-serve org publisher for the authenticated user", async () => {
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
@@ -1691,6 +1858,327 @@ describe("httpApiV1 handlers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("skill install resolver returns archive descriptor for hosted direct uploads", async () => {
|
||||
const runQuery = makeInstallResolverRunQuery({
|
||||
skill: {
|
||||
_id: "skills:demo",
|
||||
slug: "demo",
|
||||
displayName: "Demo Skill",
|
||||
latestVersionSummary: { version: "1.0.0" },
|
||||
},
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
const response = await __handlers.skillsGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request("https://example.com/api/v1/skills/demo/install"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
ok: true,
|
||||
slug: "demo",
|
||||
installKind: "archive",
|
||||
archive: {
|
||||
version: "1.0.0",
|
||||
downloadUrl: "https://example.com/api/v1/download?slug=demo&version=1.0.0",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("skill install resolver returns a pinned GitHub descriptor for scan-clean source-backed skills", async () => {
|
||||
const runQuery = makeInstallResolverRunQuery({
|
||||
skill: {
|
||||
_id: "skills:aiq-deploy",
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
installKind: "github",
|
||||
githubSourceId: "githubSkillSources:nvidia",
|
||||
githubPath: "skills/aiq-deploy",
|
||||
githubCurrentCommit: "1".repeat(40),
|
||||
githubCurrentContentHash: "hash-aiq-deploy",
|
||||
githubCurrentStatus: "present",
|
||||
githubScanStatus: "clean",
|
||||
},
|
||||
source: {
|
||||
_id: "githubSkillSources:nvidia",
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
},
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
const response = await __handlers.skillsGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request("https://example.com/api/v1/skills/aiq-deploy/install"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
ok: true,
|
||||
slug: "aiq-deploy",
|
||||
installKind: "github",
|
||||
github: {
|
||||
repo: "NVIDIA/skills",
|
||||
path: "skills/aiq-deploy",
|
||||
commit: "1".repeat(40),
|
||||
contentHash: "hash-aiq-deploy",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "hosted direct uploads",
|
||||
slug: "hidden-direct",
|
||||
skill: {
|
||||
_id: "skills:hidden-direct",
|
||||
slug: "hidden-direct",
|
||||
displayName: "Hidden Direct",
|
||||
moderationStatus: "hidden",
|
||||
latestVersionSummary: { version: "1.0.0" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "GitHub-backed skills",
|
||||
slug: "hidden-github",
|
||||
skill: {
|
||||
_id: "skills:hidden-github",
|
||||
slug: "hidden-github",
|
||||
displayName: "Hidden GitHub",
|
||||
moderationStatus: "hidden",
|
||||
installKind: "github",
|
||||
githubSourceId: "githubSkillSources:nvidia",
|
||||
githubPath: "skills/hidden-github",
|
||||
githubCurrentCommit: "1".repeat(40),
|
||||
githubCurrentContentHash: "hash-hidden-github",
|
||||
githubCurrentStatus: "present",
|
||||
githubScanStatus: "clean",
|
||||
},
|
||||
},
|
||||
])("skill install resolver hides moderated $name", async ({ slug, skill }) => {
|
||||
const runQuery = makeInstallResolverRunQuery({
|
||||
skill,
|
||||
publicVisible: false,
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
const response = await __handlers.skillsGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request(`https://example.com/api/v1/skills/${slug}/install`),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
await expect(response.text()).resolves.toBe("Skill not found");
|
||||
});
|
||||
|
||||
it("skill install resolver hides skills absent from the public skill detail path", async () => {
|
||||
const runQuery = makeInstallResolverRunQuery({
|
||||
publicVisible: false,
|
||||
skill: {
|
||||
_id: "skills:orphaned-github",
|
||||
slug: "orphaned-github",
|
||||
displayName: "Orphaned GitHub",
|
||||
installKind: "github",
|
||||
githubSourceId: "githubSkillSources:nvidia",
|
||||
githubPath: "skills/orphaned-github",
|
||||
githubCurrentCommit: "1".repeat(40),
|
||||
githubCurrentContentHash: "hash-orphaned-github",
|
||||
githubCurrentStatus: "present",
|
||||
githubScanStatus: "clean",
|
||||
},
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
const response = await __handlers.skillsGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request("https://example.com/api/v1/skills/orphaned-github/install"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
await expect(response.text()).resolves.toBe("Skill not found");
|
||||
});
|
||||
|
||||
it("skill install resolver installs the current GitHub hash after it is clean", async () => {
|
||||
const runQuery = makeInstallResolverRunQuery({
|
||||
skill: {
|
||||
_id: "skills:aiq-deploy",
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
installKind: "github",
|
||||
githubSourceId: "githubSkillSources:nvidia",
|
||||
githubPath: "skills/aiq-deploy",
|
||||
githubCurrentCommit: "2".repeat(40),
|
||||
githubCurrentContentHash: "hash-aiq-deploy-v2",
|
||||
githubCurrentStatus: "present",
|
||||
githubScanStatus: "clean",
|
||||
},
|
||||
source: { repo: "NVIDIA/skills" },
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
const response = await __handlers.skillsGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request("https://example.com/api/v1/skills/aiq-deploy/install"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
ok: true,
|
||||
installKind: "github",
|
||||
github: {
|
||||
commit: "2".repeat(40),
|
||||
contentHash: "hash-aiq-deploy-v2",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("skill install resolver returns structured GitHub blocks for hidden stale source-backed skills", async () => {
|
||||
const runQuery = makeInstallResolverRunQuery({
|
||||
publicVisible: false,
|
||||
skill: {
|
||||
_id: "skills:aiq-deploy",
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "pending.scan",
|
||||
installKind: "github",
|
||||
githubSourceId: "githubSkillSources:nvidia",
|
||||
githubPath: "skills/aiq-deploy",
|
||||
githubCurrentCommit: "2".repeat(40),
|
||||
githubCurrentContentHash: "hash-aiq-deploy-v2",
|
||||
githubCurrentStatus: "present",
|
||||
githubScanStatus: "pending",
|
||||
},
|
||||
source: { repo: "NVIDIA/skills" },
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
const response = await __handlers.skillsGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request("https://example.com/api/v1/skills/aiq-deploy/install"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(423);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
ok: false,
|
||||
reason: "github_verification_pending",
|
||||
});
|
||||
});
|
||||
|
||||
it("skill install resolver force-installs pending GitHub-backed skills", async () => {
|
||||
const runQuery = makeInstallResolverRunQuery({
|
||||
skill: {
|
||||
_id: "skills:aiq-deploy",
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
moderationStatus: "active",
|
||||
moderationReason: "pending.scan",
|
||||
installKind: "github",
|
||||
githubSourceId: "githubSkillSources:nvidia",
|
||||
githubPath: "skills/aiq-deploy",
|
||||
githubCurrentCommit: "2".repeat(40),
|
||||
githubCurrentContentHash: "hash-aiq-deploy-v2",
|
||||
githubCurrentStatus: "present",
|
||||
githubScanStatus: "pending",
|
||||
},
|
||||
source: { repo: "NVIDIA/skills" },
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
const response = await __handlers.skillsGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request("https://example.com/api/v1/skills/aiq-deploy/install?forceInstall=1"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
ok: true,
|
||||
installKind: "github",
|
||||
github: {
|
||||
commit: "2".repeat(40),
|
||||
contentHash: "hash-aiq-deploy-v2",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("skill install resolver blocks GitHub-backed skills with failed scans", async () => {
|
||||
const runQuery = makeInstallResolverRunQuery({
|
||||
skill: {
|
||||
_id: "skills:bad-source",
|
||||
slug: "bad-source",
|
||||
displayName: "Bad Source",
|
||||
installKind: "github",
|
||||
githubSourceId: "githubSkillSources:nvidia",
|
||||
githubPath: "skills/bad-source",
|
||||
githubCurrentCommit: "1".repeat(40),
|
||||
githubCurrentContentHash: "hash-bad-source",
|
||||
githubCurrentStatus: "present",
|
||||
githubScanStatus: "failed",
|
||||
},
|
||||
source: { repo: "NVIDIA/skills" },
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
const response = await __handlers.skillsGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request("https://example.com/api/v1/skills/bad-source/install"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
ok: false,
|
||||
reason: "github_scan_failed",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "pending scan",
|
||||
patch: { githubScanStatus: "pending" },
|
||||
status: 423,
|
||||
reason: "github_verification_pending",
|
||||
},
|
||||
{
|
||||
name: "missing upstream path",
|
||||
patch: { githubCurrentStatus: "missing" },
|
||||
status: 410,
|
||||
reason: "github_upstream_missing",
|
||||
},
|
||||
])(
|
||||
"skill install resolver blocks GitHub-backed skills with $name",
|
||||
async ({ patch, status, reason }) => {
|
||||
const runQuery = makeInstallResolverRunQuery({
|
||||
skill: {
|
||||
_id: "skills:blocked-source",
|
||||
slug: "blocked-source",
|
||||
displayName: "Blocked Source",
|
||||
installKind: "github",
|
||||
githubSourceId: "githubSkillSources:nvidia",
|
||||
githubPath: "skills/blocked-source",
|
||||
githubCurrentCommit: "1".repeat(40),
|
||||
githubCurrentContentHash: "hash-blocked-source",
|
||||
githubCurrentStatus: "present",
|
||||
githubScanStatus: "clean",
|
||||
...patch,
|
||||
},
|
||||
source: { repo: "NVIDIA/skills" },
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
const response = await __handlers.skillsGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request("https://example.com/api/v1/skills/blocked-source/install"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(status);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
ok: false,
|
||||
reason,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("get skill treats reports as a valid slug", async () => {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("slug" in args) {
|
||||
@@ -8743,7 +9231,7 @@ describe("httpApiV1 handlers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("npm mirror tarball downloads record package installs", async () => {
|
||||
it("npm mirror tarball downloads record package installs and download metrics", async () => {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("name" in args && !("paginationOpts" in args)) {
|
||||
return {
|
||||
@@ -8798,13 +9286,25 @@ describe("httpApiV1 handlers", () => {
|
||||
get: vi.fn(async () => new Blob(["tarball"], { type: "application/octet-stream" })),
|
||||
},
|
||||
}),
|
||||
new Request("https://example.com/api/npm/demo-plugin/-/demo-plugin-1.0.0.tgz"),
|
||||
new Request("https://example.com/api/npm/demo-plugin/-/demo-plugin-1.0.0.tgz", {
|
||||
headers: { "cf-connecting-ip": "203.0.113.10" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(runMutation).toHaveBeenCalledWith(internal.packages.recordPackageInstallInternal, {
|
||||
packageId: "packages:demo-plugin",
|
||||
});
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
internal.downloadMetrics.recordDownloadMetricInternal,
|
||||
expect.objectContaining({
|
||||
target: { kind: "package", id: "packages:demo-plugin" },
|
||||
identityKind: "ip",
|
||||
identityHash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
dayStart: expect.any(Number),
|
||||
occurredAt: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("npm mirror returns not found for invalid package lookup names", async () => {
|
||||
@@ -9202,7 +9702,9 @@ describe("httpApiV1 handlers", () => {
|
||||
}),
|
||||
},
|
||||
}),
|
||||
new Request("https://example.com/api/v1/packages/demo-plugin/download"),
|
||||
new Request("https://example.com/api/v1/packages/demo-plugin/download", {
|
||||
headers: { "cf-connecting-ip": "203.0.113.20" },
|
||||
}),
|
||||
);
|
||||
|
||||
const zipEntries = unzipSync(new Uint8Array(await response.arrayBuffer()));
|
||||
@@ -9211,9 +9713,147 @@ describe("httpApiV1 handlers", () => {
|
||||
"package/package.json",
|
||||
]);
|
||||
expect(zipEntries["_meta.json"]).toBeUndefined();
|
||||
expect(runMutation).toHaveBeenCalledWith(internal.packages.recordPackageDownloadInternal, {
|
||||
packageId: "packages:1",
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
internal.downloadMetrics.recordDownloadMetricInternal,
|
||||
{
|
||||
target: { kind: "package", id: "packages:1" },
|
||||
identityKind: "ip",
|
||||
identityHash: expect.any(String),
|
||||
dayStart: expect.any(Number),
|
||||
occurredAt: expect.any(Number),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("package download metrics prefer API token user identity over IP", async () => {
|
||||
vi.mocked(getOptionalApiTokenUserId).mockResolvedValue("users:viewer" as never);
|
||||
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("name" in args) {
|
||||
return {
|
||||
package: {
|
||||
_id: "packages:1",
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
tags: {},
|
||||
latestReleaseId: "packageReleases:1",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
latestRelease: null,
|
||||
owner: { _id: "users:owner", handle: "owner" },
|
||||
};
|
||||
}
|
||||
if ("releaseId" in args) {
|
||||
return {
|
||||
_id: "packageReleases:1",
|
||||
version: "1.0.0",
|
||||
createdAt: 1,
|
||||
changelog: "init",
|
||||
files: [
|
||||
{
|
||||
path: "package.json",
|
||||
size: 2,
|
||||
sha256: "a".repeat(64),
|
||||
storageId: "storage:1",
|
||||
contentType: "application/json",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const response = await __handlers.packagesGetRouterV1Handler(
|
||||
makeCtx({
|
||||
runQuery,
|
||||
runMutation,
|
||||
storage: {
|
||||
get: vi.fn(async () => new Blob(["{}"], { type: "application/json" })),
|
||||
},
|
||||
}),
|
||||
new Request("https://example.com/api/v1/packages/demo-plugin/download", {
|
||||
headers: {
|
||||
authorization: "Bearer clh_test",
|
||||
"cf-connecting-ip": "203.0.113.20",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
internal.downloadMetrics.recordDownloadMetricInternal,
|
||||
expect.objectContaining({
|
||||
target: { kind: "package", id: "packages:1" },
|
||||
identityKind: "user",
|
||||
identityHash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("package downloads succeed and record download metrics", async () => {
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("name" in args) {
|
||||
return {
|
||||
package: {
|
||||
_id: "packages:1",
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
tags: {},
|
||||
latestReleaseId: "packageReleases:1",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
latestRelease: null,
|
||||
owner: { _id: "users:owner", handle: "owner" },
|
||||
};
|
||||
}
|
||||
if ("releaseId" in args) {
|
||||
return {
|
||||
_id: "packageReleases:1",
|
||||
version: "1.0.0",
|
||||
createdAt: 1,
|
||||
changelog: "init",
|
||||
files: [
|
||||
{
|
||||
path: "package.json",
|
||||
size: 2,
|
||||
sha256: "a".repeat(64),
|
||||
storageId: "storage:1",
|
||||
contentType: "application/json",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const response = await __handlers.packagesGetRouterV1Handler(
|
||||
makeCtx({
|
||||
runQuery,
|
||||
runMutation,
|
||||
storage: {
|
||||
get: vi.fn(async () => new Blob(["{}"], { type: "application/json" })),
|
||||
},
|
||||
}),
|
||||
new Request("https://example.com/api/v1/packages/demo-plugin/download", {
|
||||
headers: { "cf-connecting-ip": "203.0.113.20" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const mutationArgs = runMutation.mock.calls.map(([, args]) => args);
|
||||
expect(
|
||||
mutationArgs.filter((args) => hasPackageDownloadMetricTarget(args, "packages:1")),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("package download fails when any stored file is missing", async () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
formatUserFacingErrorMessage,
|
||||
parseMultipartSkillScan,
|
||||
resolveVersionTagsBatch,
|
||||
softDeleteErrorToResponse,
|
||||
} from "./httpApiV1/shared";
|
||||
|
||||
function makeCtx() {
|
||||
@@ -32,6 +33,54 @@ describe("http API v1 shared helpers", () => {
|
||||
).toBe("Publisher not found");
|
||||
});
|
||||
|
||||
it("maps soft-delete validation failures to 400 with cleaned messages", async () => {
|
||||
const response = softDeleteErrorToResponse(
|
||||
"package",
|
||||
new Error(
|
||||
"[CONVEX M] [Request ID: abc] Server Error Called by client Uncaught ConvexError: Package name must be lowercase and npm-safe (example: @scope/name or plugin-name)",
|
||||
),
|
||||
{},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
await expect(response.text()).resolves.toBe(
|
||||
"Package name must be lowercase and npm-safe (example: @scope/name or plugin-name)",
|
||||
);
|
||||
});
|
||||
|
||||
it("maps reserved package route validation failures to 400 with cleaned messages", async () => {
|
||||
const response = softDeleteErrorToResponse(
|
||||
"package",
|
||||
new Error(
|
||||
'[CONVEX M] [Request ID: abc] Server Error Called by client Uncaught ConvexError: Package name "publish" is reserved for ClawHub routes. Use a scoped name or choose a different package name.',
|
||||
),
|
||||
{},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
await expect(response.text()).resolves.toBe(
|
||||
'Package name "publish" is reserved for ClawHub routes. Use a scoped name or choose a different package name.',
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps unknown soft-delete failures generic 500s", async () => {
|
||||
const response = softDeleteErrorToResponse("soul", new Error("boom"), {});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
await expect(response.text()).resolves.toBe("Internal Server Error");
|
||||
});
|
||||
|
||||
it("keeps unrelated reserved-word failures generic 500s", async () => {
|
||||
const response = softDeleteErrorToResponse(
|
||||
"package",
|
||||
new Error("database reserved capacity exceeded"),
|
||||
{},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
await expect(response.text()).resolves.toBe("Internal Server Error");
|
||||
});
|
||||
|
||||
it("resolves latest tags without reading version documents", async () => {
|
||||
const ctx = makeCtx();
|
||||
const versionId = "skillVersions:latest" as Id<"skillVersions">;
|
||||
|
||||
@@ -40,6 +40,7 @@ import { starsDeleteRouterV1Handler, starsPostRouterV1Handler } from "./httpApiV
|
||||
import { transfersGetRouterV1Handler } from "./httpApiV1/transfersV1";
|
||||
import {
|
||||
banAppealContextV1Handler,
|
||||
usersGetRouterV1Handler,
|
||||
usersListV1Handler,
|
||||
usersPostRouterV1Handler,
|
||||
} from "./httpApiV1/usersV1";
|
||||
@@ -84,6 +85,7 @@ export const starsDeleteRouterV1Http = httpAction(starsDeleteRouterV1Handler);
|
||||
export const transfersGetRouterV1Http = httpAction(transfersGetRouterV1Handler);
|
||||
|
||||
export const whoamiV1Http = httpAction(whoamiV1Handler);
|
||||
export const usersGetRouterV1Http = httpAction(usersGetRouterV1Handler);
|
||||
export const usersPostRouterV1Http = httpAction(usersPostRouterV1Handler);
|
||||
export const usersListV1Http = httpAction(usersListV1Handler);
|
||||
export const banAppealContextV1Http = httpAction(banAppealContextV1Handler);
|
||||
@@ -120,6 +122,7 @@ export const __handlers = {
|
||||
starsDeleteRouterV1Handler,
|
||||
transfersGetRouterV1Handler,
|
||||
whoamiV1Handler,
|
||||
usersGetRouterV1Handler,
|
||||
usersPostRouterV1Handler,
|
||||
usersListV1Handler,
|
||||
banAppealContextV1Handler,
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { api, internal } from "../_generated/api";
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
import type { ActionCtx } from "../_generated/server";
|
||||
import { buildDownloadMetricArgs, getDownloadIdentity } from "../downloadMetrics";
|
||||
import { getOptionalActiveAuthUserIdFromAction } from "../lib/access";
|
||||
import { getOptionalApiTokenUserId } from "../lib/apiTokenAuth";
|
||||
import { parseClawPack, sha256Base64, sha256Hex } from "../lib/clawpack";
|
||||
@@ -123,6 +124,9 @@ const internalRefs = internal as unknown as {
|
||||
backfillPackageArtifactKindsInternal: unknown;
|
||||
listPackageModerationQueueInternal: unknown;
|
||||
};
|
||||
downloadMetrics: {
|
||||
recordDownloadMetricInternal: unknown;
|
||||
};
|
||||
packagePublishTokens: {
|
||||
createInternal: unknown;
|
||||
};
|
||||
@@ -643,9 +647,11 @@ function releaseArtifactUrls(request: Request, packageName: string, release: Rel
|
||||
|
||||
async function streamClawPackRelease(
|
||||
ctx: ActionCtx,
|
||||
request: Request,
|
||||
rateHeaders: HeadersInit,
|
||||
pkg: PublicPackageDocLike,
|
||||
release: ReleaseLike,
|
||||
viewerUserId: Id<"users"> | null,
|
||||
statKind: "download" | "install" = "download",
|
||||
) {
|
||||
const securityBlock = getReleaseSecurityBlock(release);
|
||||
@@ -656,13 +662,24 @@ async function streamClawPackRelease(
|
||||
const blob = await ctx.storage.get(release.clawpackStorageId);
|
||||
if (!blob) return text("ClawPack artifact not found", 404, rateHeaders);
|
||||
try {
|
||||
const statMutation =
|
||||
statKind === "install"
|
||||
? internalRefs.packages.recordPackageInstallInternal
|
||||
: internalRefs.packages.recordPackageDownloadInternal;
|
||||
await runMutationRef(ctx, statMutation, {
|
||||
packageId: pkg._id,
|
||||
});
|
||||
if (statKind === "install") {
|
||||
await runMutationRef(ctx, internalRefs.packages.recordPackageInstallInternal, {
|
||||
packageId: pkg._id,
|
||||
});
|
||||
}
|
||||
|
||||
const identity = getDownloadIdentity(request, viewerUserId ? String(viewerUserId) : null);
|
||||
if (identity) {
|
||||
await runMutationRef(
|
||||
ctx,
|
||||
internalRefs.downloadMetrics.recordDownloadMetricInternal,
|
||||
await buildDownloadMetricArgs({
|
||||
target: { kind: "package", id: pkg._id },
|
||||
identity,
|
||||
now: Date.now(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort metric path; never fail package downloads.
|
||||
}
|
||||
@@ -2906,7 +2923,14 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
|
||||
if (!release) return text("Version not found", 404, rate.headers);
|
||||
if (packageSegments[3] === "download") {
|
||||
if (release.artifactKind === "npm-pack") {
|
||||
return await streamClawPackRelease(ctx, rate.headers, publicPackage!, release);
|
||||
return await streamClawPackRelease(
|
||||
ctx,
|
||||
request,
|
||||
rate.headers,
|
||||
publicPackage!,
|
||||
release,
|
||||
viewerUserId ?? null,
|
||||
);
|
||||
}
|
||||
const url = new URL(
|
||||
`/api/v1/packages/${encodePackagePath(publicPackage!.name)}/download`,
|
||||
@@ -3105,9 +3129,18 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
|
||||
const zip = buildDeterministicPackageZip(entries);
|
||||
const [zipSha256, zipSha256Base64] = await Promise.all([sha256Hex(zip), sha256Base64(zip)]);
|
||||
try {
|
||||
await runMutationRef(ctx, internalRefs.packages.recordPackageDownloadInternal, {
|
||||
packageId: publicPackage!._id,
|
||||
});
|
||||
const identity = getDownloadIdentity(request, viewerUserId ? String(viewerUserId) : null);
|
||||
if (identity) {
|
||||
await runMutationRef(
|
||||
ctx,
|
||||
internalRefs.downloadMetrics.recordDownloadMetricInternal,
|
||||
await buildDownloadMetricArgs({
|
||||
target: { kind: "package", id: publicPackage!._id },
|
||||
identity,
|
||||
now: Date.now(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort metric path; never fail package downloads.
|
||||
}
|
||||
@@ -3253,7 +3286,15 @@ export async function npmMirrorGetHandler(ctx: ActionCtx, request: Request) {
|
||||
const tarballName = path.rest[1]!;
|
||||
const release = releases.find((candidate) => candidate.npmTarballName === tarballName);
|
||||
if (!release) return text("ClawPack artifact not found", 404, rate.headers);
|
||||
return await streamClawPackRelease(ctx, rate.headers, detail.package, release, "install");
|
||||
return await streamClawPackRelease(
|
||||
ctx,
|
||||
request,
|
||||
rate.headers,
|
||||
detail.package,
|
||||
release,
|
||||
viewerUserId ?? null,
|
||||
"install",
|
||||
);
|
||||
}
|
||||
if (path.rest.length > 0) return text("Not found", 404, rate.headers);
|
||||
|
||||
|
||||
@@ -514,22 +514,40 @@ export function parsePublishBody(body: unknown) {
|
||||
};
|
||||
}
|
||||
|
||||
// Substrings that indicate user-input validation failures from the underlying
|
||||
// mutations (e.g. `normalizePackageName` ConvexErrors). These are surfaced as
|
||||
// 400s with the cleaned message so CLI/API clients can see the actual reason
|
||||
// instead of an opaque 500.
|
||||
const SOFT_DELETE_BAD_REQUEST_HINTS = [
|
||||
"slug required",
|
||||
"package name required",
|
||||
"package name must be",
|
||||
"must be lowercase",
|
||||
"npm-safe",
|
||||
"reserved for clawhub routes",
|
||||
"version required",
|
||||
] as const;
|
||||
|
||||
export function softDeleteErrorToResponse(
|
||||
entity: "skill" | "soul" | "package",
|
||||
error: unknown,
|
||||
headers: HeadersInit,
|
||||
) {
|
||||
const message = error instanceof Error ? error.message : `${entity} delete failed`;
|
||||
const lower = message.toLowerCase();
|
||||
const rawMessage = error instanceof Error ? error.message : `${entity} delete failed`;
|
||||
const cleaned = cleanUserFacingErrorMessage(rawMessage) || rawMessage;
|
||||
const lower = cleaned.toLowerCase();
|
||||
|
||||
if (lower.includes("unauthorized"))
|
||||
return text(formatAuthzMessage(error, "Unauthorized"), 401, headers);
|
||||
if (lower.includes("forbidden"))
|
||||
return text(formatAuthzMessage(error, "Forbidden"), 403, headers);
|
||||
if (lower.includes("not found")) return text(message, 404, headers);
|
||||
if (lower.includes("slug required")) return text("Slug required", 400, headers);
|
||||
if (lower.includes("not found")) return text(cleaned, 404, headers);
|
||||
if (SOFT_DELETE_BAD_REQUEST_HINTS.some((hint) => lower.includes(hint))) {
|
||||
return text(cleaned, 400, headers);
|
||||
}
|
||||
|
||||
// Unknown: server-side failure. Keep body generic.
|
||||
// Unknown: server-side failure. Keep the body generic; only known
|
||||
// user-input validation failures above surface the cleaned mutation message.
|
||||
return text("Internal Server Error", 500, headers);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,12 @@ import { getOptionalApiTokenUserId, requireApiTokenUser } from "../lib/apiTokenA
|
||||
import { mergeHeaders } from "../lib/httpHeaders";
|
||||
import { applyRateLimit } from "../lib/httpRateLimit";
|
||||
import { parseBooleanQueryParam, resolveBooleanQueryParam } from "../lib/httpUtils";
|
||||
import {
|
||||
buildSkillInstallResolution,
|
||||
type InstallResolverSkill,
|
||||
type InstallResolverSource,
|
||||
type SkillInstallResolution,
|
||||
} from "../lib/installResolver";
|
||||
import type {
|
||||
LlmAgenticRiskFinding,
|
||||
LlmEvalDimension,
|
||||
@@ -284,6 +290,9 @@ type SkillSecuritySnapshot = {
|
||||
};
|
||||
|
||||
const internalRefs = internal as unknown as {
|
||||
githubSkillSources: {
|
||||
getByIdInternal: unknown;
|
||||
};
|
||||
securityScan: {
|
||||
createUploadedSkillScanRequestInternal: unknown;
|
||||
createPublishedSkillScanRequestInternal: unknown;
|
||||
@@ -297,6 +306,7 @@ const internalRefs = internal as unknown as {
|
||||
};
|
||||
skills: {
|
||||
getSecurityVerdictTargetInternal: unknown;
|
||||
getSkillBySlugInternal: unknown;
|
||||
reportSkillForUserInternal: unknown;
|
||||
listSkillReportsInternal: unknown;
|
||||
triageSkillReportForUserInternal: unknown;
|
||||
@@ -1466,6 +1476,25 @@ async function describeOwnerVisibleSkillState(
|
||||
return null;
|
||||
}
|
||||
|
||||
function shouldExposeHiddenGitHubInstallBlock(
|
||||
skill: InstallResolverSkill & {
|
||||
installKind?: "github";
|
||||
moderationStatus?: "active" | "hidden" | "removed";
|
||||
moderationReason?: string;
|
||||
},
|
||||
resolution: SkillInstallResolution,
|
||||
) {
|
||||
if (skill.installKind !== "github" || resolution.ok) return false;
|
||||
if (skill.moderationStatus !== "hidden") return false;
|
||||
const reason = skill.moderationReason ?? "";
|
||||
return (
|
||||
reason === "pending.scan" ||
|
||||
reason === "scanner.failed" ||
|
||||
reason === "scanner.llm.malicious" ||
|
||||
reason.startsWith("github.")
|
||||
);
|
||||
}
|
||||
|
||||
export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const rate = await applyRateLimit(ctx, request, "read");
|
||||
if (!rate.ok) return rate.response;
|
||||
@@ -1530,6 +1559,60 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
|
||||
return json(result, 200, rate.headers);
|
||||
}
|
||||
|
||||
if (second === "install" && segments.length === 2) {
|
||||
const url = new URL(request.url);
|
||||
const forceInstall = parseBooleanQueryParam(url.searchParams.get("forceInstall"));
|
||||
const skill = (await runQueryRef<
|
||||
| (InstallResolverSkill & {
|
||||
_id: Id<"skills">;
|
||||
githubSourceId?: Id<"githubSkillSources">;
|
||||
softDeletedAt?: number;
|
||||
moderationStatus?: "active" | "hidden" | "removed";
|
||||
moderationReason?: string;
|
||||
moderationFlags?: string[];
|
||||
})
|
||||
| null
|
||||
>(ctx, internalRefs.skills.getSkillBySlugInternal, { slug })) as
|
||||
| (InstallResolverSkill & {
|
||||
_id: Id<"skills">;
|
||||
githubSourceId?: Id<"githubSkillSources">;
|
||||
softDeletedAt?: number;
|
||||
moderationStatus?: "active" | "hidden" | "removed";
|
||||
moderationReason?: string;
|
||||
moderationFlags?: string[];
|
||||
})
|
||||
| null;
|
||||
if (!skill || skill.softDeletedAt || skill.moderationStatus === "removed") {
|
||||
return text("Skill not found", 404, rate.headers);
|
||||
}
|
||||
|
||||
const source =
|
||||
skill.installKind === "github" && skill.githubSourceId
|
||||
? ((await runQueryRef(ctx, internalRefs.githubSkillSources.getByIdInternal, {
|
||||
sourceId: skill.githubSourceId,
|
||||
})) as InstallResolverSource | null)
|
||||
: null;
|
||||
const resolution = buildSkillInstallResolution({
|
||||
origin: publicApiOrigin(request),
|
||||
skill,
|
||||
source,
|
||||
forceInstall,
|
||||
});
|
||||
|
||||
const publicSkillResult = (await ctx.runQuery(api.skills.getBySlug, {
|
||||
slug,
|
||||
})) as GetBySlugResult;
|
||||
const publiclyVisible = publicSkillResult?.skill?._id === skill._id;
|
||||
if (!publiclyVisible) {
|
||||
if (!resolution.ok && shouldExposeHiddenGitHubInstallBlock(skill, resolution)) {
|
||||
return json(resolution, resolution.status, rate.headers);
|
||||
}
|
||||
return text("Skill not found", 404, rate.headers);
|
||||
}
|
||||
|
||||
return json(resolution, resolution.ok ? 200 : resolution.status, rate.headers);
|
||||
}
|
||||
|
||||
if (segments.length === 1) {
|
||||
const result = (await ctx.runQuery(api.skills.getBySlug, { slug })) as GetBySlugResult;
|
||||
if (!result?.skill) {
|
||||
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
|
||||
const usersV1InternalRefs = internal as unknown as {
|
||||
publishers: {
|
||||
addOfficialPublisherInternal: unknown;
|
||||
listOfficialPublishersInternal: unknown;
|
||||
removeOrgPublisherMemberInternal: unknown;
|
||||
removeOfficialPublisherInternal: unknown;
|
||||
};
|
||||
users: {
|
||||
getBanAppealContextByGitHubProviderAccountIdInternal: unknown;
|
||||
@@ -84,6 +87,7 @@ export async function usersPostRouterV1Handler(ctx: ActionCtx, request: Request)
|
||||
action !== "reclaim" &&
|
||||
action !== "reserve" &&
|
||||
action !== "publisher" &&
|
||||
action !== "publisher-official" &&
|
||||
action !== "publisher-member"
|
||||
) {
|
||||
return text("Not found", 404, rate.headers);
|
||||
@@ -139,6 +143,12 @@ export async function usersPostRouterV1Handler(ctx: ActionCtx, request: Request)
|
||||
return handleAdminEnsurePublisher(ctx, payload, actorUserId, rate.headers);
|
||||
}
|
||||
|
||||
if (action === "publisher-official") {
|
||||
const admin = requireAdminOrResponse(actorUser, rate.headers);
|
||||
if (!admin.ok) return admin.response;
|
||||
return handleAdminOfficialPublisherPost(ctx, payload, actorUserId, rate.headers);
|
||||
}
|
||||
|
||||
if (action === "publisher-member") {
|
||||
const admin = requireAdminOrResponse(actorUser, rate.headers);
|
||||
if (!admin.ok) return admin.response;
|
||||
@@ -352,6 +362,37 @@ async function handleAdminRemediateAutobans(
|
||||
}
|
||||
}
|
||||
|
||||
export async function usersGetRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const rate = await applyRateLimit(ctx, request, "read");
|
||||
if (!rate.ok) return rate.response;
|
||||
|
||||
const segments = getPathSegments(request, "/api/v1/users/");
|
||||
if (segments.length !== 1 || segments[0] !== "publisher-official") {
|
||||
return text("Not found", 404, rate.headers);
|
||||
}
|
||||
|
||||
const authResult = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
|
||||
if (!authResult.ok) return authResult.response;
|
||||
const admin = requireAdminOrResponse(authResult.user, rate.headers);
|
||||
if (!admin.ok) return admin.response;
|
||||
|
||||
try {
|
||||
const result = await runUsersV1QueryRef(
|
||||
ctx,
|
||||
usersV1InternalRefs.publishers.listOfficialPublishersInternal,
|
||||
{ actorUserId: authResult.userId },
|
||||
);
|
||||
return json(result, 200, rate.headers);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Official publisher list failed";
|
||||
if (message.toLowerCase().includes("forbidden")) return text("Forbidden", 403, rate.headers);
|
||||
if (message.toLowerCase().includes("unauthorized")) {
|
||||
return text("Unauthorized", 401, rate.headers);
|
||||
}
|
||||
return text(message, 400, rate.headers);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/users/restore
|
||||
* Admin-only: restore skills from GitHub backup for a user.
|
||||
@@ -532,6 +573,42 @@ async function handleAdminReserve(
|
||||
return json({ ok: true, results, succeeded, failed }, 200, headers);
|
||||
}
|
||||
|
||||
async function handleAdminOfficialPublisherPost(
|
||||
ctx: ActionCtx,
|
||||
payload: Record<string, unknown>,
|
||||
actorUserId: Id<"users">,
|
||||
headers: HeadersInit,
|
||||
) {
|
||||
const action = typeof payload.action === "string" ? payload.action.trim().toLowerCase() : "";
|
||||
const handle = typeof payload.handle === "string" ? payload.handle.trim().toLowerCase() : "";
|
||||
const reason = typeof payload.reason === "string" ? payload.reason.trim() : "";
|
||||
if (action !== "add" && action !== "remove") return text("Invalid action", 400, headers);
|
||||
if (!handle) return text("Missing handle", 400, headers);
|
||||
if (!reason) return text("Missing reason", 400, headers);
|
||||
if (reason.length > 500) return text("Reason too long (max 500 chars)", 400, headers);
|
||||
|
||||
try {
|
||||
const result = await runUsersV1MutationRef(
|
||||
ctx,
|
||||
action === "add"
|
||||
? usersV1InternalRefs.publishers.addOfficialPublisherInternal
|
||||
: usersV1InternalRefs.publishers.removeOfficialPublisherInternal,
|
||||
{
|
||||
actorUserId,
|
||||
handle,
|
||||
reason,
|
||||
},
|
||||
);
|
||||
return json(result, 200, headers);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Official publisher update failed";
|
||||
if (message.toLowerCase().includes("forbidden")) return text("Forbidden", 403, headers);
|
||||
if (message.toLowerCase().includes("unauthorized")) return text("Unauthorized", 401, headers);
|
||||
if (message.toLowerCase().includes("not found")) return text(message, 404, headers);
|
||||
return text(message, 400, headers);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdminEnsurePublisher(
|
||||
ctx: ActionCtx,
|
||||
payload: Record<string, unknown>,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@convex-dev/auth/server", () => ({
|
||||
getAuthUserId: vi.fn(),
|
||||
@@ -9,6 +9,10 @@ const { getAuthUserId } = await import("@convex-dev/auth/server");
|
||||
const { assertAdmin, assertModerator, assertRole, requireUser, requireUserFromAction } =
|
||||
await import("./access");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getAuthUserId).mockReset();
|
||||
});
|
||||
|
||||
describe("access.requireUser", () => {
|
||||
it("throws when auth is missing", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue(null);
|
||||
@@ -59,6 +63,67 @@ describe("access.requireUser", () => {
|
||||
expect(dbGet).toHaveBeenCalledWith("users:2");
|
||||
expect(result).toEqual({ userId: "users:2", user });
|
||||
});
|
||||
|
||||
it("uses the local dev impersonation user before browser auth", async () => {
|
||||
const previousHandle = process.env.CLAW_HUB_DEV_IMPERSONATE_USER_HANDLE;
|
||||
const previousEnabled = process.env.CLAW_HUB_ENABLE_DEV_IMPERSONATION;
|
||||
try {
|
||||
process.env.CLAW_HUB_DEV_IMPERSONATE_USER_HANDLE = "local";
|
||||
process.env.CLAW_HUB_ENABLE_DEV_IMPERSONATION = "1";
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:browser" as never);
|
||||
const user = { _id: "users:local", handle: "local", role: "admin" };
|
||||
const unique = vi.fn().mockResolvedValue(user as never);
|
||||
const withIndex = vi.fn().mockReturnValue({ unique });
|
||||
const query = vi.fn().mockReturnValue({ withIndex });
|
||||
const dbGet = vi.fn().mockResolvedValue(user as never);
|
||||
|
||||
const result = await requireUser({
|
||||
db: { get: dbGet, query },
|
||||
} as never);
|
||||
|
||||
expect(query).toHaveBeenCalledWith("users");
|
||||
expect(withIndex).toHaveBeenCalledWith("handle", expect.any(Function));
|
||||
expect(dbGet).toHaveBeenCalledWith("users:local");
|
||||
expect(getAuthUserId).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ userId: "users:local", user });
|
||||
} finally {
|
||||
if (previousHandle === undefined) delete process.env.CLAW_HUB_DEV_IMPERSONATE_USER_HANDLE;
|
||||
else process.env.CLAW_HUB_DEV_IMPERSONATE_USER_HANDLE = previousHandle;
|
||||
if (previousEnabled === undefined) delete process.env.CLAW_HUB_ENABLE_DEV_IMPERSONATION;
|
||||
else process.env.CLAW_HUB_ENABLE_DEV_IMPERSONATION = previousEnabled;
|
||||
}
|
||||
});
|
||||
|
||||
it("does not use local dev impersonation in production deployments", async () => {
|
||||
const previousHandle = process.env.CLAW_HUB_DEV_IMPERSONATE_USER_HANDLE;
|
||||
const previousEnabled = process.env.CLAW_HUB_ENABLE_DEV_IMPERSONATION;
|
||||
const previousDeployment = process.env.CONVEX_DEPLOYMENT;
|
||||
try {
|
||||
process.env.CLAW_HUB_DEV_IMPERSONATE_USER_HANDLE = "local";
|
||||
process.env.CLAW_HUB_ENABLE_DEV_IMPERSONATION = "1";
|
||||
process.env.CONVEX_DEPLOYMENT = "prod:wry-manatee-359";
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:browser" as never);
|
||||
const user = { _id: "users:browser", handle: "browser", role: "user" };
|
||||
const query = vi.fn();
|
||||
const dbGet = vi.fn().mockResolvedValue(user as never);
|
||||
|
||||
const result = await requireUser({
|
||||
db: { get: dbGet, query },
|
||||
} as never);
|
||||
|
||||
expect(query).not.toHaveBeenCalled();
|
||||
expect(getAuthUserId).toHaveBeenCalled();
|
||||
expect(dbGet).toHaveBeenCalledWith("users:browser");
|
||||
expect(result).toEqual({ userId: "users:browser", user });
|
||||
} finally {
|
||||
if (previousHandle === undefined) delete process.env.CLAW_HUB_DEV_IMPERSONATE_USER_HANDLE;
|
||||
else process.env.CLAW_HUB_DEV_IMPERSONATE_USER_HANDLE = previousHandle;
|
||||
if (previousEnabled === undefined) delete process.env.CLAW_HUB_ENABLE_DEV_IMPERSONATION;
|
||||
else process.env.CLAW_HUB_ENABLE_DEV_IMPERSONATION = previousEnabled;
|
||||
if (previousDeployment === undefined) delete process.env.CONVEX_DEPLOYMENT;
|
||||
else process.env.CONVEX_DEPLOYMENT = previousDeployment;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("access.requireUserFromAction", () => {
|
||||
@@ -111,6 +176,37 @@ describe("access.requireUserFromAction", () => {
|
||||
expect(runQuery).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({ userId: "users:9", user });
|
||||
});
|
||||
|
||||
it("uses the local dev impersonation user before action auth", async () => {
|
||||
const previousHandle = process.env.CLAW_HUB_DEV_IMPERSONATE_USER_HANDLE;
|
||||
const previousEnabled = process.env.CLAW_HUB_ENABLE_DEV_IMPERSONATION;
|
||||
try {
|
||||
process.env.CLAW_HUB_DEV_IMPERSONATE_USER_HANDLE = "local";
|
||||
process.env.CLAW_HUB_ENABLE_DEV_IMPERSONATION = "1";
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:browser" as never);
|
||||
const user = { _id: "users:local", handle: "local", role: "admin" };
|
||||
const runQuery = vi.fn(async (_query, args: { handle?: string; userId?: string }) => {
|
||||
if (args.handle === "local") return user;
|
||||
if (args.userId === "users:local") return user;
|
||||
return null;
|
||||
});
|
||||
|
||||
const result = await requireUserFromAction({ runQuery } as never);
|
||||
|
||||
expect(runQuery).toHaveBeenCalledTimes(2);
|
||||
expect(runQuery).toHaveBeenNthCalledWith(1, expect.anything(), { handle: "local" });
|
||||
expect(runQuery).toHaveBeenNthCalledWith(2, expect.anything(), {
|
||||
userId: "users:local",
|
||||
});
|
||||
expect(getAuthUserId).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ userId: "users:local", user });
|
||||
} finally {
|
||||
if (previousHandle === undefined) delete process.env.CLAW_HUB_DEV_IMPERSONATE_USER_HANDLE;
|
||||
else process.env.CLAW_HUB_DEV_IMPERSONATE_USER_HANDLE = previousHandle;
|
||||
if (previousEnabled === undefined) delete process.env.CLAW_HUB_ENABLE_DEV_IMPERSONATION;
|
||||
else process.env.CLAW_HUB_ENABLE_DEV_IMPERSONATION = previousEnabled;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("access role assertions", () => {
|
||||
|
||||
+36
-7
@@ -8,10 +8,23 @@ export type Role = "admin" | "moderator" | "user" | "mirror";
|
||||
const DEV_IMPERSONATE_LOCAL_HANDLE = "local";
|
||||
|
||||
function readEnv(name: string) {
|
||||
const value = process.env[name]?.trim();
|
||||
const value = readKnownEnv(name)?.trim();
|
||||
return value ? value : undefined;
|
||||
}
|
||||
|
||||
function readKnownEnv(name: string) {
|
||||
if (name === "CLAW_HUB_DEV_IMPERSONATE_USER_HANDLE") {
|
||||
return process.env.CLAW_HUB_DEV_IMPERSONATE_USER_HANDLE;
|
||||
}
|
||||
if (name === "CLAW_HUB_ENABLE_DEV_IMPERSONATION") {
|
||||
return process.env.CLAW_HUB_ENABLE_DEV_IMPERSONATION;
|
||||
}
|
||||
if (name === "CONVEX_DEPLOYMENT") {
|
||||
return process.env.CONVEX_DEPLOYMENT;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isDevImpersonationAllowed() {
|
||||
const requestedHandle = readEnv("CLAW_HUB_DEV_IMPERSONATE_USER_HANDLE");
|
||||
if (requestedHandle !== DEV_IMPERSONATE_LOCAL_HANDLE) return false;
|
||||
@@ -52,39 +65,49 @@ async function getDevImpersonatedUserIdFromAction(
|
||||
export async function getOptionalActiveAuthUserId(
|
||||
ctx: MutationCtx | QueryCtx,
|
||||
): Promise<Id<"users"> | undefined> {
|
||||
const devUserId = await getDevImpersonatedUserId(ctx);
|
||||
if (devUserId) return devUserId;
|
||||
try {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) return await getDevImpersonatedUserId(ctx);
|
||||
if (!userId) return undefined;
|
||||
const user = await ctx.db.get(userId);
|
||||
if (!user || user.deletedAt || user.deactivatedAt) return undefined;
|
||||
return userId;
|
||||
} catch {
|
||||
return await getDevImpersonatedUserId(ctx);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOptionalActiveAuthUserIdFromAction(
|
||||
ctx: ActionCtx,
|
||||
): Promise<Id<"users"> | undefined> {
|
||||
const devUserId = await getDevImpersonatedUserIdFromAction(ctx);
|
||||
if (devUserId) return devUserId;
|
||||
try {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) return await getDevImpersonatedUserIdFromAction(ctx);
|
||||
if (!userId) return undefined;
|
||||
const user = await ctx.runQuery(internal.users.getByIdInternal, { userId });
|
||||
if (!user || user.deletedAt || user.deactivatedAt) return undefined;
|
||||
return userId;
|
||||
} catch {
|
||||
return await getDevImpersonatedUserIdFromAction(ctx);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireUser(ctx: MutationCtx | QueryCtx) {
|
||||
const devUserId = await getDevImpersonatedUserId(ctx);
|
||||
if (devUserId) {
|
||||
const devUser = await ctx.db.get(devUserId);
|
||||
if (!devUser || devUser.deletedAt || devUser.deactivatedAt) throw new Error("User not found");
|
||||
return { userId: devUserId, user: devUser };
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -99,13 +122,19 @@ export async function requireUser(ctx: MutationCtx | QueryCtx) {
|
||||
export async function requireUserFromAction(
|
||||
ctx: ActionCtx,
|
||||
): Promise<{ userId: Id<"users">; user: Doc<"users"> }> {
|
||||
const devUserId = await getDevImpersonatedUserIdFromAction(ctx);
|
||||
if (devUserId) {
|
||||
const devUser = await ctx.runQuery(internal.users.getByIdInternal, { userId: devUserId });
|
||||
if (!devUser || devUser.deletedAt || devUser.deactivatedAt) throw new Error("User not found");
|
||||
return { userId: devUserId, user: devUser as Doc<"users"> };
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -34,7 +34,7 @@ export const MISSING_API_TOKEN_MESSAGE =
|
||||
export const INVALID_API_TOKEN_MESSAGE =
|
||||
"Unauthorized: API token is invalid or revoked. Run `clawhub login` again.";
|
||||
export const BLOCKED_API_TOKEN_ACCOUNT_MESSAGE =
|
||||
"Unauthorized: This ClawHub account is not in good standing and cannot use API tokens. If you believe this is a mistake, contact security@openclaw.ai.";
|
||||
"Unauthorized: This ClawHub account is not in good standing and cannot use API tokens. If you believe this is a mistake, open a GitHub issue: https://github.com/openclaw/clawhub/issues/new.";
|
||||
|
||||
export async function requireApiTokenUser(
|
||||
ctx: ActionCtx,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isLocalDevAuthEnabled } from "./devAuth";
|
||||
|
||||
const CLOUD_DEV_AUTH_SECRET = "dev-auth-secret-with-enough-entropy-123";
|
||||
|
||||
describe("isLocalDevAuthEnabled", () => {
|
||||
it("requires the explicit dev auth flag", () => {
|
||||
expect(
|
||||
@@ -41,16 +43,78 @@ describe("isLocalDevAuthEnabled", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects cloud dev deployments even when the dev auth flag is set", () => {
|
||||
it("allows cloud dev deployments with an explicit localhost site and matching secret", () => {
|
||||
expect(
|
||||
isLocalDevAuthEnabled(
|
||||
{
|
||||
CONVEX_SITE_URL: "https://clever-rabbit-123.convex.cloud",
|
||||
DEV_AUTH_CONVEX_DEPLOYMENT: "dev:clever-rabbit-123",
|
||||
DEV_AUTH_ENABLED: "1",
|
||||
DEV_AUTH_SECRET: CLOUD_DEV_AUTH_SECRET,
|
||||
DEV_AUTH_SITE_URL: "http://127.0.0.1:3211",
|
||||
},
|
||||
CLOUD_DEV_AUTH_SECRET,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows cloud dev deployments from the fallback marker when Convex deployment is blank", () => {
|
||||
expect(
|
||||
isLocalDevAuthEnabled(
|
||||
{
|
||||
CONVEX_DEPLOYMENT: "",
|
||||
CONVEX_SITE_URL: "https://clever-rabbit-123.convex.cloud",
|
||||
DEV_AUTH_CONVEX_DEPLOYMENT: "dev:clever-rabbit-123",
|
||||
DEV_AUTH_ENABLED: "1",
|
||||
DEV_AUTH_SECRET: CLOUD_DEV_AUTH_SECRET,
|
||||
DEV_AUTH_SITE_URL: "http://127.0.0.1:3211",
|
||||
},
|
||||
CLOUD_DEV_AUTH_SECRET,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects cloud dev deployments when the secret is missing", () => {
|
||||
expect(
|
||||
isLocalDevAuthEnabled({
|
||||
CONVEX_SITE_URL: "http://127.0.0.1:3211",
|
||||
CONVEX_SITE_URL: "https://clever-rabbit-123.convex.cloud",
|
||||
DEV_AUTH_CONVEX_DEPLOYMENT: "dev:clever-rabbit-123",
|
||||
DEV_AUTH_ENABLED: "1",
|
||||
CONVEX_DEPLOYMENT: "dev:clever-rabbit-123",
|
||||
DEV_AUTH_SECRET: CLOUD_DEV_AUTH_SECRET,
|
||||
DEV_AUTH_SITE_URL: "http://127.0.0.1:3211",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects cloud dev deployments when the configured secret is too short", () => {
|
||||
expect(
|
||||
isLocalDevAuthEnabled(
|
||||
{
|
||||
CONVEX_SITE_URL: "https://clever-rabbit-123.convex.cloud",
|
||||
DEV_AUTH_CONVEX_DEPLOYMENT: "dev:clever-rabbit-123",
|
||||
DEV_AUTH_ENABLED: "1",
|
||||
DEV_AUTH_SECRET: "short",
|
||||
DEV_AUTH_SITE_URL: "http://127.0.0.1:3211",
|
||||
},
|
||||
"short",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects cloud dev deployments without an explicit localhost dev auth site", () => {
|
||||
expect(
|
||||
isLocalDevAuthEnabled(
|
||||
{
|
||||
CONVEX_SITE_URL: "http://127.0.0.1:3211",
|
||||
DEV_AUTH_ENABLED: "1",
|
||||
DEV_AUTH_SECRET: CLOUD_DEV_AUTH_SECRET,
|
||||
CONVEX_DEPLOYMENT: "dev:clever-rabbit-123",
|
||||
},
|
||||
CLOUD_DEV_AUTH_SECRET,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects localhost site URLs without a local deployment marker", () => {
|
||||
expect(
|
||||
isLocalDevAuthEnabled({
|
||||
|
||||
+33
-3
@@ -3,18 +3,48 @@ type DevAuthEnv = {
|
||||
CONVEX_SITE_URL?: string;
|
||||
DEV_AUTH_CONVEX_DEPLOYMENT?: string;
|
||||
DEV_AUTH_ENABLED?: string;
|
||||
DEV_AUTH_SECRET?: string;
|
||||
DEV_AUTH_SITE_URL?: string;
|
||||
};
|
||||
|
||||
export function isLocalDevAuthEnabled(env: DevAuthEnv = process.env) {
|
||||
const MIN_CLOUD_DEV_AUTH_SECRET_LENGTH = 32;
|
||||
|
||||
export function isLocalDevAuthEnabled(env: DevAuthEnv = process.env, suppliedSecret?: string) {
|
||||
if (env.DEV_AUTH_ENABLED !== "1") return false;
|
||||
const deployment = env.CONVEX_DEPLOYMENT?.trim() || env.DEV_AUTH_CONVEX_DEPLOYMENT?.trim() || "";
|
||||
return isLocalConvexDeployment(deployment) && isLocalhostUrl(env.CONVEX_SITE_URL);
|
||||
const convexDeployment = env.CONVEX_DEPLOYMENT?.trim();
|
||||
const devAuthDeployment = env.DEV_AUTH_CONVEX_DEPLOYMENT?.trim();
|
||||
const deployment = convexDeployment || devAuthDeployment || "";
|
||||
|
||||
if (isLocalConvexDeployment(deployment)) {
|
||||
return isLocalhostUrl(env.CONVEX_SITE_URL);
|
||||
}
|
||||
|
||||
if (isDevConvexDeployment(deployment)) {
|
||||
return isLocalhostUrl(env.DEV_AUTH_SITE_URL) && hasValidCloudDevAuthSecret(env, suppliedSecret);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isLocalConvexDeployment(deployment: string) {
|
||||
return deployment.startsWith("local:") || deployment.startsWith("anonymous:");
|
||||
}
|
||||
|
||||
function isDevConvexDeployment(deployment: string) {
|
||||
return deployment.startsWith("dev:");
|
||||
}
|
||||
|
||||
function hasValidCloudDevAuthSecret(env: DevAuthEnv, suppliedSecret: string | undefined) {
|
||||
const expected = env.DEV_AUTH_SECRET?.trim();
|
||||
const actual = suppliedSecret?.trim();
|
||||
return Boolean(
|
||||
expected &&
|
||||
actual &&
|
||||
expected.length >= MIN_CLOUD_DEV_AUTH_SECRET_LENGTH &&
|
||||
actual === expected,
|
||||
);
|
||||
}
|
||||
|
||||
function isLocalhostUrl(value: string | undefined) {
|
||||
if (!value) return false;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export function assertLocalDevSeedAllowed(seedName: string): void {
|
||||
const deployment =
|
||||
process.env.CONVEX_DEPLOYMENT?.trim() || process.env.DEV_AUTH_CONVEX_DEPLOYMENT?.trim() || "";
|
||||
if (
|
||||
deployment.startsWith("dev:") ||
|
||||
deployment.startsWith("local:") ||
|
||||
deployment.startsWith("anonymous:")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!deployment &&
|
||||
(process.env.DEV_AUTH_ENABLED === "1" || process.env.CLAW_HUB_ENABLE_DEV_IMPERSONATION === "1")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
throw new Error(`${seedName} dev seed is disabled outside local/dev deployments`);
|
||||
}
|
||||
@@ -91,12 +91,7 @@ export function parseGitHubImportUrl(input: string): GitHubImportUrl {
|
||||
}
|
||||
|
||||
function canonicalGitHubImportUrl(url: URL) {
|
||||
const canonical = new URL(url.toString());
|
||||
canonical.username = "";
|
||||
canonical.password = "";
|
||||
canonical.search = "";
|
||||
canonical.hash = "";
|
||||
return `${canonical.origin}${canonical.pathname}`;
|
||||
return `https://${url.hostname}${url.pathname}`;
|
||||
}
|
||||
|
||||
export async function resolveGitHubCommit(
|
||||
|
||||
@@ -0,0 +1,565 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildGitHubSkillSourceSnapshot,
|
||||
buildGitHubSkillSyncPlan,
|
||||
parseSkillsShDisplayManifest,
|
||||
} from "./githubSkillSync";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function bytes(text: string) {
|
||||
return encoder.encode(text);
|
||||
}
|
||||
|
||||
function repoEntries(entries: Record<string, string>) {
|
||||
return Object.fromEntries(Object.entries(entries).map(([path, text]) => [path, bytes(text)]));
|
||||
}
|
||||
|
||||
describe("parseSkillsShDisplayManifest", () => {
|
||||
it("keeps the supported skills.sh rendering fields and drops invalid groups", () => {
|
||||
const result = parseSkillsShDisplayManifest(
|
||||
JSON.stringify({
|
||||
notGrouped: "top",
|
||||
groupings: [
|
||||
{
|
||||
title: "Agentic AI",
|
||||
description: "Agentic workflows.",
|
||||
skills: ["aiq-deploy", "nemoclaw-user-configure-security"],
|
||||
},
|
||||
{ title: "Broken", skills: [123] },
|
||||
{ description: "Missing title", skills: ["ignored"] },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: "ok",
|
||||
manifest: {
|
||||
notGrouped: "top",
|
||||
groupings: [
|
||||
{
|
||||
title: "Agentic AI",
|
||||
description: "Agentic workflows.",
|
||||
skills: ["aiq-deploy", "nemoclaw-user-configure-security"],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("marks missing and invalid manifests so the UI can fall back", () => {
|
||||
expect(parseSkillsShDisplayManifest(undefined)).toEqual({
|
||||
status: "missing",
|
||||
manifest: undefined,
|
||||
});
|
||||
expect(parseSkillsShDisplayManifest("{nope")).toEqual({
|
||||
status: "invalid",
|
||||
manifest: undefined,
|
||||
});
|
||||
expect(parseSkillsShDisplayManifest(JSON.stringify({ groupings: [] }))).toEqual({
|
||||
status: "invalid",
|
||||
manifest: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildGitHubSkillSourceSnapshot", () => {
|
||||
it("discovers skill folders, parses SKILL.md metadata, and hashes exact folder bytes", async () => {
|
||||
const baseEntries = repoEntries({
|
||||
"skills/aiq-deploy/SKILL.md":
|
||||
"---\nname: AIQ Deploy\nversion: 0.2.0\ndescription: Deploy AgentIQ workflows.\n---\n# AIQ Deploy\n",
|
||||
"skills/aiq-deploy/skill-card.md": "# Card\n",
|
||||
"skills/vision-helper/SKILL.md": "# Vision Helper\n",
|
||||
"skills.sh.json": JSON.stringify({
|
||||
groupings: [{ title: "Agentic AI", skills: ["aiq-deploy"] }],
|
||||
}),
|
||||
});
|
||||
const changedEntries = {
|
||||
...baseEntries,
|
||||
"skills/aiq-deploy/skill-card.md": bytes("# Card changed\n"),
|
||||
};
|
||||
|
||||
const base = await buildGitHubSkillSourceSnapshot({
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
commit: "1".repeat(40),
|
||||
entries: baseEntries,
|
||||
});
|
||||
const changed = await buildGitHubSkillSourceSnapshot({
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
commit: "2".repeat(40),
|
||||
entries: changedEntries,
|
||||
});
|
||||
|
||||
expect(base.manifestStatus).toBe("ok");
|
||||
expect(base.manifest).toEqual({
|
||||
groupings: [{ title: "Agentic AI", skills: ["aiq-deploy"] }],
|
||||
});
|
||||
expect(base.skills).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
summary: "Deploy AgentIQ workflows.",
|
||||
upstreamVersion: "0.2.0",
|
||||
path: "skills/aiq-deploy",
|
||||
skillMarkdownPath: "skills/aiq-deploy/SKILL.md",
|
||||
skillMarkdown:
|
||||
"---\nname: AIQ Deploy\nversion: 0.2.0\ndescription: Deploy AgentIQ workflows.\n---\n# AIQ Deploy\n",
|
||||
skillCardMarkdownPath: "skills/aiq-deploy/skill-card.md",
|
||||
skillCardMarkdown: "# Card\n",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
slug: "vision-helper",
|
||||
displayName: "Vision Helper",
|
||||
path: "skills/vision-helper",
|
||||
skillMarkdownPath: "skills/vision-helper/SKILL.md",
|
||||
skillMarkdown: "# Vision Helper\n",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(changed.skills.find((skill) => skill.slug === "aiq-deploy")?.contentHash).not.toBe(
|
||||
base.skills.find((skill) => skill.slug === "aiq-deploy")?.contentHash,
|
||||
);
|
||||
expect(changed.skills.find((skill) => skill.slug === "vision-helper")?.contentHash).toBe(
|
||||
base.skills.find((skill) => skill.slug === "vision-helper")?.contentHash,
|
||||
);
|
||||
});
|
||||
|
||||
it("includes valid filenames containing dot-dot text in folder hashes", async () => {
|
||||
const base = await buildGitHubSkillSourceSnapshot({
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
commit: "1".repeat(40),
|
||||
entries: repoEntries({
|
||||
"skills/aiq-deploy/SKILL.md": "# AIQ Deploy\n",
|
||||
"skills/aiq-deploy/payload..sh": "echo safe\n",
|
||||
}),
|
||||
});
|
||||
const changed = await buildGitHubSkillSourceSnapshot({
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
commit: "2".repeat(40),
|
||||
entries: repoEntries({
|
||||
"skills/aiq-deploy/SKILL.md": "# AIQ Deploy\n",
|
||||
"skills/aiq-deploy/payload..sh": "echo changed\n",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(changed.skills[0]?.contentHash).not.toBe(base.skills[0]?.contentHash);
|
||||
});
|
||||
|
||||
it("rejects duplicate normalized skill slugs before syncing content", async () => {
|
||||
await expect(
|
||||
buildGitHubSkillSourceSnapshot({
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
commit: "1".repeat(40),
|
||||
entries: repoEntries({
|
||||
"skills/aiq_deploy/SKILL.md": "# AIQ Deploy A\n",
|
||||
"skills/aiq-deploy/SKILL.md": "# AIQ Deploy B\n",
|
||||
}),
|
||||
}),
|
||||
).rejects.toThrow(/duplicate normalized slug/i);
|
||||
});
|
||||
|
||||
it("prefers the top-level skills catalog folder over duplicate plugin copies", async () => {
|
||||
const snapshot = await buildGitHubSkillSourceSnapshot({
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
commit: "1".repeat(40),
|
||||
entries: repoEntries({
|
||||
"skills/aiq-deploy/SKILL.md": "# AIQ Deploy\n",
|
||||
"plugins/nvidia-skills/skills/aiq-deploy/SKILL.md": "# Plugin Copy\n",
|
||||
"skills.sh.json": JSON.stringify({
|
||||
groupings: [{ title: "Agentic AI", skills: ["aiq-deploy"] }],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
expect(snapshot.skills.map((skill) => skill.path)).toEqual(["skills/aiq-deploy"]);
|
||||
expect(snapshot.skills[0]?.displayName).toBe("AIQ Deploy");
|
||||
});
|
||||
|
||||
it("rejects oversized cached markdown before writing Convex content docs", async () => {
|
||||
await expect(
|
||||
buildGitHubSkillSourceSnapshot({
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
commit: "1".repeat(40),
|
||||
entries: repoEntries({
|
||||
"skills/aiq-deploy/SKILL.md": `# AIQ Deploy\n${"x".repeat(513 * 1024)}`,
|
||||
}),
|
||||
}),
|
||||
).rejects.toThrow(/too large to cache/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildGitHubSkillSyncPlan", () => {
|
||||
it("marks changed upstream content pending", async () => {
|
||||
const snapshot = await buildGitHubSkillSourceSnapshot({
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
commit: "2".repeat(40),
|
||||
entries: repoEntries({
|
||||
"skills/aiq-deploy/SKILL.md": "# AIQ Deploy v2\n",
|
||||
"skills.sh.json": JSON.stringify({
|
||||
groupings: [{ title: "Agentic AI", skills: ["aiq-deploy"] }],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const plan = buildGitHubSkillSyncPlan({
|
||||
sourceId: "githubSkillSources:nvidia",
|
||||
ownerUserId: "users:nvidia",
|
||||
ownerPublisherId: "publishers:nvidia",
|
||||
existingSkills: [
|
||||
{
|
||||
_id: "skills:aiq-deploy",
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
githubPath: "skills/aiq-deploy",
|
||||
githubCurrentStatus: "present",
|
||||
githubCurrentContentHash: "old-hash",
|
||||
githubScanStatus: "clean",
|
||||
},
|
||||
],
|
||||
snapshot,
|
||||
now: 123,
|
||||
});
|
||||
|
||||
expect(plan.skillPatches).toEqual([
|
||||
expect.objectContaining({
|
||||
skillId: "skills:aiq-deploy",
|
||||
slug: "aiq-deploy",
|
||||
patch: expect.objectContaining({
|
||||
githubCurrentCommit: "2".repeat(40),
|
||||
githubCurrentContentHash: snapshot.skills[0]?.contentHash,
|
||||
githubScanStatus: "pending",
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "pending.scan",
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
expect(plan.skillInserts).toEqual([]);
|
||||
expect(plan.stats.changed).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps clean scan status when only the repo commit changes", async () => {
|
||||
const snapshot = await buildGitHubSkillSourceSnapshot({
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
commit: "2".repeat(40),
|
||||
entries: repoEntries({
|
||||
"skills/aiq-deploy/SKILL.md": "---\nversion: 0.2.0\n---\n# AIQ Deploy\n",
|
||||
}),
|
||||
});
|
||||
const contentHash = snapshot.skills[0]?.contentHash ?? "";
|
||||
|
||||
const plan = buildGitHubSkillSyncPlan({
|
||||
sourceId: "githubSkillSources:nvidia",
|
||||
ownerUserId: "users:nvidia",
|
||||
ownerPublisherId: "publishers:nvidia",
|
||||
existingSkills: [
|
||||
{
|
||||
_id: "skills:aiq-deploy",
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
latestVersionSummary: {
|
||||
version: "0.2.0",
|
||||
createdAt: 7,
|
||||
},
|
||||
githubPath: "skills/aiq-deploy",
|
||||
githubCurrentStatus: "present",
|
||||
githubCurrentContentHash: contentHash,
|
||||
githubScanStatus: "clean",
|
||||
},
|
||||
],
|
||||
snapshot,
|
||||
now: 123,
|
||||
});
|
||||
|
||||
expect(plan.skillPatches[0]?.patch).toMatchObject({
|
||||
githubCurrentCommit: "2".repeat(40),
|
||||
githubCurrentContentHash: contentHash,
|
||||
githubScanStatus: "clean",
|
||||
moderationStatus: "active",
|
||||
moderationVerdict: "clean",
|
||||
});
|
||||
expect(plan.skillPatches[0]?.patch).not.toHaveProperty("updatedAt");
|
||||
expect(plan.skillPatches[0]?.patch).not.toHaveProperty("latestVersionSummary");
|
||||
expect(plan.stats.unchanged).toBe(1);
|
||||
});
|
||||
|
||||
it("updates existing skill ownership when a source is reassigned", async () => {
|
||||
const snapshot = await buildGitHubSkillSourceSnapshot({
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
commit: "2".repeat(40),
|
||||
entries: repoEntries({
|
||||
"skills/aiq-deploy/SKILL.md": "# AIQ Deploy\n",
|
||||
}),
|
||||
});
|
||||
|
||||
const plan = buildGitHubSkillSyncPlan({
|
||||
sourceId: "githubSkillSources:nvidia",
|
||||
ownerUserId: "users:new-owner",
|
||||
ownerPublisherId: "publishers:new-owner",
|
||||
existingSkills: [
|
||||
{
|
||||
_id: "skills:aiq-deploy",
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
githubPath: "skills/aiq-deploy",
|
||||
githubCurrentStatus: "present",
|
||||
githubCurrentContentHash: snapshot.skills[0]?.contentHash ?? "",
|
||||
githubScanStatus: "clean",
|
||||
},
|
||||
],
|
||||
snapshot,
|
||||
now: 123,
|
||||
});
|
||||
|
||||
expect(plan.skillPatches[0]?.patch).toMatchObject({
|
||||
ownerUserId: "users:new-owner",
|
||||
ownerPublisherId: "publishers:new-owner",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves pending scan status for unchanged pending content", async () => {
|
||||
const snapshot = await buildGitHubSkillSourceSnapshot({
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
commit: "3".repeat(40),
|
||||
entries: repoEntries({
|
||||
"skills/aiq-deploy/SKILL.md": "# AIQ Deploy\n",
|
||||
}),
|
||||
});
|
||||
const contentHash = snapshot.skills[0]?.contentHash ?? "";
|
||||
|
||||
const plan = buildGitHubSkillSyncPlan({
|
||||
sourceId: "githubSkillSources:nvidia",
|
||||
ownerUserId: "users:nvidia",
|
||||
ownerPublisherId: "publishers:nvidia",
|
||||
existingSkills: [
|
||||
{
|
||||
_id: "skills:aiq-deploy",
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
githubPath: "skills/aiq-deploy",
|
||||
githubCurrentStatus: "present",
|
||||
githubCurrentContentHash: contentHash,
|
||||
githubScanStatus: "pending",
|
||||
},
|
||||
],
|
||||
snapshot,
|
||||
now: 123,
|
||||
});
|
||||
|
||||
expect(plan.skillPatches[0]?.patch).toMatchObject({
|
||||
githubCurrentCommit: "3".repeat(40),
|
||||
githubCurrentContentHash: contentHash,
|
||||
githubScanStatus: "pending",
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "pending.scan",
|
||||
});
|
||||
expect(plan.stats.unchanged).toBe(1);
|
||||
});
|
||||
|
||||
it("preserves terminal scan status for unchanged current content", async () => {
|
||||
const snapshot = await buildGitHubSkillSourceSnapshot({
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
commit: "3".repeat(40),
|
||||
entries: repoEntries({
|
||||
"skills/aiq-deploy/SKILL.md": "# AIQ Deploy\n",
|
||||
}),
|
||||
});
|
||||
const contentHash = snapshot.skills[0]?.contentHash ?? "";
|
||||
|
||||
const plan = buildGitHubSkillSyncPlan({
|
||||
sourceId: "githubSkillSources:nvidia",
|
||||
ownerUserId: "users:nvidia",
|
||||
ownerPublisherId: "publishers:nvidia",
|
||||
existingSkills: [
|
||||
{
|
||||
_id: "skills:aiq-deploy",
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
githubPath: "skills/aiq-deploy",
|
||||
githubCurrentStatus: "present",
|
||||
githubCurrentContentHash: contentHash,
|
||||
githubScanStatus: "malicious",
|
||||
},
|
||||
],
|
||||
snapshot,
|
||||
now: 123,
|
||||
});
|
||||
|
||||
expect(plan.skillPatches[0]?.patch).toMatchObject({
|
||||
githubCurrentContentHash: contentHash,
|
||||
githubScanStatus: "malicious",
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "scanner.llm.malicious",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves terminal scan status for unchanged current bytes", async () => {
|
||||
const snapshot = await buildGitHubSkillSourceSnapshot({
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
commit: "3".repeat(40),
|
||||
entries: repoEntries({
|
||||
"skills/aiq-deploy/SKILL.md": "# AIQ Deploy\n",
|
||||
}),
|
||||
});
|
||||
const contentHash = snapshot.skills[0]?.contentHash ?? "";
|
||||
|
||||
const plan = buildGitHubSkillSyncPlan({
|
||||
sourceId: "githubSkillSources:nvidia",
|
||||
ownerUserId: "users:nvidia",
|
||||
ownerPublisherId: "publishers:nvidia",
|
||||
existingSkills: [
|
||||
{
|
||||
_id: "skills:aiq-deploy",
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
githubPath: "skills/aiq-deploy",
|
||||
githubCurrentStatus: "present",
|
||||
githubCurrentContentHash: contentHash,
|
||||
githubScanStatus: "malicious",
|
||||
},
|
||||
],
|
||||
snapshot,
|
||||
now: 123,
|
||||
});
|
||||
|
||||
expect(plan.skillPatches[0]?.patch).toMatchObject({
|
||||
githubCurrentContentHash: contentHash,
|
||||
githubScanStatus: "malicious",
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "scanner.llm.malicious",
|
||||
});
|
||||
expect(plan.skillPatches[0]?.patch).not.toHaveProperty("updatedAt");
|
||||
expect(plan.stats.unchanged).toBe(1);
|
||||
});
|
||||
|
||||
it("revives soft-deleted skills when a configured repo is synced again", async () => {
|
||||
const snapshot = await buildGitHubSkillSourceSnapshot({
|
||||
repo: "mattpocock/skills",
|
||||
defaultBranch: "main",
|
||||
commit: "4".repeat(40),
|
||||
entries: repoEntries({
|
||||
"skills/engineering/tdd/SKILL.md": "# TDD\n",
|
||||
}),
|
||||
});
|
||||
|
||||
const plan = buildGitHubSkillSyncPlan({
|
||||
sourceId: "githubSkillSources:matt",
|
||||
ownerUserId: "users:matt",
|
||||
ownerPublisherId: "publishers:matt",
|
||||
existingSkills: [
|
||||
{
|
||||
_id: "skills:tdd",
|
||||
slug: "tdd",
|
||||
displayName: "TDD",
|
||||
githubPath: "skills/engineering/tdd",
|
||||
githubCurrentStatus: "missing",
|
||||
},
|
||||
],
|
||||
snapshot,
|
||||
now: 123,
|
||||
});
|
||||
|
||||
expect(plan.skillPatches[0]?.patch).toMatchObject({
|
||||
githubCurrentStatus: "present",
|
||||
githubRemovedAt: undefined,
|
||||
softDeletedAt: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("tombstones upstream removals instead of leaving stale installs active", async () => {
|
||||
const snapshot = await buildGitHubSkillSourceSnapshot({
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
commit: "2".repeat(40),
|
||||
entries: repoEntries({
|
||||
"skills/vision-helper/SKILL.md": "# Vision Helper\n",
|
||||
}),
|
||||
});
|
||||
|
||||
const plan = buildGitHubSkillSyncPlan({
|
||||
sourceId: "githubSkillSources:nvidia",
|
||||
ownerUserId: "users:nvidia",
|
||||
ownerPublisherId: "publishers:nvidia",
|
||||
existingSkills: [
|
||||
{
|
||||
_id: "skills:aiq-deploy",
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
githubPath: "skills/aiq-deploy",
|
||||
githubScanStatus: "clean",
|
||||
},
|
||||
],
|
||||
snapshot,
|
||||
now: 123,
|
||||
});
|
||||
|
||||
expect(plan.skillPatches).toEqual([
|
||||
expect.objectContaining({
|
||||
skillId: "skills:aiq-deploy",
|
||||
slug: "aiq-deploy",
|
||||
patch: expect.objectContaining({
|
||||
githubCurrentCommit: "2".repeat(40),
|
||||
githubCurrentStatus: "missing",
|
||||
githubRemovedAt: 123,
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "github.upstream.removed",
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
expect(plan.skillInserts).toHaveLength(1);
|
||||
expect(plan.stats.removed).toBe(1);
|
||||
});
|
||||
|
||||
it("preserves first upstream removal time on later syncs", async () => {
|
||||
const snapshot = await buildGitHubSkillSourceSnapshot({
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
commit: "3".repeat(40),
|
||||
entries: repoEntries({
|
||||
"skills/vision-helper/SKILL.md": "# Vision Helper\n",
|
||||
}),
|
||||
});
|
||||
|
||||
const plan = buildGitHubSkillSyncPlan({
|
||||
sourceId: "githubSkillSources:nvidia",
|
||||
ownerUserId: "users:nvidia",
|
||||
ownerPublisherId: "publishers:nvidia",
|
||||
existingSkills: [
|
||||
{
|
||||
_id: "skills:aiq-deploy",
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
githubPath: "skills/aiq-deploy",
|
||||
githubCurrentStatus: "missing",
|
||||
githubRemovedAt: 77,
|
||||
githubScanStatus: "clean",
|
||||
},
|
||||
],
|
||||
snapshot,
|
||||
now: 123,
|
||||
});
|
||||
|
||||
expect(plan.skillPatches[0]?.patch).toMatchObject({
|
||||
githubCurrentCommit: "3".repeat(40),
|
||||
githubCurrentStatus: "missing",
|
||||
githubCurrentCheckedAt: 123,
|
||||
githubRemovedAt: 77,
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "github.upstream.removed",
|
||||
});
|
||||
expect(plan.skillPatches[0]?.patch).not.toHaveProperty("updatedAt");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,669 @@
|
||||
import { getFrontmatterValue, parseFrontmatter } from "./skills";
|
||||
|
||||
export type GitHubSkillScanStatus = "clean" | "suspicious" | "malicious" | "pending" | "failed";
|
||||
export type GitHubCurrentStatus = "present" | "missing" | "unknown";
|
||||
export type DisplayManifestStatus = "ok" | "missing" | "invalid" | "failed";
|
||||
|
||||
export type DisplayManifest = {
|
||||
notGrouped?: "top" | "bottom";
|
||||
groupings: Array<{
|
||||
title: string;
|
||||
description?: string;
|
||||
skills: string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
export type GitHubSkillSourceSnapshot = {
|
||||
repo: string;
|
||||
defaultBranch: string;
|
||||
commit: string;
|
||||
manifestStatus: DisplayManifestStatus;
|
||||
manifestHash?: string;
|
||||
manifest?: DisplayManifest;
|
||||
skills: DiscoveredGitHubSkill[];
|
||||
};
|
||||
|
||||
export type GitHubSkillSourceMetadataSnapshot = Omit<GitHubSkillSourceSnapshot, "skills"> & {
|
||||
skills: DiscoveredGitHubSkillMetadata[];
|
||||
};
|
||||
|
||||
export type DiscoveredGitHubSkill = {
|
||||
slug: string;
|
||||
displayName: string;
|
||||
summary?: string;
|
||||
upstreamVersion?: string;
|
||||
path: string;
|
||||
skillMarkdownPath: string;
|
||||
skillMarkdown: string;
|
||||
skillCardMarkdownPath?: string;
|
||||
skillCardMarkdown?: string;
|
||||
contentHash: string;
|
||||
};
|
||||
|
||||
export type DiscoveredGitHubSkillMetadata = Omit<
|
||||
DiscoveredGitHubSkill,
|
||||
"skillMarkdown" | "skillCardMarkdown"
|
||||
>;
|
||||
|
||||
export type ExistingGitHubSkillForSync = {
|
||||
_id: string;
|
||||
slug: string;
|
||||
displayName: string;
|
||||
summary?: string;
|
||||
latestVersionSummary?: {
|
||||
version: string;
|
||||
createdAt: number;
|
||||
};
|
||||
githubPath?: string;
|
||||
githubCurrentCommit?: string;
|
||||
githubCurrentContentHash?: string;
|
||||
githubCurrentStatus?: GitHubCurrentStatus;
|
||||
githubScanStatus?: GitHubSkillScanStatus;
|
||||
githubRemovedAt?: number;
|
||||
};
|
||||
|
||||
export type GitHubBackedSkillModeration = {
|
||||
moderationStatus: "active" | "hidden";
|
||||
moderationReason?: string;
|
||||
moderationVerdict?: "clean" | "suspicious" | "malicious";
|
||||
moderationFlags: string[];
|
||||
isSuspicious: boolean;
|
||||
};
|
||||
|
||||
export type GitHubSkillPatchForSync = {
|
||||
skillId: string;
|
||||
slug: string;
|
||||
patch: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type GitHubSkillInsertForSync = {
|
||||
slug: string;
|
||||
doc: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type GitHubSkillSyncPlan = {
|
||||
sourcePatch: Record<string, unknown>;
|
||||
skillPatches: GitHubSkillPatchForSync[];
|
||||
skillInserts: GitHubSkillInsertForSync[];
|
||||
stats: {
|
||||
discovered: number;
|
||||
inserted: number;
|
||||
changed: number;
|
||||
unchanged: number;
|
||||
removed: number;
|
||||
};
|
||||
};
|
||||
|
||||
const SKILL_MARKDOWN_BASENAME = "skill.md";
|
||||
const SKILL_CARD_MARKDOWN_BASENAME = "skill-card.md";
|
||||
const MAX_STORED_MARKDOWN_BYTES = 512 * 1024;
|
||||
const MAX_STORED_SKILL_CONTENT_BYTES = 768 * 1024;
|
||||
|
||||
export function parseSkillsShDisplayManifest(raw: string | undefined | null): {
|
||||
status: DisplayManifestStatus;
|
||||
manifest?: DisplayManifest;
|
||||
} {
|
||||
if (raw === undefined || raw === null) return { status: "missing", manifest: undefined };
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return { status: "invalid", manifest: undefined };
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return { status: "invalid", manifest: undefined };
|
||||
}
|
||||
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const rawGroups = record.groupings;
|
||||
if (!Array.isArray(rawGroups)) return { status: "invalid", manifest: undefined };
|
||||
|
||||
const groupings = rawGroups.flatMap((group): DisplayManifest["groupings"] => {
|
||||
if (!group || typeof group !== "object" || Array.isArray(group)) return [];
|
||||
const groupRecord = group as Record<string, unknown>;
|
||||
const title = typeof groupRecord.title === "string" ? groupRecord.title.trim() : "";
|
||||
const description =
|
||||
typeof groupRecord.description === "string" ? groupRecord.description.trim() : "";
|
||||
const skills = Array.isArray(groupRecord.skills)
|
||||
? groupRecord.skills
|
||||
.filter((value): value is string => typeof value === "string")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
if (!title || skills.length === 0) return [];
|
||||
return [
|
||||
{
|
||||
title,
|
||||
...(description ? { description } : {}),
|
||||
skills,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
if (groupings.length === 0) return { status: "invalid", manifest: undefined };
|
||||
|
||||
const notGrouped =
|
||||
record.notGrouped === "top" || record.notGrouped === "bottom" ? record.notGrouped : undefined;
|
||||
return {
|
||||
status: "ok",
|
||||
manifest: {
|
||||
...(notGrouped ? { notGrouped } : {}),
|
||||
groupings,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildGitHubSkillSourceSnapshot({
|
||||
repo,
|
||||
defaultBranch,
|
||||
commit,
|
||||
entries,
|
||||
}: {
|
||||
repo: string;
|
||||
defaultBranch: string;
|
||||
commit: string;
|
||||
entries: Record<string, Uint8Array>;
|
||||
}): Promise<GitHubSkillSourceSnapshot> {
|
||||
const normalizedEntries = normalizeEntryMap(entries);
|
||||
const manifestBytes = normalizedEntries["skills.sh.json"];
|
||||
const manifestText = manifestBytes ? decodeUtf8(manifestBytes) : undefined;
|
||||
const parsedManifest = parseSkillsShDisplayManifest(manifestText);
|
||||
const manifestHash = manifestBytes ? await sha256Hex(manifestBytes) : undefined;
|
||||
const skillPaths = discoverSkillPaths(normalizedEntries);
|
||||
const skills: DiscoveredGitHubSkill[] = [];
|
||||
|
||||
for (const skillMdPath of skillPaths) {
|
||||
const path = parentPath(skillMdPath);
|
||||
const markdownBytes = normalizedEntries[skillMdPath] ?? new Uint8Array();
|
||||
assertStoredMarkdownSize(skillMdPath, markdownBytes);
|
||||
const markdown = decodeUtf8(markdownBytes);
|
||||
const frontmatter = parseFrontmatter(markdown);
|
||||
const folderName = path.split("/").filter(Boolean).at(-1) ?? "";
|
||||
const slug = slugFromPathSegment(folderName);
|
||||
if (!slug) continue;
|
||||
const frontmatterName = getFrontmatterValue(frontmatter, "name")?.trim();
|
||||
const frontmatterDescription = getFrontmatterValue(frontmatter, "description")?.trim();
|
||||
const frontmatterVersion = getFrontmatterValue(frontmatter, "version")?.trim();
|
||||
const heading = firstMarkdownHeading(markdown);
|
||||
const skillCardMarkdownPath = findFolderFilePath(
|
||||
normalizedEntries,
|
||||
path,
|
||||
SKILL_CARD_MARKDOWN_BASENAME,
|
||||
);
|
||||
const skillCardBytes = skillCardMarkdownPath
|
||||
? normalizedEntries[skillCardMarkdownPath]
|
||||
: undefined;
|
||||
if (skillCardMarkdownPath && skillCardBytes) {
|
||||
assertStoredMarkdownSize(skillCardMarkdownPath, skillCardBytes);
|
||||
assertStoredSkillContentSize(markdownBytes.byteLength + skillCardBytes.byteLength);
|
||||
} else {
|
||||
assertStoredSkillContentSize(markdownBytes.byteLength);
|
||||
}
|
||||
const skillCardMarkdown = skillCardBytes ? decodeUtf8(skillCardBytes) : undefined;
|
||||
|
||||
skills.push({
|
||||
slug,
|
||||
displayName: frontmatterName || heading || titleizeSlug(slug),
|
||||
...(frontmatterDescription ? { summary: frontmatterDescription } : {}),
|
||||
...(frontmatterVersion ? { upstreamVersion: frontmatterVersion } : {}),
|
||||
path,
|
||||
skillMarkdownPath: skillMdPath,
|
||||
skillMarkdown: markdown,
|
||||
...(skillCardMarkdownPath ? { skillCardMarkdownPath } : {}),
|
||||
...(skillCardMarkdown !== undefined ? { skillCardMarkdown } : {}),
|
||||
contentHash: await computeGitHubSkillFolderContentHash(normalizedEntries, path),
|
||||
});
|
||||
}
|
||||
|
||||
const sortedSkills = skills.sort((a, b) => a.path.localeCompare(b.path));
|
||||
assertUniqueDiscoveredSlugs(sortedSkills);
|
||||
|
||||
return {
|
||||
repo,
|
||||
defaultBranch,
|
||||
commit,
|
||||
manifestStatus: parsedManifest.status,
|
||||
...(manifestHash ? { manifestHash } : {}),
|
||||
...(parsedManifest.manifest ? { manifest: parsedManifest.manifest } : {}),
|
||||
skills: sortedSkills,
|
||||
};
|
||||
}
|
||||
|
||||
export async function computeGitHubSkillFolderContentHash(
|
||||
entries: Record<string, Uint8Array>,
|
||||
folderPath: string,
|
||||
) {
|
||||
const normalizedEntries = normalizeEntryMap(entries);
|
||||
const root = folderPath ? `${folderPath}/` : "";
|
||||
const lines: string[] = [];
|
||||
for (const [path, content] of Object.entries(normalizedEntries).sort(([a], [b]) =>
|
||||
a.localeCompare(b),
|
||||
)) {
|
||||
if (root && path !== folderPath && !path.startsWith(root)) continue;
|
||||
if (!root && path.includes("/")) continue;
|
||||
const relativePath = root ? path.slice(root.length) : path;
|
||||
if (!relativePath) continue;
|
||||
const fileHash = await sha256Hex(content);
|
||||
lines.push(`${relativePath}\0${content.byteLength}\0${fileHash}`);
|
||||
}
|
||||
return sha256Hex(new TextEncoder().encode(lines.join("\n")));
|
||||
}
|
||||
|
||||
export function buildGitHubSkillSyncPlan({
|
||||
sourceId,
|
||||
ownerUserId,
|
||||
ownerPublisherId,
|
||||
existingSkills,
|
||||
snapshot,
|
||||
now,
|
||||
}: {
|
||||
sourceId: string;
|
||||
ownerUserId: string;
|
||||
ownerPublisherId?: string;
|
||||
existingSkills: ExistingGitHubSkillForSync[];
|
||||
snapshot: GitHubSkillSourceSnapshot | GitHubSkillSourceMetadataSnapshot;
|
||||
now: number;
|
||||
}): GitHubSkillSyncPlan {
|
||||
const sourcePatch = {
|
||||
repo: snapshot.repo,
|
||||
defaultBranch: snapshot.defaultBranch,
|
||||
lastSyncStatus: "ok",
|
||||
lastSyncError: undefined,
|
||||
lastSyncErrorAt: undefined,
|
||||
displayManifestKind: "skills.sh",
|
||||
displayManifestHash: snapshot.manifestHash,
|
||||
displayManifestCommit: snapshot.commit,
|
||||
displayManifestFetchedAt: now,
|
||||
displayManifestStatus: snapshot.manifestStatus,
|
||||
displayManifest: snapshot.manifest,
|
||||
...(ownerPublisherId ? { ownerPublisherId } : {}),
|
||||
updatedAt: now,
|
||||
};
|
||||
const existingByPath = new Map(
|
||||
existingSkills
|
||||
.filter((skill) => skill.githubPath)
|
||||
.map((skill) => [skill.githubPath as string, skill]),
|
||||
);
|
||||
const existingBySlug = new Map(existingSkills.map((skill) => [skill.slug, skill]));
|
||||
const matchedSkillIds = new Set<string>();
|
||||
const skillPatches: GitHubSkillPatchForSync[] = [];
|
||||
const skillInserts: GitHubSkillInsertForSync[] = [];
|
||||
const stats = {
|
||||
discovered: snapshot.skills.length,
|
||||
inserted: 0,
|
||||
changed: 0,
|
||||
unchanged: 0,
|
||||
removed: 0,
|
||||
};
|
||||
|
||||
for (const discovered of snapshot.skills) {
|
||||
const existing = existingByPath.get(discovered.path) ?? existingBySlug.get(discovered.slug);
|
||||
if (!existing) {
|
||||
const scanStatus: GitHubSkillScanStatus = "pending";
|
||||
const moderation = githubBackedSkillModeration(scanStatus);
|
||||
skillInserts.push({
|
||||
slug: discovered.slug,
|
||||
doc: {
|
||||
slug: discovered.slug,
|
||||
displayName: discovered.displayName,
|
||||
summary: discovered.summary,
|
||||
ownerUserId,
|
||||
ownerPublisherId,
|
||||
installKind: "github",
|
||||
githubSourceId: sourceId,
|
||||
githubPath: discovered.path,
|
||||
githubHasSkillCard: Boolean(discovered.skillCardMarkdownPath),
|
||||
githubCurrentCommit: snapshot.commit,
|
||||
githubCurrentContentHash: discovered.contentHash,
|
||||
githubCurrentStatus: "present",
|
||||
githubCurrentCheckedAt: now,
|
||||
githubScanStatus: scanStatus,
|
||||
githubRemovedAt: undefined,
|
||||
latestVersionId: undefined,
|
||||
latestVersionSummary: latestVersionSummary(discovered.upstreamVersion, now),
|
||||
tags: {},
|
||||
capabilityTags: [],
|
||||
softDeletedAt: undefined,
|
||||
badges: undefined,
|
||||
statsDownloads: 0,
|
||||
statsStars: 0,
|
||||
statsInstallsCurrent: 0,
|
||||
statsInstallsAllTime: 0,
|
||||
stats: {
|
||||
downloads: 0,
|
||||
stars: 0,
|
||||
installsCurrent: 0,
|
||||
installsAllTime: 0,
|
||||
versions: 0,
|
||||
comments: 0,
|
||||
},
|
||||
...moderation,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
stats.inserted += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
matchedSkillIds.add(existing._id);
|
||||
const currentContentUnchanged =
|
||||
existing.githubCurrentStatus === "present" &&
|
||||
existing.githubCurrentContentHash === discovered.contentHash;
|
||||
const scanStatus: GitHubSkillScanStatus = currentContentUnchanged
|
||||
? githubScanStatusForUnchangedContent(existing.githubScanStatus)
|
||||
: "pending";
|
||||
const moderation = githubBackedSkillModeration(scanStatus);
|
||||
const nextLatestVersionSummary = latestVersionSummary(
|
||||
discovered.upstreamVersion,
|
||||
existing.latestVersionSummary?.createdAt ?? now,
|
||||
);
|
||||
const materialChanged =
|
||||
!currentContentUnchanged ||
|
||||
existing.displayName !== discovered.displayName ||
|
||||
(existing.summary ?? undefined) !== (discovered.summary ?? undefined) ||
|
||||
(existing.githubPath ?? undefined) !== discovered.path ||
|
||||
!sameLatestVersionSummary(existing.latestVersionSummary, nextLatestVersionSummary);
|
||||
const patch = {
|
||||
displayName: discovered.displayName,
|
||||
summary: discovered.summary,
|
||||
ownerUserId,
|
||||
...(ownerPublisherId ? { ownerPublisherId } : {}),
|
||||
githubSourceId: sourceId,
|
||||
githubPath: discovered.path,
|
||||
githubHasSkillCard: Boolean(discovered.skillCardMarkdownPath),
|
||||
githubCurrentCommit: snapshot.commit,
|
||||
githubCurrentContentHash: discovered.contentHash,
|
||||
githubCurrentStatus: "present",
|
||||
githubCurrentCheckedAt: now,
|
||||
githubScanStatus: scanStatus,
|
||||
githubRemovedAt: undefined,
|
||||
softDeletedAt: undefined,
|
||||
...(materialChanged
|
||||
? {
|
||||
latestVersionSummary: latestVersionSummary(discovered.upstreamVersion, now),
|
||||
updatedAt: now,
|
||||
}
|
||||
: {}),
|
||||
...moderation,
|
||||
};
|
||||
skillPatches.push({ skillId: existing._id, slug: existing.slug, patch });
|
||||
if (materialChanged) stats.changed += 1;
|
||||
else stats.unchanged += 1;
|
||||
}
|
||||
|
||||
for (const existing of existingSkills) {
|
||||
if (matchedSkillIds.has(existing._id)) continue;
|
||||
const removedAt = existing.githubRemovedAt ?? now;
|
||||
const moderation = githubBackedSkillModeration(
|
||||
existing.githubScanStatus ?? "pending",
|
||||
removedAt,
|
||||
);
|
||||
const wasAlreadyRemoved =
|
||||
existing.githubCurrentStatus === "missing" && existing.githubRemovedAt !== undefined;
|
||||
skillPatches.push({
|
||||
skillId: existing._id,
|
||||
slug: existing.slug,
|
||||
patch: {
|
||||
githubCurrentCommit: snapshot.commit,
|
||||
githubCurrentStatus: "missing",
|
||||
githubCurrentCheckedAt: now,
|
||||
githubRemovedAt: removedAt,
|
||||
...(wasAlreadyRemoved ? {} : { updatedAt: now }),
|
||||
...moderation,
|
||||
},
|
||||
});
|
||||
stats.removed += 1;
|
||||
}
|
||||
|
||||
return { sourcePatch, skillPatches, skillInserts, stats };
|
||||
}
|
||||
|
||||
function githubScanStatusForUnchangedContent(
|
||||
status: GitHubSkillScanStatus | undefined,
|
||||
): GitHubSkillScanStatus {
|
||||
if (
|
||||
status === "clean" ||
|
||||
status === "failed" ||
|
||||
status === "malicious" ||
|
||||
status === "suspicious"
|
||||
) {
|
||||
return status;
|
||||
}
|
||||
return "pending";
|
||||
}
|
||||
|
||||
export function githubBackedSkillModeration(
|
||||
scanStatus: GitHubSkillScanStatus,
|
||||
removedAt?: number,
|
||||
): GitHubBackedSkillModeration {
|
||||
if (typeof removedAt === "number") {
|
||||
return {
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "github.upstream.removed",
|
||||
moderationVerdict: undefined,
|
||||
moderationFlags: [],
|
||||
isSuspicious: false,
|
||||
};
|
||||
}
|
||||
if (scanStatus === "pending") {
|
||||
return {
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "pending.scan",
|
||||
moderationVerdict: undefined,
|
||||
moderationFlags: [],
|
||||
isSuspicious: false,
|
||||
};
|
||||
}
|
||||
if (scanStatus === "failed") {
|
||||
return {
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "scanner.failed",
|
||||
moderationVerdict: undefined,
|
||||
moderationFlags: [],
|
||||
isSuspicious: false,
|
||||
};
|
||||
}
|
||||
if (scanStatus === "malicious") {
|
||||
return {
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "scanner.llm.malicious",
|
||||
moderationVerdict: "malicious",
|
||||
moderationFlags: ["blocked.malware"],
|
||||
isSuspicious: true,
|
||||
};
|
||||
}
|
||||
if (scanStatus === "suspicious") {
|
||||
return {
|
||||
moderationStatus: "active",
|
||||
moderationReason: "scanner.llm.suspicious",
|
||||
moderationVerdict: "suspicious",
|
||||
moderationFlags: ["flagged.suspicious"],
|
||||
isSuspicious: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
moderationStatus: "active",
|
||||
moderationReason: undefined,
|
||||
moderationVerdict: "clean",
|
||||
moderationFlags: [],
|
||||
isSuspicious: false,
|
||||
};
|
||||
}
|
||||
|
||||
function latestVersionSummary(version: string | undefined, now: number) {
|
||||
if (!version) return undefined;
|
||||
return {
|
||||
version,
|
||||
createdAt: now,
|
||||
changelog: "Synced from GitHub source.",
|
||||
changelogSource: "auto" as const,
|
||||
};
|
||||
}
|
||||
|
||||
function sameLatestVersionSummary(
|
||||
a: ExistingGitHubSkillForSync["latestVersionSummary"] | undefined,
|
||||
b: ReturnType<typeof latestVersionSummary>,
|
||||
) {
|
||||
if (!a && !b) return true;
|
||||
if (!a || !b) return false;
|
||||
return a.version === b.version;
|
||||
}
|
||||
|
||||
function assertStoredMarkdownSize(path: string, bytes: Uint8Array) {
|
||||
if (bytes.byteLength > MAX_STORED_MARKDOWN_BYTES) {
|
||||
throw new Error(`GitHub skill markdown file is too large to cache: ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertStoredSkillContentSize(totalBytes: number) {
|
||||
if (totalBytes > MAX_STORED_SKILL_CONTENT_BYTES) {
|
||||
throw new Error("GitHub skill cached markdown is too large");
|
||||
}
|
||||
}
|
||||
|
||||
function assertUniqueDiscoveredSlugs(skills: DiscoveredGitHubSkill[]) {
|
||||
const firstPathBySlug = new Map<string, string>();
|
||||
for (const skill of skills) {
|
||||
const firstPath = firstPathBySlug.get(skill.slug);
|
||||
if (firstPath) {
|
||||
throw duplicateSkillSlugError(skill.slug, firstPath, skill.path);
|
||||
}
|
||||
firstPathBySlug.set(skill.slug, skill.path);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeEntryMap(entries: Record<string, Uint8Array>) {
|
||||
const out: Record<string, Uint8Array> = {};
|
||||
for (const [rawPath, bytes] of Object.entries(entries)) {
|
||||
const normalized = normalizeRepoPath(rawPath);
|
||||
if (!normalized) continue;
|
||||
out[normalized] = new Uint8Array(bytes);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function discoverSkillPaths(entries: Record<string, Uint8Array>) {
|
||||
const candidates = Object.keys(entries)
|
||||
.filter((path) => path.split("/").at(-1)?.toLowerCase() === SKILL_MARKDOWN_BASENAME)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
const pathsBySlug = new Map<string, string[]>();
|
||||
|
||||
for (const skillMdPath of candidates) {
|
||||
const path = parentPath(skillMdPath);
|
||||
const folderName = path.split("/").filter(Boolean).at(-1) ?? "";
|
||||
const slug = slugFromPathSegment(folderName);
|
||||
if (!slug) continue;
|
||||
const paths = pathsBySlug.get(slug) ?? [];
|
||||
paths.push(skillMdPath);
|
||||
pathsBySlug.set(slug, paths);
|
||||
}
|
||||
|
||||
const selected: string[] = [];
|
||||
for (const [slug, paths] of pathsBySlug) {
|
||||
if (paths.length === 1) {
|
||||
selected.push(paths[0] as string);
|
||||
continue;
|
||||
}
|
||||
|
||||
const canonicalPath = `skills/${slug}/${SKILL_MARKDOWN_BASENAME}`;
|
||||
const exactTopLevelMatches = paths.filter((path) => path.toLowerCase() === canonicalPath);
|
||||
const topLevelSkillMatches = paths.filter((path) => path.toLowerCase().startsWith("skills/"));
|
||||
if (exactTopLevelMatches.length === 1 && topLevelSkillMatches.length === 1) {
|
||||
selected.push(exactTopLevelMatches[0] as string);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw duplicateSkillSlugError(
|
||||
slug,
|
||||
parentPath(paths[0] as string),
|
||||
parentPath(paths[1] as string),
|
||||
);
|
||||
}
|
||||
|
||||
return selected.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function duplicateSkillSlugError(slug: string, firstPath: string, secondPath: string) {
|
||||
return new Error(
|
||||
`GitHub skill source has duplicate normalized slug "${slug}" at ${firstPath} and ${secondPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
function findFolderFilePath(
|
||||
entries: Record<string, Uint8Array>,
|
||||
folderPath: string,
|
||||
basename: string,
|
||||
) {
|
||||
const prefix = folderPath ? `${folderPath}/` : "";
|
||||
return Object.keys(entries).find((entryPath) => {
|
||||
if (prefix) {
|
||||
if (!entryPath.startsWith(prefix)) return false;
|
||||
const relativePath = entryPath.slice(prefix.length);
|
||||
return !relativePath.includes("/") && relativePath.toLowerCase() === basename;
|
||||
}
|
||||
return !entryPath.includes("/") && entryPath.toLowerCase() === basename;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRepoPath(path: string) {
|
||||
if (path.includes("\u0000")) return "";
|
||||
const normalized = path
|
||||
.replaceAll("\\", "/")
|
||||
.trim()
|
||||
.replace(/^\.\/+/, "")
|
||||
.replace(/^\/+/, "");
|
||||
if (!normalized) return "";
|
||||
const segments = normalized.split("/").filter(Boolean);
|
||||
if (segments.some((segment) => segment === "." || segment === "..")) return "";
|
||||
return segments.join("/");
|
||||
}
|
||||
|
||||
function parentPath(path: string) {
|
||||
return path.split("/").slice(0, -1).join("/");
|
||||
}
|
||||
|
||||
function decodeUtf8(bytes: Uint8Array) {
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
async function sha256Hex(bytes: Uint8Array) {
|
||||
const safe = new Uint8Array(bytes);
|
||||
const buffer = safe.buffer.slice(safe.byteOffset, safe.byteOffset + safe.byteLength);
|
||||
const digest = await crypto.subtle.digest("SHA-256", buffer);
|
||||
return toHex(new Uint8Array(digest));
|
||||
}
|
||||
|
||||
function toHex(bytes: Uint8Array) {
|
||||
let out = "";
|
||||
for (const byte of bytes) out += byte.toString(16).padStart(2, "0");
|
||||
return out;
|
||||
}
|
||||
|
||||
function slugFromPathSegment(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_]+/g, "-")
|
||||
.replace(/[^a-z0-9-]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
function titleizeSlug(slug: string) {
|
||||
return slug
|
||||
.split("-")
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function firstMarkdownHeading(markdown: string) {
|
||||
for (const line of markdown.split(/\r?\n/)) {
|
||||
const match = /^#\s+(.+)$/.exec(line.trim());
|
||||
if (match?.[1]) return match[1].trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSkillInstallResolution } from "./installResolver";
|
||||
|
||||
const baseSkill = {
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
latestVersionSummary: null,
|
||||
installKind: "github" as const,
|
||||
githubPath: "skills/aiq-deploy",
|
||||
githubCurrentCommit: "1".repeat(40),
|
||||
githubCurrentContentHash: "hash-aiq-deploy",
|
||||
githubCurrentStatus: "present" as const,
|
||||
githubScanStatus: "clean" as const,
|
||||
githubRemovedAt: undefined,
|
||||
};
|
||||
|
||||
const source = {
|
||||
repo: "NVIDIA/skills",
|
||||
defaultBranch: "main",
|
||||
};
|
||||
|
||||
describe("buildSkillInstallResolution", () => {
|
||||
it("returns an archive descriptor for hosted direct uploads", () => {
|
||||
const resolution = buildSkillInstallResolution({
|
||||
origin: "https://clawhub.ai",
|
||||
skill: {
|
||||
slug: "direct-skill",
|
||||
displayName: "Direct Skill",
|
||||
latestVersionSummary: { version: "1.2.3" },
|
||||
},
|
||||
source: null,
|
||||
});
|
||||
|
||||
expect(resolution).toEqual({
|
||||
ok: true,
|
||||
slug: "direct-skill",
|
||||
installKind: "archive",
|
||||
archive: {
|
||||
version: "1.2.3",
|
||||
downloadUrl: "https://clawhub.ai/api/v1/download?slug=direct-skill&version=1.2.3",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a pinned GitHub descriptor when current upstream state is scan-clean", () => {
|
||||
const resolution = buildSkillInstallResolution({
|
||||
origin: "https://clawhub.ai",
|
||||
skill: baseSkill,
|
||||
source,
|
||||
});
|
||||
|
||||
expect(resolution).toEqual({
|
||||
ok: true,
|
||||
slug: "aiq-deploy",
|
||||
installKind: "github",
|
||||
github: {
|
||||
repo: "NVIDIA/skills",
|
||||
path: "skills/aiq-deploy",
|
||||
commit: "1".repeat(40),
|
||||
contentHash: "hash-aiq-deploy",
|
||||
sourceUrl: `https://github.com/NVIDIA/skills/tree/${"1".repeat(40)}/skills/aiq-deploy`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("allows GitHub-backed installs when upstream content changed and the current hash is clean", () => {
|
||||
const resolution = buildSkillInstallResolution({
|
||||
origin: "https://clawhub.ai",
|
||||
skill: {
|
||||
...baseSkill,
|
||||
githubCurrentCommit: "2".repeat(40),
|
||||
githubCurrentContentHash: "hash-aiq-deploy-v2",
|
||||
},
|
||||
source,
|
||||
});
|
||||
|
||||
expect(resolution).toMatchObject({
|
||||
ok: true,
|
||||
installKind: "github",
|
||||
github: {
|
||||
commit: "2".repeat(40),
|
||||
contentHash: "hash-aiq-deploy-v2",
|
||||
sourceUrl: `https://github.com/NVIDIA/skills/tree/${"2".repeat(40)}/skills/aiq-deploy`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("allows GitHub-backed installs when only unrelated repository content changed", () => {
|
||||
const resolution = buildSkillInstallResolution({
|
||||
origin: "https://clawhub.ai",
|
||||
skill: {
|
||||
...baseSkill,
|
||||
githubCurrentCommit: "2".repeat(40),
|
||||
githubCurrentContentHash: baseSkill.githubCurrentContentHash,
|
||||
},
|
||||
source,
|
||||
});
|
||||
|
||||
expect(resolution).toMatchObject({
|
||||
ok: true,
|
||||
installKind: "github",
|
||||
github: {
|
||||
commit: "2".repeat(40),
|
||||
contentHash: "hash-aiq-deploy",
|
||||
sourceUrl: `https://github.com/NVIDIA/skills/tree/${"2".repeat(40)}/skills/aiq-deploy`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "upstream path is missing",
|
||||
patch: { githubCurrentStatus: "missing" as const },
|
||||
reason: "github_upstream_missing",
|
||||
status: 410,
|
||||
},
|
||||
{
|
||||
name: "skill was pulled upstream",
|
||||
patch: { githubRemovedAt: 456 },
|
||||
reason: "github_upstream_removed",
|
||||
status: 410,
|
||||
},
|
||||
{
|
||||
name: "scan is pending",
|
||||
patch: { githubScanStatus: "pending" as const },
|
||||
reason: "github_verification_pending",
|
||||
status: 423,
|
||||
},
|
||||
{
|
||||
name: "scan failed",
|
||||
patch: { githubScanStatus: "failed" as const },
|
||||
reason: "github_scan_failed",
|
||||
status: 403,
|
||||
},
|
||||
{
|
||||
name: "scan is suspicious",
|
||||
patch: { githubScanStatus: "suspicious" as const },
|
||||
reason: "github_scan_failed",
|
||||
status: 403,
|
||||
},
|
||||
])("blocks GitHub-backed installs when $name", ({ patch, reason, status }) => {
|
||||
const resolution = buildSkillInstallResolution({
|
||||
origin: "https://clawhub.ai",
|
||||
skill: { ...baseSkill, ...patch },
|
||||
source,
|
||||
});
|
||||
|
||||
expect(resolution).toMatchObject({
|
||||
ok: false,
|
||||
slug: "aiq-deploy",
|
||||
reason,
|
||||
status,
|
||||
});
|
||||
});
|
||||
|
||||
it("explains pending GitHub-backed verification clearly", () => {
|
||||
const resolution = buildSkillInstallResolution({
|
||||
origin: "https://clawhub.ai",
|
||||
skill: {
|
||||
...baseSkill,
|
||||
githubScanStatus: "pending",
|
||||
},
|
||||
source,
|
||||
});
|
||||
|
||||
expect(resolution).toMatchObject({
|
||||
ok: false,
|
||||
slug: "aiq-deploy",
|
||||
reason: "github_verification_pending",
|
||||
status: 423,
|
||||
message:
|
||||
"GitHub-backed skill security scan is in progress. Try again shortly, or rerun with --force-install to install the unverified upstream commit.",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows force-install for pending GitHub-backed verification", () => {
|
||||
const resolution = buildSkillInstallResolution({
|
||||
origin: "https://clawhub.ai",
|
||||
skill: {
|
||||
...baseSkill,
|
||||
githubScanStatus: "pending",
|
||||
},
|
||||
source,
|
||||
forceInstall: true,
|
||||
});
|
||||
|
||||
expect(resolution).toMatchObject({
|
||||
ok: true,
|
||||
installKind: "github",
|
||||
github: {
|
||||
commit: "1".repeat(40),
|
||||
contentHash: "hash-aiq-deploy",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not force-install failed GitHub-backed scans", () => {
|
||||
const resolution = buildSkillInstallResolution({
|
||||
origin: "https://clawhub.ai",
|
||||
skill: {
|
||||
...baseSkill,
|
||||
githubScanStatus: "failed",
|
||||
},
|
||||
source,
|
||||
forceInstall: true,
|
||||
});
|
||||
|
||||
expect(resolution).toMatchObject({
|
||||
ok: false,
|
||||
reason: "github_scan_failed",
|
||||
status: 403,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
export type GitHubSkillScanStatus = "clean" | "suspicious" | "malicious" | "pending" | "failed";
|
||||
export type GitHubCurrentStatus = "present" | "missing" | "unknown";
|
||||
|
||||
export type InstallResolverSkill = {
|
||||
slug: string;
|
||||
displayName: string;
|
||||
latestVersionSummary?: { version: string } | null;
|
||||
installKind?: "github";
|
||||
githubPath?: string;
|
||||
githubCurrentCommit?: string;
|
||||
githubCurrentContentHash?: string;
|
||||
githubCurrentStatus?: GitHubCurrentStatus;
|
||||
githubScanStatus?: GitHubSkillScanStatus;
|
||||
githubRemovedAt?: number;
|
||||
};
|
||||
|
||||
export type InstallResolverSource = {
|
||||
repo: string;
|
||||
defaultBranch?: string | null;
|
||||
};
|
||||
|
||||
export type SkillInstallResolution =
|
||||
| {
|
||||
ok: true;
|
||||
slug: string;
|
||||
installKind: "archive";
|
||||
archive: {
|
||||
version: string;
|
||||
downloadUrl: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
ok: true;
|
||||
slug: string;
|
||||
installKind: "github";
|
||||
github: {
|
||||
repo: string;
|
||||
path: string;
|
||||
commit: string;
|
||||
contentHash: string;
|
||||
sourceUrl: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
slug: string;
|
||||
reason:
|
||||
| "archive_version_missing"
|
||||
| "github_source_missing"
|
||||
| "github_upstream_removed"
|
||||
| "github_upstream_missing"
|
||||
| "github_upstream_unknown"
|
||||
| "github_verification_pending"
|
||||
| "github_scan_failed";
|
||||
message: string;
|
||||
status: 403 | 409 | 410 | 423;
|
||||
};
|
||||
|
||||
export function buildSkillInstallResolution({
|
||||
origin,
|
||||
skill,
|
||||
source,
|
||||
forceInstall = false,
|
||||
}: {
|
||||
origin: string;
|
||||
skill: InstallResolverSkill;
|
||||
source: InstallResolverSource | null;
|
||||
forceInstall?: boolean;
|
||||
}): SkillInstallResolution {
|
||||
if (skill.installKind !== "github") {
|
||||
const version = skill.latestVersionSummary?.version;
|
||||
if (!version) {
|
||||
return block(skill.slug, "archive_version_missing", 409);
|
||||
}
|
||||
|
||||
const url = new URL("/api/v1/download", origin);
|
||||
url.searchParams.set("slug", skill.slug);
|
||||
url.searchParams.set("version", version);
|
||||
return {
|
||||
ok: true,
|
||||
slug: skill.slug,
|
||||
installKind: "archive",
|
||||
archive: {
|
||||
version,
|
||||
downloadUrl: url.toString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (skill.githubRemovedAt) {
|
||||
return block(skill.slug, "github_upstream_removed", 410);
|
||||
}
|
||||
if (skill.githubCurrentStatus === "missing") {
|
||||
return block(skill.slug, "github_upstream_missing", 410);
|
||||
}
|
||||
if (
|
||||
skill.githubScanStatus === "failed" ||
|
||||
skill.githubScanStatus === "malicious" ||
|
||||
skill.githubScanStatus === "suspicious"
|
||||
) {
|
||||
return block(skill.slug, "github_scan_failed", 403);
|
||||
}
|
||||
if (!source || !skill.githubPath) {
|
||||
return block(skill.slug, "github_source_missing", 409);
|
||||
}
|
||||
if (
|
||||
skill.githubCurrentStatus !== "present" ||
|
||||
!skill.githubCurrentCommit ||
|
||||
!skill.githubCurrentContentHash
|
||||
) {
|
||||
return block(skill.slug, "github_upstream_unknown", 423);
|
||||
}
|
||||
if (
|
||||
skill.githubScanStatus !== "clean" &&
|
||||
!(forceInstall && skill.githubScanStatus === "pending")
|
||||
) {
|
||||
return block(skill.slug, "github_verification_pending", 423);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
slug: skill.slug,
|
||||
installKind: "github",
|
||||
github: {
|
||||
repo: source.repo,
|
||||
path: skill.githubPath,
|
||||
commit: skill.githubCurrentCommit,
|
||||
contentHash: skill.githubCurrentContentHash,
|
||||
sourceUrl: buildGitHubTreeUrl(source.repo, skill.githubCurrentCommit, skill.githubPath),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function block(
|
||||
slug: string,
|
||||
reason: Extract<SkillInstallResolution, { ok: false }>["reason"],
|
||||
status: Extract<SkillInstallResolution, { ok: false }>["status"],
|
||||
): SkillInstallResolution {
|
||||
return {
|
||||
ok: false,
|
||||
slug,
|
||||
reason,
|
||||
status,
|
||||
message: INSTALL_BLOCK_MESSAGES[reason],
|
||||
};
|
||||
}
|
||||
|
||||
const INSTALL_BLOCK_MESSAGES: Record<
|
||||
Extract<SkillInstallResolution, { ok: false }>["reason"],
|
||||
string
|
||||
> = {
|
||||
archive_version_missing: "Hosted skill has no downloadable version.",
|
||||
github_source_missing: "GitHub-backed skill source metadata is incomplete.",
|
||||
github_upstream_removed: "GitHub-backed skill has been removed upstream.",
|
||||
github_upstream_missing: "GitHub-backed skill path is missing upstream.",
|
||||
github_upstream_unknown: "GitHub-backed skill needs an upstream freshness check before install.",
|
||||
github_verification_pending:
|
||||
"GitHub-backed skill security scan is in progress. Try again shortly, or rerun with --force-install to install the unverified upstream commit.",
|
||||
github_scan_failed: "GitHub-backed skill failed ClawHub security scanning.",
|
||||
};
|
||||
|
||||
function buildGitHubTreeUrl(repo: string, commit: string, path: string) {
|
||||
return `https://github.com/${encodeURIComponentRepo(repo)}/tree/${commit}/${encodeURIComponentPath(
|
||||
path,
|
||||
)}`;
|
||||
}
|
||||
|
||||
function encodeURIComponentRepo(repo: string) {
|
||||
return repo
|
||||
.split("/")
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join("/");
|
||||
}
|
||||
|
||||
function encodeURIComponentPath(path: string) {
|
||||
return path
|
||||
.split("/")
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join("/");
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export const Events = {
|
||||
GitHubSkillSourceSyncStarted: "github_skill_source_sync.started",
|
||||
GitHubSkillSourceSyncCompleted: "github_skill_source_sync.completed",
|
||||
GitHubSkillSourceSyncSourceFailed: "github_skill_source_sync.source_failed",
|
||||
GitHubSkillSourceSyncFailed: "github_skill_source_sync.failed",
|
||||
} as const;
|
||||
|
||||
export type EventName = (typeof Events)[keyof typeof Events];
|
||||
|
||||
type EventPayload = Record<string, unknown>;
|
||||
|
||||
export function logEvent(event: EventName, payload: EventPayload = {}) {
|
||||
console.log(JSON.stringify({ event, ...payload }));
|
||||
}
|
||||
|
||||
export function logErrorEvent(event: EventName, payload: EventPayload = {}) {
|
||||
console.error(JSON.stringify({ event, ...payload }));
|
||||
}
|
||||
@@ -17,60 +17,91 @@ function makePublisher(
|
||||
} as Doc<"publishers">;
|
||||
}
|
||||
|
||||
function makeOfficialRow(publisherId: string) {
|
||||
return {
|
||||
_id: `officialPublishers:${publisherId}`,
|
||||
_creationTime: 1,
|
||||
publisherId,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCtx({ officialPublisherIds = [] }: { officialPublisherIds?: string[] } = {}) {
|
||||
return {
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table !== "officialPublishers") {
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
}
|
||||
return {
|
||||
withIndex: vi.fn((_indexName: string, buildQuery: (q: unknown) => unknown) => {
|
||||
let requestedPublisherId: string | undefined;
|
||||
buildQuery({
|
||||
eq: vi.fn((field: string, value: string) => {
|
||||
if (field === "publisherId") requestedPublisherId = value;
|
||||
return {};
|
||||
}),
|
||||
});
|
||||
return {
|
||||
unique: vi.fn(async () =>
|
||||
requestedPublisherId && officialPublisherIds.includes(requestedPublisherId)
|
||||
? makeOfficialRow(requestedPublisherId)
|
||||
: null,
|
||||
),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("isOfficialPublisher", () => {
|
||||
it("treats the openclaw org publisher as official", async () => {
|
||||
const ctx = { db: { query: vi.fn() } };
|
||||
it("treats a publisher with an official row as official", async () => {
|
||||
const ctx = makeCtx({ officialPublisherIds: ["publishers:acme"] });
|
||||
|
||||
await expect(
|
||||
isOfficialPublisher(ctx as never, makePublisher({ handle: "openclaw" })),
|
||||
isOfficialPublisher(ctx as never, makePublisher({ _id: "publishers:acme", handle: "acme" })),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("treats the nvidia org publisher as official", async () => {
|
||||
const ctx = { db: { query: vi.fn() } };
|
||||
it("treats a personal publisher with an official row as official", async () => {
|
||||
const ctx = makeCtx({ officialPublisherIds: ["publishers:alice"] });
|
||||
|
||||
await expect(
|
||||
isOfficialPublisher(ctx as never, makePublisher({ handle: "nvidia" })),
|
||||
isOfficialPublisher(
|
||||
ctx as never,
|
||||
makePublisher({
|
||||
_id: "publishers:alice",
|
||||
kind: "user",
|
||||
handle: "alice",
|
||||
linkedUserId: "users:alice",
|
||||
}),
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("treats personal publishers for openclaw org members as official", async () => {
|
||||
const openclaw = makePublisher({ _id: "publishers:openclaw", handle: "openclaw" });
|
||||
it("does not treat legacy official handles as official without a row", async () => {
|
||||
const ctx = makeCtx();
|
||||
|
||||
await expect(
|
||||
isOfficialPublisher(
|
||||
ctx as never,
|
||||
makePublisher({ _id: "publishers:openclaw", handle: "openclaw" }),
|
||||
),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("does not inherit official status from org membership", async () => {
|
||||
const personal = makePublisher({
|
||||
_id: "publishers:alice",
|
||||
kind: "user",
|
||||
handle: "alice",
|
||||
linkedUserId: "users:alice",
|
||||
});
|
||||
const ctx = {
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn(async () => openclaw),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn(async () => ({
|
||||
_id: "publisherMembers:alice",
|
||||
publisherId: "publishers:openclaw",
|
||||
userId: "users:alice",
|
||||
role: "publisher",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
const ctx = makeCtx({ officialPublisherIds: ["publishers:openclaw"] });
|
||||
|
||||
await expect(isOfficialPublisher(ctx as never, personal)).resolves.toBe(true);
|
||||
await expect(isOfficialPublisher(ctx as never, personal)).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import type { Doc } from "../_generated/dataModel";
|
||||
import type { MutationCtx, QueryCtx } from "../_generated/server";
|
||||
import { toPublicPublisher, type PublicPublisher } from "./public";
|
||||
import {
|
||||
getPublisherByHandle,
|
||||
getPublisherMembership,
|
||||
normalizePublisherHandle,
|
||||
} from "./publishers";
|
||||
|
||||
const OFFICIAL_ORG_HANDLES = ["openclaw", "nvidia"] as const;
|
||||
const OFFICIAL_ORG_HANDLE_SET = new Set<string>(OFFICIAL_ORG_HANDLES);
|
||||
|
||||
type DbCtx = Pick<QueryCtx | MutationCtx, "db">;
|
||||
|
||||
@@ -31,21 +23,11 @@ export async function isOfficialPublisher(
|
||||
publisher: OfficialPublisherCandidate | null | undefined,
|
||||
): Promise<boolean> {
|
||||
if (!publisher || publisher.deletedAt || publisher.deactivatedAt) return false;
|
||||
if (publisher.kind === "org") {
|
||||
const handle = normalizePublisherHandle(publisher.handle);
|
||||
return Boolean(handle && OFFICIAL_ORG_HANDLE_SET.has(handle));
|
||||
}
|
||||
if (!publisher.linkedUserId) return false;
|
||||
|
||||
for (const officialOrgHandle of OFFICIAL_ORG_HANDLES) {
|
||||
const officialOrg = await getPublisherByHandle(ctx, officialOrgHandle);
|
||||
if (!officialOrg || officialOrg.deletedAt || officialOrg.deactivatedAt) continue;
|
||||
|
||||
const membership = await getPublisherMembership(ctx, officialOrg._id, publisher.linkedUserId);
|
||||
if (membership) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
const officialPublisher = await ctx.db
|
||||
.query("officialPublishers")
|
||||
.withIndex("by_publisher", (q) => q.eq("publisherId", publisher._id))
|
||||
.unique();
|
||||
return Boolean(officialPublisher);
|
||||
}
|
||||
|
||||
export async function toPublicPublisherWithOfficial(
|
||||
|
||||
@@ -66,6 +66,22 @@ describe("public skill mapping", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes GitHub-backed skill source fields", () => {
|
||||
const mapped = toPublicSkill(
|
||||
makeSkill({
|
||||
installKind: "github",
|
||||
githubPath: "skills/demo",
|
||||
githubCurrentCommit: "a".repeat(40),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mapped).toMatchObject({
|
||||
installKind: "github",
|
||||
githubPath: "skills/demo",
|
||||
githubCurrentCommit: "a".repeat(40),
|
||||
});
|
||||
});
|
||||
|
||||
it("returns skill when moderationStatus is active", () => {
|
||||
const skill = makeSkill({ moderationStatus: "active" });
|
||||
expect(toPublicSkill(skill)).not.toBeNull();
|
||||
|
||||
+15
-2
@@ -24,6 +24,10 @@ export type PublicSkill = Pick<
|
||||
| "canonicalSkillId"
|
||||
| "forkOf"
|
||||
| "latestVersionId"
|
||||
| "installKind"
|
||||
| "githubPath"
|
||||
| "githubCurrentCommit"
|
||||
| "githubHasSkillCard"
|
||||
| "tags"
|
||||
| "capabilityTags"
|
||||
| "badges"
|
||||
@@ -31,7 +35,9 @@ export type PublicSkill = Pick<
|
||||
| "isSuspicious"
|
||||
| "createdAt"
|
||||
| "updatedAt"
|
||||
>;
|
||||
> & {
|
||||
githubSourceRepo?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Minimum set of fields needed by `hydrateResults` to filter and convert
|
||||
@@ -52,6 +58,8 @@ export type HydratableSkill = Pick<
|
||||
| "canonicalSkillId"
|
||||
| "forkOf"
|
||||
| "latestVersionId"
|
||||
| "installKind"
|
||||
| "githubHasSkillCard"
|
||||
| "latestVersionSummary"
|
||||
| "tags"
|
||||
| "capabilityTags"
|
||||
@@ -68,7 +76,8 @@ export type HydratableSkill = Pick<
|
||||
| "isSuspicious"
|
||||
| "createdAt"
|
||||
| "updatedAt"
|
||||
>;
|
||||
> &
|
||||
Partial<Pick<Doc<"skills">, "githubPath" | "githubCurrentCommit">>;
|
||||
|
||||
export type PublicSoul = Pick<
|
||||
Doc<"souls">,
|
||||
@@ -149,6 +158,10 @@ export function toPublicSkill(skill: HydratableSkill | null | undefined): Public
|
||||
canonicalSkillId: skill.canonicalSkillId,
|
||||
forkOf: skill.forkOf,
|
||||
latestVersionId: skill.latestVersionId,
|
||||
installKind: skill.installKind,
|
||||
githubPath: skill.githubPath,
|
||||
githubCurrentCommit: skill.githubCurrentCommit,
|
||||
githubHasSkillCard: skill.githubHasSkillCard,
|
||||
tags: skill.tags,
|
||||
capabilityTags: skill.capabilityTags,
|
||||
badges: skill.badges,
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildGitHubSkillCatalogDisplay } from "./publisherCatalogDisplay";
|
||||
|
||||
const baseItem = {
|
||||
kind: "skill" as const,
|
||||
summary: null,
|
||||
icon: null,
|
||||
href: "/nvidia/example",
|
||||
downloads: 0,
|
||||
stars: 0,
|
||||
isOfficial: true,
|
||||
updatedAt: 1,
|
||||
sourceBacked: true,
|
||||
sourceRepo: "NVIDIA/skills",
|
||||
sourcePath: null,
|
||||
sourceVerifiedCommit: null,
|
||||
};
|
||||
|
||||
describe("buildGitHubSkillCatalogDisplay", () => {
|
||||
it("groups source-backed skills by manifest entries and ignores missing entries", () => {
|
||||
const display = buildGitHubSkillCatalogDisplay({
|
||||
sources: [
|
||||
{
|
||||
_id: "githubSkillSources:nvidia",
|
||||
repo: "NVIDIA/skills",
|
||||
displayManifestStatus: "ok",
|
||||
displayManifest: {
|
||||
notGrouped: "bottom",
|
||||
groupings: [
|
||||
{
|
||||
title: "Agentic AI",
|
||||
description: "Agentic AI skills.",
|
||||
skills: ["aiq-deploy", "missing-upstream-entry"],
|
||||
},
|
||||
{
|
||||
title: "Vision AI",
|
||||
skills: ["vision-helper"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
items: [
|
||||
{
|
||||
...baseItem,
|
||||
_id: "skills:aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
slug: "aiq-deploy",
|
||||
sourceId: "githubSkillSources:nvidia",
|
||||
},
|
||||
{
|
||||
...baseItem,
|
||||
_id: "skills:vision-helper",
|
||||
displayName: "Vision Helper",
|
||||
slug: "vision-helper",
|
||||
sourceId: "githubSkillSources:nvidia",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(display).toMatchObject({
|
||||
mode: "grouped",
|
||||
sourceRepos: ["NVIDIA/skills"],
|
||||
sections: [
|
||||
{
|
||||
title: "Agentic AI",
|
||||
description: "Agentic AI skills.",
|
||||
sourceRepo: "NVIDIA/skills",
|
||||
items: [{ displayName: "AIQ Deploy" }],
|
||||
},
|
||||
{
|
||||
title: "Vision AI",
|
||||
sourceRepo: "NVIDIA/skills",
|
||||
items: [{ displayName: "Vision Helper" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("matches manifest entries by normalized display name and places unlisted skills at the requested edge", () => {
|
||||
const display = buildGitHubSkillCatalogDisplay({
|
||||
sources: [
|
||||
{
|
||||
_id: "githubSkillSources:nvidia",
|
||||
repo: "NVIDIA/skills",
|
||||
displayManifestStatus: "ok",
|
||||
displayManifest: {
|
||||
notGrouped: "top",
|
||||
groupings: [
|
||||
{
|
||||
title: "Physical AI",
|
||||
skills: ["Isaac Sim Helper"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
items: [
|
||||
{
|
||||
...baseItem,
|
||||
_id: "skills:isaac-sim-helper",
|
||||
displayName: "Isaac Sim Helper",
|
||||
slug: "isaac-sim-helper",
|
||||
sourceId: "githubSkillSources:nvidia",
|
||||
},
|
||||
{
|
||||
...baseItem,
|
||||
_id: "skills:extra",
|
||||
displayName: "Extra Skill",
|
||||
slug: "extra",
|
||||
sourceId: "githubSkillSources:nvidia",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(display?.sections.map((section) => section.title)).toEqual([
|
||||
"Other skills",
|
||||
"Physical AI",
|
||||
]);
|
||||
expect(display?.sections[0]?.items.map((item) => item.displayName)).toEqual(["Extra Skill"]);
|
||||
});
|
||||
|
||||
it("falls back to the normal catalog when the source manifest is missing or invalid", () => {
|
||||
const display = buildGitHubSkillCatalogDisplay({
|
||||
sources: [
|
||||
{
|
||||
_id: "githubSkillSources:nvidia",
|
||||
repo: "NVIDIA/skills",
|
||||
displayManifestStatus: "invalid",
|
||||
},
|
||||
],
|
||||
items: [
|
||||
{
|
||||
...baseItem,
|
||||
_id: "skills:aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
slug: "aiq-deploy",
|
||||
sourceId: "githubSkillSources:nvidia",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(display).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps source-backed skills from non-renderable sources in other skills", () => {
|
||||
const display = buildGitHubSkillCatalogDisplay({
|
||||
sources: [
|
||||
{
|
||||
_id: "githubSkillSources:nvidia",
|
||||
repo: "NVIDIA/skills",
|
||||
displayManifestStatus: "ok",
|
||||
displayManifest: {
|
||||
notGrouped: "bottom",
|
||||
groupings: [
|
||||
{
|
||||
title: "Agentic AI",
|
||||
skills: ["aiq-deploy"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
_id: "githubSkillSources:invalid",
|
||||
repo: "example/skills",
|
||||
displayManifestStatus: "invalid",
|
||||
},
|
||||
],
|
||||
items: [
|
||||
{
|
||||
...baseItem,
|
||||
_id: "skills:aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
slug: "aiq-deploy",
|
||||
sourceId: "githubSkillSources:nvidia",
|
||||
},
|
||||
{
|
||||
...baseItem,
|
||||
_id: "skills:unlisted",
|
||||
displayName: "Unlisted Source Skill",
|
||||
slug: "unlisted-source-skill",
|
||||
sourceRepo: "example/skills",
|
||||
sourceId: "githubSkillSources:invalid",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(display?.sections.map((section) => section.title)).toEqual([
|
||||
"Agentic AI",
|
||||
"Other skills",
|
||||
]);
|
||||
expect(display?.sections.at(-1)?.items.map((item) => item.displayName)).toEqual([
|
||||
"Unlisted Source Skill",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
type DisplayManifest = {
|
||||
notGrouped?: "top" | "bottom";
|
||||
groupings: Array<{
|
||||
title: string;
|
||||
description?: string;
|
||||
skills: string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
export type GitHubSkillCatalogSource = {
|
||||
_id: string;
|
||||
repo: string;
|
||||
displayManifestStatus?: "ok" | "missing" | "invalid" | "failed";
|
||||
displayManifest?: DisplayManifest;
|
||||
};
|
||||
|
||||
export type GitHubSkillCatalogItem = {
|
||||
_id: string;
|
||||
kind: "skill" | "plugin";
|
||||
displayName: string;
|
||||
slug?: string | null;
|
||||
sourceBacked?: boolean;
|
||||
sourceId?: string | null;
|
||||
sourceRepo?: string | null;
|
||||
sourcePath?: string | null;
|
||||
sourceVerifiedCommit?: string | null;
|
||||
summary: string | null;
|
||||
icon: string | null;
|
||||
href: string;
|
||||
downloads: number;
|
||||
stars: number;
|
||||
isOfficial: boolean;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
export type GitHubSkillCatalogSection = {
|
||||
key: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
sourceRepo: string | null;
|
||||
items: GitHubSkillCatalogItem[];
|
||||
};
|
||||
|
||||
export type GitHubSkillCatalogDisplay = {
|
||||
mode: "grouped";
|
||||
sourceRepos: string[];
|
||||
sections: GitHubSkillCatalogSection[];
|
||||
};
|
||||
|
||||
function normalizeManifestSkillKey(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_]+/g, "-")
|
||||
.replace(/[^a-z0-9-]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
function getItemKeys(item: GitHubSkillCatalogItem) {
|
||||
const keys = new Set<string>();
|
||||
if (item.slug) keys.add(normalizeManifestSkillKey(item.slug));
|
||||
keys.add(normalizeManifestSkillKey(item.displayName));
|
||||
|
||||
const sourcePathName = item.sourcePath?.split("/").filter(Boolean).at(-1);
|
||||
if (sourcePathName) keys.add(normalizeManifestSkillKey(sourcePathName));
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
function findManifestItem(
|
||||
candidates: GitHubSkillCatalogItem[],
|
||||
manifestEntry: string,
|
||||
usedItemIds: Set<string>,
|
||||
) {
|
||||
const key = normalizeManifestSkillKey(manifestEntry);
|
||||
if (!key) return null;
|
||||
|
||||
return (
|
||||
candidates.find((item) => !usedItemIds.has(item._id) && getItemKeys(item).has(key)) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function isRenderableSource(source: GitHubSkillCatalogSource) {
|
||||
return (
|
||||
source.displayManifestStatus === "ok" &&
|
||||
Boolean(source.displayManifest) &&
|
||||
source.displayManifest!.groupings.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function buildGitHubSkillCatalogDisplay({
|
||||
sources,
|
||||
items,
|
||||
}: {
|
||||
sources: GitHubSkillCatalogSource[];
|
||||
items: GitHubSkillCatalogItem[];
|
||||
}): GitHubSkillCatalogDisplay | null {
|
||||
const renderableSources = sources.filter(isRenderableSource);
|
||||
if (renderableSources.length === 0) return null;
|
||||
|
||||
const sourceRepos = Array.from(new Set(renderableSources.map((source) => source.repo)));
|
||||
const usedItemIds = new Set<string>();
|
||||
const sections: GitHubSkillCatalogSection[] = [];
|
||||
const otherPosition = renderableSources.some(
|
||||
(source) => source.displayManifest?.notGrouped === "top",
|
||||
)
|
||||
? "top"
|
||||
: "bottom";
|
||||
|
||||
for (const source of renderableSources) {
|
||||
const sourceItems = items.filter(
|
||||
(item) => item.kind === "skill" && item.sourceId === source._id,
|
||||
);
|
||||
if (sourceItems.length === 0) continue;
|
||||
|
||||
for (const [groupIndex, group] of source.displayManifest!.groupings.entries()) {
|
||||
const groupItems = group.skills
|
||||
.map((entry) => findManifestItem(sourceItems, entry, usedItemIds))
|
||||
.filter((item): item is GitHubSkillCatalogItem => Boolean(item));
|
||||
|
||||
if (groupItems.length === 0) continue;
|
||||
for (const item of groupItems) usedItemIds.add(item._id);
|
||||
|
||||
sections.push({
|
||||
key: `${source._id}:${groupIndex}:${group.title}`,
|
||||
title: group.title,
|
||||
description: group.description ?? null,
|
||||
sourceRepo: source.repo,
|
||||
items: groupItems,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const otherItems = items.filter((item) => item.kind === "skill" && !usedItemIds.has(item._id));
|
||||
const otherSection =
|
||||
otherItems.length > 0
|
||||
? {
|
||||
key: "other-skills",
|
||||
title: "Other skills",
|
||||
description: null,
|
||||
sourceRepo: null,
|
||||
items: otherItems,
|
||||
}
|
||||
: null;
|
||||
const orderedSections =
|
||||
otherPosition === "top" && otherSection
|
||||
? [otherSection, ...sections]
|
||||
: [...sections, ...(otherSection ? [otherSection] : [])];
|
||||
|
||||
if (orderedSections.length === 0) return null;
|
||||
return {
|
||||
mode: "grouped",
|
||||
sourceRepos,
|
||||
sections: orderedSections,
|
||||
};
|
||||
}
|
||||
@@ -52,6 +52,13 @@ describe("publisher stat maintenance", () => {
|
||||
return {
|
||||
collect: vi.fn(async () => [
|
||||
makeSkill({ statsDownloads: 11, statsStars: 2, statsInstallsAllTime: 5 }),
|
||||
makeSkill({
|
||||
_id: "skills:hidden",
|
||||
moderationStatus: "hidden",
|
||||
statsDownloads: 100,
|
||||
statsStars: 100,
|
||||
statsInstallsAllTime: 100,
|
||||
}),
|
||||
]),
|
||||
};
|
||||
}
|
||||
@@ -136,6 +143,26 @@ describe("publisher stat maintenance", () => {
|
||||
expect(ctx.db.query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not count hidden skills in public publisher aggregates", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
query: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
await adjustPublisherStatsForSkillChange(
|
||||
ctx as never,
|
||||
null,
|
||||
makeSkill({ moderationStatus: "hidden", moderationReason: "pending.scan" }),
|
||||
);
|
||||
|
||||
expect(ctx.db.get).not.toHaveBeenCalled();
|
||||
expect(ctx.db.patch).not.toHaveBeenCalled();
|
||||
expect(ctx.db.query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps legacy aggregate updates bounded when skill-only aggregates are missing", async () => {
|
||||
const patch = vi.fn();
|
||||
const ctx = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
import type { MutationCtx } from "../_generated/server";
|
||||
import { isPublicSkillDoc } from "./globalStats";
|
||||
import { readCanonicalStat } from "./skillStats";
|
||||
|
||||
export type PublisherStatsContribution = {
|
||||
@@ -27,7 +28,7 @@ export function emptyPublisherStatsContribution(): PublisherStatsContribution {
|
||||
}
|
||||
|
||||
export function getSkillPublisherContribution(skill: Doc<"skills">): PublisherStatsContribution {
|
||||
if (skill.softDeletedAt) return emptyPublisherStatsContribution();
|
||||
if (!isPublicSkillDoc(skill)) return emptyPublisherStatsContribution();
|
||||
const totalInstalls = readCanonicalStat(skill, "installsAllTime");
|
||||
const totalDownloads = readCanonicalStat(skill, "downloads");
|
||||
const totalStars = readCanonicalStat(skill, "stars");
|
||||
@@ -91,7 +92,7 @@ function publisherHasSkillTotalStats(
|
||||
);
|
||||
}
|
||||
|
||||
async function recomputePublisherStats(
|
||||
export async function recomputePublisherStats(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
publisherId: Id<"publishers">,
|
||||
): Promise<PublisherStatsContribution> {
|
||||
|
||||
@@ -32,7 +32,7 @@ function normalizeGeneratedPublisherHandle(handle: string | undefined | null) {
|
||||
return sanitized || undefined;
|
||||
}
|
||||
|
||||
function derivePersonalPublisherHandle(user: Doc<"users">) {
|
||||
export function derivePersonalPublisherHandle(user: Doc<"users">) {
|
||||
const emailLocalPart = user.email?.split("@")[0];
|
||||
const userIdSuffix = String(user._id).split(":").pop();
|
||||
return (
|
||||
|
||||
@@ -25,6 +25,8 @@ const SHARED_KEYS = [
|
||||
"canonicalSkillId",
|
||||
"forkOf",
|
||||
"latestVersionId",
|
||||
"installKind",
|
||||
"githubHasSkillCard",
|
||||
"latestVersionSummary",
|
||||
"tags",
|
||||
"capabilityTags",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ConvexError } from "convex/values";
|
||||
// - Lowercase letters, digits, and single hyphens only.
|
||||
// - Must start and end with a letter or digit.
|
||||
// - No consecutive hyphens ("--", "---", ...).
|
||||
// - Length 3..48 (URL/SEO friendly, aligned with publisher handle).
|
||||
// - Length 3..96 (URL-friendly, but long enough for source-backed upstream slugs).
|
||||
//
|
||||
// The pattern enforces first/last char class and forbids consecutive hyphens
|
||||
// via a negative lookahead. Length bounds are checked separately so we can
|
||||
@@ -12,7 +12,7 @@ import { ConvexError } from "convex/values";
|
||||
const SLUG_PATTERN = /^[a-z0-9](?:(?!--)[a-z0-9-])*[a-z0-9]$/;
|
||||
|
||||
const MIN_SLUG_LENGTH = 3;
|
||||
const MAX_SLUG_LENGTH = 48;
|
||||
const MAX_SLUG_LENGTH = 96;
|
||||
|
||||
// Reserved slugs. These are blocked because they would:
|
||||
// 1. Clash semantically with top-level routes under src/routes/*.
|
||||
|
||||
@@ -11,6 +11,9 @@ vi.mock("./_generated/api", () => ({
|
||||
getUserOwnedSkillsBackfillPageInternal: Symbol("getUserOwnedSkillsBackfillPageInternal"),
|
||||
applyUserStatsBackfillPatchInternal: Symbol("applyUserStatsBackfillPatchInternal"),
|
||||
backfillUserStatsInternal: Symbol("backfillUserStatsInternal"),
|
||||
getPublisherStatsBackfillPageInternal: Symbol("getPublisherStatsBackfillPageInternal"),
|
||||
recomputePublisherStatsInternal: Symbol("recomputePublisherStatsInternal"),
|
||||
backfillPublisherStatsInternal: Symbol("backfillPublisherStatsInternal"),
|
||||
getSkillFingerprintBackfillPageInternal: Symbol("getSkillFingerprintBackfillPageInternal"),
|
||||
applySkillFingerprintBackfillPatchInternal: Symbol(
|
||||
"applySkillFingerprintBackfillPatchInternal",
|
||||
@@ -24,6 +27,7 @@ vi.mock("./_generated/api", () => ({
|
||||
nominateUserForEmptySkillSpamInternal: Symbol("nominateUserForEmptySkillSpamInternal"),
|
||||
cleanupEmptySkillsInternal: Symbol("cleanupEmptySkillsInternal"),
|
||||
nominateEmptySkillSpammersInternal: Symbol("nominateEmptySkillSpammersInternal"),
|
||||
repairLegacyPublisherOwnership: Symbol("repairLegacyPublisherOwnership"),
|
||||
},
|
||||
skills: {
|
||||
backfillLatestSkillModerationInternal: Symbol("skills.backfillLatestSkillModerationInternal"),
|
||||
@@ -44,12 +48,14 @@ const {
|
||||
applySkillCapabilityTagsInternal,
|
||||
backfillDigestVersionSummary,
|
||||
backfillLatestVersionSummaryInternal,
|
||||
backfillPublisherStatsInternalHandler,
|
||||
backfillSkillSearchDigestInternal,
|
||||
backfillSkillFingerprintsInternalHandler,
|
||||
backfillSkillSummariesInternalHandler,
|
||||
backfillUserStatsInternalHandler,
|
||||
cleanupEmptySkillsInternalHandler,
|
||||
nominateEmptySkillSpammersInternalHandler,
|
||||
repairLegacyPublisherOwnershipHandler,
|
||||
upsertSkillBadgeRecordInternal,
|
||||
} = await import("./maintenance");
|
||||
const { internal } = await import("./_generated/api");
|
||||
@@ -59,6 +65,593 @@ function makeBlob(text: string) {
|
||||
return { text: () => Promise.resolve(text) } as unknown as Blob;
|
||||
}
|
||||
|
||||
type QueryEq = {
|
||||
eq: (field: string, value: unknown) => QueryEq;
|
||||
};
|
||||
|
||||
function makeLegacyPublisherOwnershipDb() {
|
||||
const now = 1_717_456_000_000;
|
||||
let nextPublisherId = 2;
|
||||
let nextMemberId = 1;
|
||||
const users = new Map<string, Record<string, unknown>>([
|
||||
[
|
||||
"users:legacy",
|
||||
{
|
||||
_id: "users:legacy",
|
||||
_creationTime: now - 1000,
|
||||
handle: "legacy-owner",
|
||||
name: "Legacy Owner",
|
||||
displayName: "Legacy Owner",
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
purgedAt: undefined,
|
||||
},
|
||||
],
|
||||
[
|
||||
"users:deleted",
|
||||
{
|
||||
_id: "users:deleted",
|
||||
_creationTime: now - 1000,
|
||||
handle: "deleted-owner",
|
||||
deletedAt: now - 10,
|
||||
deactivatedAt: undefined,
|
||||
purgedAt: undefined,
|
||||
},
|
||||
],
|
||||
]);
|
||||
const publishers = new Map<string, Record<string, unknown>>([
|
||||
[
|
||||
"publishers:existing",
|
||||
{
|
||||
_id: "publishers:existing",
|
||||
_creationTime: now - 500,
|
||||
kind: "user",
|
||||
handle: "existing-owner",
|
||||
displayName: "Existing Owner",
|
||||
linkedUserId: "users:existing",
|
||||
publishedSkills: 0,
|
||||
publishedPackages: 0,
|
||||
totalInstalls: 0,
|
||||
totalDownloads: 0,
|
||||
totalStars: 0,
|
||||
skillTotalInstalls: 0,
|
||||
skillTotalDownloads: 0,
|
||||
skillTotalStars: 0,
|
||||
createdAt: now - 500,
|
||||
updatedAt: now - 500,
|
||||
},
|
||||
],
|
||||
]);
|
||||
const publisherMembers = new Map<string, Record<string, unknown>>();
|
||||
const skills = new Map<string, Record<string, unknown>>([
|
||||
[
|
||||
"skills:legacy",
|
||||
{
|
||||
_id: "skills:legacy",
|
||||
_creationTime: now - 400,
|
||||
slug: "legacy-skill",
|
||||
displayName: "Legacy Skill",
|
||||
ownerUserId: "users:legacy",
|
||||
ownerPublisherId: undefined,
|
||||
latestVersionId: "skillVersions:legacy",
|
||||
tags: { latest: "skillVersions:legacy" },
|
||||
stats: {
|
||||
downloads: 10,
|
||||
stars: 3,
|
||||
installsCurrent: 2,
|
||||
installsAllTime: 5,
|
||||
comments: 0,
|
||||
versions: 1,
|
||||
},
|
||||
statsDownloads: 10,
|
||||
statsStars: 3,
|
||||
statsInstallsCurrent: 2,
|
||||
statsInstallsAllTime: 5,
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
createdAt: now - 300,
|
||||
updatedAt: now - 200,
|
||||
},
|
||||
],
|
||||
[
|
||||
"skills:deleted-owner",
|
||||
{
|
||||
_id: "skills:deleted-owner",
|
||||
_creationTime: now - 400,
|
||||
slug: "deleted-owner-skill",
|
||||
displayName: "Deleted Owner Skill",
|
||||
ownerUserId: "users:deleted",
|
||||
ownerPublisherId: undefined,
|
||||
latestVersionId: "skillVersions:deleted-owner",
|
||||
tags: { latest: "skillVersions:deleted-owner" },
|
||||
stats: {
|
||||
downloads: 1,
|
||||
stars: 0,
|
||||
installsCurrent: 0,
|
||||
installsAllTime: 0,
|
||||
comments: 0,
|
||||
versions: 1,
|
||||
},
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
createdAt: now - 300,
|
||||
updatedAt: now - 200,
|
||||
},
|
||||
],
|
||||
]);
|
||||
const skillVersions = new Map<string, Record<string, unknown>>([
|
||||
[
|
||||
"skillVersions:legacy",
|
||||
{
|
||||
_id: "skillVersions:legacy",
|
||||
skillId: "skills:legacy",
|
||||
version: "1.0.0",
|
||||
softDeletedAt: undefined,
|
||||
},
|
||||
],
|
||||
[
|
||||
"skillVersions:deleted-owner",
|
||||
{
|
||||
_id: "skillVersions:deleted-owner",
|
||||
skillId: "skills:deleted-owner",
|
||||
version: "1.0.0",
|
||||
softDeletedAt: undefined,
|
||||
},
|
||||
],
|
||||
]);
|
||||
const skillSlugAliases = new Map<string, Record<string, unknown>>([
|
||||
[
|
||||
"skillSlugAliases:legacy",
|
||||
{
|
||||
_id: "skillSlugAliases:legacy",
|
||||
slug: "old-legacy-skill",
|
||||
skillId: "skills:legacy",
|
||||
ownerUserId: "users:legacy",
|
||||
ownerPublisherId: undefined,
|
||||
createdAt: now - 250,
|
||||
updatedAt: now - 250,
|
||||
},
|
||||
],
|
||||
]);
|
||||
const skillEmbeddings = new Map<string, Record<string, unknown>>([
|
||||
[
|
||||
"skillEmbeddings:legacy",
|
||||
{
|
||||
_id: "skillEmbeddings:legacy",
|
||||
skillId: "skills:legacy",
|
||||
versionId: "skillVersions:legacy",
|
||||
ownerId: "users:legacy",
|
||||
ownerPublisherId: undefined,
|
||||
embedding: [0.1, 0.2],
|
||||
isLatest: true,
|
||||
isApproved: true,
|
||||
visibility: "public",
|
||||
updatedAt: now - 200,
|
||||
},
|
||||
],
|
||||
]);
|
||||
const skillSearchDigest = new Map<string, Record<string, unknown>>([
|
||||
[
|
||||
"skillSearchDigest:legacy",
|
||||
{
|
||||
_id: "skillSearchDigest:legacy",
|
||||
skillId: "skills:legacy",
|
||||
slug: "legacy-skill",
|
||||
displayName: "Legacy Skill",
|
||||
ownerUserId: "users:legacy",
|
||||
ownerPublisherId: undefined,
|
||||
ownerHandle: "legacy-owner",
|
||||
ownerKind: "user",
|
||||
stats: {
|
||||
downloads: 10,
|
||||
stars: 3,
|
||||
installsCurrent: 2,
|
||||
installsAllTime: 5,
|
||||
comments: 0,
|
||||
versions: 1,
|
||||
},
|
||||
statsDownloads: 10,
|
||||
statsStars: 3,
|
||||
statsInstallsCurrent: 2,
|
||||
statsInstallsAllTime: 5,
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
createdAt: now - 300,
|
||||
updatedAt: now - 200,
|
||||
},
|
||||
],
|
||||
]);
|
||||
const packages = new Map<string, Record<string, unknown>>([
|
||||
[
|
||||
"packages:legacy",
|
||||
{
|
||||
_id: "packages:legacy",
|
||||
_creationTime: now - 400,
|
||||
name: "@legacy-owner/demo-plugin",
|
||||
normalizedName: "@legacy-owner/demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "bundle-plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
ownerUserId: "users:legacy",
|
||||
ownerPublisherId: undefined,
|
||||
summary: "Demo package",
|
||||
latestReleaseId: undefined,
|
||||
tags: {},
|
||||
compatibility: undefined,
|
||||
capabilities: undefined,
|
||||
verification: undefined,
|
||||
scanStatus: "clean",
|
||||
stats: { downloads: 7, installs: 4, stars: 2, versions: 1 },
|
||||
softDeletedAt: undefined,
|
||||
createdAt: now - 300,
|
||||
updatedAt: now - 200,
|
||||
},
|
||||
],
|
||||
]);
|
||||
const packageSearchDigest = new Map<string, Record<string, unknown>>([
|
||||
[
|
||||
"packageSearchDigest:legacy",
|
||||
{
|
||||
_id: "packageSearchDigest:legacy",
|
||||
packageId: "packages:legacy",
|
||||
name: "@legacy-owner/demo-plugin",
|
||||
normalizedName: "@legacy-owner/demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "bundle-plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
ownerUserId: "users:legacy",
|
||||
ownerPublisherId: undefined,
|
||||
ownerHandle: "legacy-owner",
|
||||
ownerKind: "user",
|
||||
summary: "Demo package",
|
||||
scanStatus: "clean",
|
||||
softDeletedAt: undefined,
|
||||
createdAt: now - 300,
|
||||
updatedAt: now - 200,
|
||||
},
|
||||
],
|
||||
]);
|
||||
const packageCapabilitySearchDigest = new Map<string, Record<string, unknown>>();
|
||||
const packagePluginCategorySearchDigest = new Map<string, Record<string, unknown>>();
|
||||
|
||||
const tableMap: Record<string, Map<string, Record<string, unknown>>> = {
|
||||
users,
|
||||
publishers,
|
||||
publisherMembers,
|
||||
skills,
|
||||
skillVersions,
|
||||
skillSlugAliases,
|
||||
skillEmbeddings,
|
||||
skillSearchDigest,
|
||||
packages,
|
||||
packageSearchDigest,
|
||||
packageCapabilitySearchDigest,
|
||||
packagePluginCategorySearchDigest,
|
||||
};
|
||||
const patchCalls: Array<{ id: string; patch: Record<string, unknown> }> = [];
|
||||
const insertCalls: Array<{ table: string; value: Record<string, unknown> }> = [];
|
||||
|
||||
const getRows = (table: string) => Array.from(tableMap[table]?.values() ?? []);
|
||||
const getTableForId = (id: string) => id.split(":")[0];
|
||||
const readField = (row: Record<string, unknown>, field: string) =>
|
||||
field.split(".").reduce<unknown>((value, part) => {
|
||||
if (!value || typeof value !== "object") return undefined;
|
||||
return (value as Record<string, unknown>)[part];
|
||||
}, row);
|
||||
const makeQuery = (table: string, rows: Record<string, unknown>[]) => ({
|
||||
collect: vi.fn(async () => rows),
|
||||
unique: vi.fn(async () => rows[0] ?? null),
|
||||
take: vi.fn(async (limit: number) => rows.slice(0, limit)),
|
||||
order: vi.fn(() => ({
|
||||
take: vi.fn(async (limit: number) => rows.slice(0, limit)),
|
||||
paginate: vi.fn(async ({ cursor, numItems }: { cursor: string | null; numItems: number }) =>
|
||||
paginateRows(rows, cursor, numItems),
|
||||
),
|
||||
})),
|
||||
paginate: vi.fn(async ({ cursor, numItems }: { cursor: string | null; numItems: number }) =>
|
||||
paginateRows(rows, cursor, numItems),
|
||||
),
|
||||
withIndex: vi.fn((indexName: string, build?: (q: QueryEq) => unknown) => {
|
||||
const filters: Array<{ field: string; value: unknown }> = [];
|
||||
const q: QueryEq = {
|
||||
eq: (field, value) => {
|
||||
filters.push({ field, value });
|
||||
return q;
|
||||
},
|
||||
};
|
||||
build?.(q);
|
||||
let indexedRows = getRows(table).filter((row) =>
|
||||
filters.every((filter) => readField(row, filter.field) === filter.value),
|
||||
);
|
||||
if (table === "users" && indexName === "by_active_handle") {
|
||||
indexedRows = indexedRows.filter(
|
||||
(row) => row.deletedAt === undefined && row.deactivatedAt === undefined,
|
||||
);
|
||||
}
|
||||
return makeQuery(table, indexedRows);
|
||||
}),
|
||||
});
|
||||
|
||||
const db = {
|
||||
get: vi.fn(async (id: string) => tableMap[getTableForId(id)]?.get(id) ?? null),
|
||||
query: vi.fn((table: string) => makeQuery(table, getRows(table))),
|
||||
patch: vi.fn(async (id: string, patch: Record<string, unknown>) => {
|
||||
patchCalls.push({ id, patch });
|
||||
const row = tableMap[getTableForId(id)]?.get(id);
|
||||
if (row) Object.assign(row, patch);
|
||||
}),
|
||||
insert: vi.fn(async (table: string, value: Record<string, unknown>) => {
|
||||
const id =
|
||||
table === "publishers"
|
||||
? `publishers:created${nextPublisherId++}`
|
||||
: table === "publisherMembers"
|
||||
? `publisherMembers:created${nextMemberId++}`
|
||||
: `${table}:created`;
|
||||
insertCalls.push({ table, value });
|
||||
tableMap[table].set(id, { _id: id, _creationTime: now, ...value });
|
||||
return id;
|
||||
}),
|
||||
delete: vi.fn(async (id: string) => {
|
||||
tableMap[getTableForId(id)]?.delete(id);
|
||||
}),
|
||||
normalizeId: vi.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
db,
|
||||
patchCalls,
|
||||
insertCalls,
|
||||
tableMap,
|
||||
};
|
||||
}
|
||||
|
||||
function paginateRows(rows: Record<string, unknown>[], cursor: string | null, numItems: number) {
|
||||
const start = cursor ? Number(cursor) : 0;
|
||||
const page = rows.slice(start, start + numItems);
|
||||
const next = start + page.length;
|
||||
return {
|
||||
page,
|
||||
continueCursor: next >= rows.length ? null : String(next),
|
||||
isDone: next >= rows.length,
|
||||
};
|
||||
}
|
||||
|
||||
describe("maintenance legacy publisher ownership repair", () => {
|
||||
it("dry-runs legacy publisher ownership repair without writes", async () => {
|
||||
const { db, patchCalls, insertCalls } = makeLegacyPublisherOwnershipDb();
|
||||
|
||||
const result = await repairLegacyPublisherOwnershipHandler(
|
||||
{ db, scheduler: { runAfter: vi.fn() } } as never,
|
||||
{ phase: "users", dryRun: true, batchSize: 10, scheduleNext: false },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
phase: "users",
|
||||
dryRun: true,
|
||||
scanned: 1,
|
||||
repaired: 1,
|
||||
skipped: 0,
|
||||
isDone: true,
|
||||
});
|
||||
expect(patchCalls).toEqual([]);
|
||||
expect(insertCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports dry-run personal publisher handle conflicts without writes", async () => {
|
||||
const { db, tableMap, patchCalls, insertCalls } = makeLegacyPublisherOwnershipDb();
|
||||
tableMap.users.set("users:conflict", {
|
||||
_id: "users:conflict",
|
||||
_creationTime: 1_717_456_000_000 - 1000,
|
||||
handle: "existing-owner",
|
||||
name: "Conflicting Owner",
|
||||
displayName: "Conflicting Owner",
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
purgedAt: undefined,
|
||||
});
|
||||
|
||||
const result = await repairLegacyPublisherOwnershipHandler(
|
||||
{ db, scheduler: { runAfter: vi.fn() } } as never,
|
||||
{ phase: "users", dryRun: true, batchSize: 10, scheduleNext: false },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
phase: "users",
|
||||
dryRun: true,
|
||||
scanned: 2,
|
||||
repaired: 1,
|
||||
skipped: 1,
|
||||
isDone: true,
|
||||
errors: ['user:users:conflict: Publisher handle "@existing-owner" is already claimed'],
|
||||
});
|
||||
expect(patchCalls).toEqual([]);
|
||||
expect(insertCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips apply-mode personal publisher handle conflicts while repairing other users", async () => {
|
||||
const { db, tableMap } = makeLegacyPublisherOwnershipDb();
|
||||
tableMap.users.set("users:conflict", {
|
||||
_id: "users:conflict",
|
||||
_creationTime: 1_717_456_000_000 - 1000,
|
||||
handle: "existing-owner",
|
||||
name: "Conflicting Owner",
|
||||
displayName: "Conflicting Owner",
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
purgedAt: undefined,
|
||||
});
|
||||
|
||||
const result = await repairLegacyPublisherOwnershipHandler(
|
||||
{ db, scheduler: { runAfter: vi.fn() } } as never,
|
||||
{ phase: "users", dryRun: false, batchSize: 10, scheduleNext: false },
|
||||
);
|
||||
|
||||
const createdPublisher = Array.from(tableMap.publishers.values()).find(
|
||||
(publisher) => publisher.handle === "legacy-owner",
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
phase: "users",
|
||||
dryRun: false,
|
||||
scanned: 2,
|
||||
repaired: 1,
|
||||
skipped: 1,
|
||||
isDone: true,
|
||||
errors: ['user:users:conflict: Publisher handle "@existing-owner" is already claimed'],
|
||||
});
|
||||
expect(createdPublisher).toMatchObject({
|
||||
kind: "user",
|
||||
linkedUserId: "users:legacy",
|
||||
});
|
||||
expect(tableMap.users.get("users:legacy")).toMatchObject({
|
||||
personalPublisherId: createdPublisher?._id,
|
||||
});
|
||||
expect(tableMap.users.get("users:conflict")).not.toHaveProperty("personalPublisherId");
|
||||
});
|
||||
|
||||
it("repairs active legacy users, skills, aliases, embeddings, and packages", async () => {
|
||||
const { db, tableMap, patchCalls, insertCalls } = makeLegacyPublisherOwnershipDb();
|
||||
const scheduler = { runAfter: vi.fn() };
|
||||
|
||||
const usersResult = await repairLegacyPublisherOwnershipHandler({ db, scheduler } as never, {
|
||||
phase: "users",
|
||||
dryRun: false,
|
||||
batchSize: 10,
|
||||
scheduleNext: false,
|
||||
});
|
||||
const createdPublisher = Array.from(tableMap.publishers.values()).find(
|
||||
(publisher) => publisher.handle === "legacy-owner",
|
||||
);
|
||||
expect(usersResult).toMatchObject({
|
||||
phase: "users",
|
||||
dryRun: false,
|
||||
scanned: 1,
|
||||
repaired: 1,
|
||||
skipped: 0,
|
||||
isDone: true,
|
||||
});
|
||||
expect(createdPublisher).toMatchObject({
|
||||
kind: "user",
|
||||
handle: "legacy-owner",
|
||||
displayName: "Legacy Owner",
|
||||
linkedUserId: "users:legacy",
|
||||
});
|
||||
expect(tableMap.users.get("users:legacy")).toMatchObject({
|
||||
personalPublisherId: createdPublisher?._id,
|
||||
});
|
||||
expect(insertCalls.some((call) => call.table === "publisherMembers")).toBe(true);
|
||||
|
||||
const skillsResult = await repairLegacyPublisherOwnershipHandler({ db, scheduler } as never, {
|
||||
phase: "skills",
|
||||
dryRun: false,
|
||||
batchSize: 10,
|
||||
scheduleNext: false,
|
||||
});
|
||||
expect(skillsResult).toMatchObject({
|
||||
phase: "skills",
|
||||
dryRun: false,
|
||||
scanned: 2,
|
||||
repaired: 1,
|
||||
skipped: 1,
|
||||
isDone: true,
|
||||
});
|
||||
expect(tableMap.skills.get("skills:legacy")).toMatchObject({
|
||||
ownerPublisherId: createdPublisher?._id,
|
||||
});
|
||||
expect(tableMap.skills.get("skills:deleted-owner")).toMatchObject({
|
||||
ownerPublisherId: undefined,
|
||||
});
|
||||
expect(tableMap.skillSlugAliases.get("skillSlugAliases:legacy")).toMatchObject({
|
||||
ownerPublisherId: createdPublisher?._id,
|
||||
});
|
||||
expect(tableMap.skillEmbeddings.get("skillEmbeddings:legacy")).toMatchObject({
|
||||
ownerPublisherId: createdPublisher?._id,
|
||||
});
|
||||
|
||||
const packagesResult = await repairLegacyPublisherOwnershipHandler({ db, scheduler } as never, {
|
||||
phase: "packages",
|
||||
dryRun: false,
|
||||
batchSize: 10,
|
||||
scheduleNext: false,
|
||||
});
|
||||
expect(packagesResult).toMatchObject({
|
||||
phase: "packages",
|
||||
dryRun: false,
|
||||
scanned: 1,
|
||||
repaired: 1,
|
||||
skipped: 0,
|
||||
isDone: true,
|
||||
});
|
||||
expect(tableMap.packages.get("packages:legacy")).toMatchObject({
|
||||
ownerPublisherId: createdPublisher?._id,
|
||||
});
|
||||
expect(patchCalls.some((call) => call.id === "skillSearchDigest:legacy")).toBe(false);
|
||||
expect(patchCalls.some((call) => call.id === "packageSearchDigest:legacy")).toBe(false);
|
||||
expect(
|
||||
patchCalls.some(
|
||||
(call) =>
|
||||
call.id === createdPublisher?._id &&
|
||||
("publishedSkills" in call.patch || "publishedPackages" in call.patch),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("aborts apply-mode skill repair when owner projection sync fails", async () => {
|
||||
const { db } = makeLegacyPublisherOwnershipDb();
|
||||
const scheduler = { runAfter: vi.fn() };
|
||||
|
||||
await repairLegacyPublisherOwnershipHandler({ db, scheduler } as never, {
|
||||
phase: "users",
|
||||
dryRun: false,
|
||||
batchSize: 10,
|
||||
scheduleNext: false,
|
||||
});
|
||||
|
||||
const patch = db.patch;
|
||||
db.patch = vi.fn(async (id: string, value: Record<string, unknown>) => {
|
||||
if (id === "skillEmbeddings:legacy") throw new Error("embedding sync failed");
|
||||
await patch(id, value);
|
||||
});
|
||||
|
||||
await expect(
|
||||
repairLegacyPublisherOwnershipHandler({ db, scheduler } as never, {
|
||||
phase: "skills",
|
||||
dryRun: false,
|
||||
batchSize: 10,
|
||||
scheduleNext: false,
|
||||
}),
|
||||
).rejects.toThrow("embedding sync failed");
|
||||
});
|
||||
|
||||
it("propagates apply-mode package patch failures", async () => {
|
||||
const { db } = makeLegacyPublisherOwnershipDb();
|
||||
const scheduler = { runAfter: vi.fn() };
|
||||
|
||||
await repairLegacyPublisherOwnershipHandler({ db, scheduler } as never, {
|
||||
phase: "users",
|
||||
dryRun: false,
|
||||
batchSize: 10,
|
||||
scheduleNext: false,
|
||||
});
|
||||
|
||||
const patch = db.patch;
|
||||
db.patch = vi.fn(async (id: string, value: Record<string, unknown>) => {
|
||||
if (id === "packages:legacy") throw new Error("package patch failed");
|
||||
await patch(id, value);
|
||||
});
|
||||
|
||||
await expect(
|
||||
repairLegacyPublisherOwnershipHandler({ db, scheduler } as never, {
|
||||
phase: "packages",
|
||||
dryRun: false,
|
||||
batchSize: 10,
|
||||
scheduleNext: false,
|
||||
}),
|
||||
).rejects.toThrow("package patch failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("maintenance backfill", () => {
|
||||
it("patches stale skill search digest rank stats from legacy skill stats", async () => {
|
||||
const existingDigest = {
|
||||
@@ -514,6 +1107,54 @@ describe("maintenance backfill", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("backfills denormalized publisher stats through the recompute mutation", async () => {
|
||||
const runQuery = vi.fn().mockResolvedValue({
|
||||
items: [{ _id: "publishers:1" }, { _id: "publishers:2" }],
|
||||
cursor: "next",
|
||||
isDone: false,
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue({ ok: true });
|
||||
|
||||
const result = await backfillPublisherStatsInternalHandler({ runQuery, runMutation } as never, {
|
||||
dryRun: true,
|
||||
batchSize: 2,
|
||||
maxBatches: 1,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
stats: {
|
||||
publishersScanned: 2,
|
||||
publishersPatched: 0,
|
||||
},
|
||||
isDone: false,
|
||||
cursor: "next",
|
||||
});
|
||||
expect(runQuery).toHaveBeenCalledWith(
|
||||
internal.maintenance.getPublisherStatsBackfillPageInternal,
|
||||
{
|
||||
cursor: undefined,
|
||||
batchSize: 2,
|
||||
},
|
||||
);
|
||||
expect(runMutation).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
internal.maintenance.recomputePublisherStatsInternal,
|
||||
{
|
||||
publisherId: "publishers:1",
|
||||
dryRun: true,
|
||||
},
|
||||
);
|
||||
expect(runMutation).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
internal.maintenance.recomputePublisherStatsInternal,
|
||||
{
|
||||
publisherId: "publishers:2",
|
||||
dryRun: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("maintenance badge denormalization", () => {
|
||||
|
||||
+422
-1
@@ -1,10 +1,19 @@
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { ActionCtx } from "./_generated/server";
|
||||
import type { ActionCtx, MutationCtx } from "./_generated/server";
|
||||
import { action, internalAction, internalMutation, internalQuery } from "./functions";
|
||||
import { assertRole, requireUserFromAction } from "./lib/access";
|
||||
import { extractPackageDigestFields, upsertPackageSearchDigest } from "./lib/packageSearchDigest";
|
||||
import {
|
||||
derivePersonalPublisherHandle,
|
||||
ensurePersonalPublisherForUser,
|
||||
getPersonalPublisherForUser,
|
||||
getPublisherByHandle,
|
||||
getPublisherMembership,
|
||||
isPublisherActive,
|
||||
} from "./lib/publishers";
|
||||
import { recomputePublisherStats } from "./lib/publisherStats";
|
||||
import { buildSkillSummaryBackfillPatch, type ParsedSkillData } from "./lib/skillBackfill";
|
||||
import { deriveSkillCapabilityTags } from "./lib/skillCapabilityTags";
|
||||
import { isSkillCardPath } from "./lib/skillCards";
|
||||
@@ -48,6 +57,11 @@ type UserStatsBackfillStats = {
|
||||
usersPatched: number;
|
||||
};
|
||||
|
||||
type PublisherStatsBackfillStats = {
|
||||
publishersScanned: number;
|
||||
publishersPatched: number;
|
||||
};
|
||||
|
||||
type BackfillPageItem =
|
||||
| {
|
||||
kind: "ok";
|
||||
@@ -75,12 +89,32 @@ type UserStatsBackfillPageResult = {
|
||||
isDone: boolean;
|
||||
};
|
||||
|
||||
type PublisherStatsBackfillPageResult = {
|
||||
items: Array<Pick<Doc<"publishers">, "_id">>;
|
||||
cursor: string | null;
|
||||
isDone: boolean;
|
||||
};
|
||||
|
||||
type UserOwnedSkillsBackfillPageResult = {
|
||||
items: Array<Pick<Doc<"skills">, "stats" | "softDeletedAt">>;
|
||||
cursor: string | null;
|
||||
isDone: boolean;
|
||||
};
|
||||
|
||||
type LegacyPublisherOwnershipPhase = "users" | "skills" | "packages";
|
||||
|
||||
type LegacyPublisherOwnershipRepairResult = {
|
||||
phase: LegacyPublisherOwnershipPhase;
|
||||
dryRun: boolean;
|
||||
scanned: number;
|
||||
repaired: number;
|
||||
skipped: number;
|
||||
errors: string[];
|
||||
cursor: string | null;
|
||||
isDone: boolean;
|
||||
nextPhase?: LegacyPublisherOwnershipPhase;
|
||||
};
|
||||
|
||||
export const getSkillBackfillPageInternal = internalQuery({
|
||||
args: {
|
||||
cursor: v.optional(v.string()),
|
||||
@@ -179,6 +213,25 @@ export const getUserStatsBackfillPageInternal = internalQuery({
|
||||
},
|
||||
});
|
||||
|
||||
export const getPublisherStatsBackfillPageInternal = internalQuery({
|
||||
args: {
|
||||
cursor: v.optional(v.string()),
|
||||
batchSize: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<PublisherStatsBackfillPageResult> => {
|
||||
const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE);
|
||||
const { page, isDone, continueCursor } = await ctx.db
|
||||
.query("publishers")
|
||||
.order("asc")
|
||||
.paginate({ cursor: args.cursor ?? null, numItems: batchSize });
|
||||
return {
|
||||
items: page.map((publisher) => ({ _id: publisher._id })),
|
||||
cursor: continueCursor,
|
||||
isDone,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getUserOwnedSkillsBackfillPageInternal = internalQuery({
|
||||
args: {
|
||||
ownerUserId: v.id("users"),
|
||||
@@ -219,6 +272,20 @@ export const applyUserStatsBackfillPatchInternal = internalMutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const recomputePublisherStatsInternal = internalMutation({
|
||||
args: {
|
||||
publisherId: v.id("publishers"),
|
||||
dryRun: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const stats = await recomputePublisherStats(ctx, args.publisherId);
|
||||
if (!args.dryRun) {
|
||||
await ctx.db.patch(args.publisherId, stats);
|
||||
}
|
||||
return { ok: true as const, stats };
|
||||
},
|
||||
});
|
||||
|
||||
export type BackfillActionArgs = {
|
||||
dryRun?: boolean;
|
||||
batchSize?: number;
|
||||
@@ -248,6 +315,20 @@ export type UserStatsBackfillActionResult = {
|
||||
cursor: string | null;
|
||||
};
|
||||
|
||||
export type PublisherStatsBackfillActionArgs = {
|
||||
dryRun?: boolean;
|
||||
batchSize?: number;
|
||||
maxBatches?: number;
|
||||
cursor?: string;
|
||||
};
|
||||
|
||||
export type PublisherStatsBackfillActionResult = {
|
||||
ok: true;
|
||||
stats: PublisherStatsBackfillStats;
|
||||
isDone: boolean;
|
||||
cursor: string | null;
|
||||
};
|
||||
|
||||
export async function backfillSkillSummariesInternalHandler(
|
||||
ctx: ActionCtx,
|
||||
args: BackfillActionArgs,
|
||||
@@ -410,6 +491,45 @@ export async function backfillUserStatsInternalHandler(
|
||||
return { ok: true as const, stats: totals, isDone, cursor };
|
||||
}
|
||||
|
||||
export async function backfillPublisherStatsInternalHandler(
|
||||
ctx: ActionCtx,
|
||||
args: PublisherStatsBackfillActionArgs,
|
||||
): Promise<PublisherStatsBackfillActionResult> {
|
||||
const dryRun = Boolean(args.dryRun);
|
||||
const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE);
|
||||
const maxBatches = clampInt(args.maxBatches ?? DEFAULT_MAX_BATCHES, 1, MAX_MAX_BATCHES);
|
||||
const totals: PublisherStatsBackfillStats = {
|
||||
publishersScanned: 0,
|
||||
publishersPatched: 0,
|
||||
};
|
||||
|
||||
let cursor: string | null = args.cursor ?? null;
|
||||
let isDone = false;
|
||||
|
||||
for (let i = 0; i < maxBatches; i++) {
|
||||
const page = (await ctx.runQuery(internal.maintenance.getPublisherStatsBackfillPageInternal, {
|
||||
cursor: cursor ?? undefined,
|
||||
batchSize,
|
||||
})) as PublisherStatsBackfillPageResult;
|
||||
|
||||
cursor = page.cursor;
|
||||
isDone = page.isDone;
|
||||
|
||||
for (const publisher of page.items) {
|
||||
totals.publishersScanned++;
|
||||
await ctx.runMutation(internal.maintenance.recomputePublisherStatsInternal, {
|
||||
publisherId: publisher._id,
|
||||
dryRun,
|
||||
});
|
||||
if (!dryRun) totals.publishersPatched++;
|
||||
}
|
||||
|
||||
if (isDone) break;
|
||||
}
|
||||
|
||||
return { ok: true as const, stats: totals, isDone, cursor };
|
||||
}
|
||||
|
||||
export const backfillSkillSummariesInternal = internalAction({
|
||||
args: {
|
||||
dryRun: v.optional(v.boolean()),
|
||||
@@ -431,6 +551,16 @@ export const backfillUserStatsInternal = internalAction({
|
||||
handler: backfillUserStatsInternalHandler,
|
||||
});
|
||||
|
||||
export const backfillPublisherStatsInternal = internalAction({
|
||||
args: {
|
||||
dryRun: v.optional(v.boolean()),
|
||||
batchSize: v.optional(v.number()),
|
||||
maxBatches: v.optional(v.number()),
|
||||
cursor: v.optional(v.string()),
|
||||
},
|
||||
handler: backfillPublisherStatsInternalHandler,
|
||||
});
|
||||
|
||||
export const backfillSkillSummaries: ReturnType<typeof action> = action({
|
||||
args: {
|
||||
dryRun: v.optional(v.boolean()),
|
||||
@@ -449,6 +579,37 @@ export const backfillSkillSummaries: ReturnType<typeof action> = action({
|
||||
},
|
||||
});
|
||||
|
||||
export const backfillPublisherStats: ReturnType<typeof action> = action({
|
||||
args: {
|
||||
dryRun: v.optional(v.boolean()),
|
||||
batchSize: v.optional(v.number()),
|
||||
maxBatches: v.optional(v.number()),
|
||||
cursor: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<PublisherStatsBackfillActionResult> => {
|
||||
const { user } = await requireUserFromAction(ctx);
|
||||
assertRole(user, ["admin"]);
|
||||
return ctx.runAction(
|
||||
internal.maintenance.backfillPublisherStatsInternal,
|
||||
args,
|
||||
) as Promise<PublisherStatsBackfillActionResult>;
|
||||
},
|
||||
});
|
||||
|
||||
export const scheduleBackfillPublisherStats: ReturnType<typeof action> = action({
|
||||
args: { dryRun: v.optional(v.boolean()) },
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUserFromAction(ctx);
|
||||
assertRole(user, ["admin"]);
|
||||
await ctx.scheduler.runAfter(0, internal.maintenance.backfillPublisherStatsInternal, {
|
||||
dryRun: Boolean(args.dryRun),
|
||||
batchSize: DEFAULT_BATCH_SIZE,
|
||||
maxBatches: DEFAULT_MAX_BATCHES,
|
||||
});
|
||||
return { ok: true as const };
|
||||
},
|
||||
});
|
||||
|
||||
export const scheduleBackfillSkillSummaries: ReturnType<typeof action> = action({
|
||||
args: { dryRun: v.optional(v.boolean()), useAi: v.optional(v.boolean()) },
|
||||
handler: async (ctx, args) => {
|
||||
@@ -2137,6 +2298,266 @@ export const backfillPackagePluginCategoryDigestsInternal = internalMutation({
|
||||
},
|
||||
});
|
||||
|
||||
function isActiveLegacyPublisherRepairUser(
|
||||
user: Doc<"users"> | null | undefined,
|
||||
): user is Doc<"users"> {
|
||||
return Boolean(user && !user.deletedAt && !user.deactivatedAt && !user.purgedAt);
|
||||
}
|
||||
|
||||
function nextLegacyPublisherOwnershipPhase(
|
||||
phase: LegacyPublisherOwnershipPhase,
|
||||
): LegacyPublisherOwnershipPhase | undefined {
|
||||
if (phase === "users") return "skills";
|
||||
if (phase === "skills") return "packages";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function getExistingActivePersonalPublisher(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
user: Doc<"users">,
|
||||
) {
|
||||
if (user.personalPublisherId) {
|
||||
const publisher = await ctx.db.get(user.personalPublisherId);
|
||||
if (isPublisherActive(publisher)) return publisher;
|
||||
}
|
||||
const publisher = await getPersonalPublisherForUser(ctx, user._id);
|
||||
return isPublisherActive(publisher) ? publisher : null;
|
||||
}
|
||||
|
||||
async function needsPersonalPublisherRepair(ctx: Pick<MutationCtx, "db">, user: Doc<"users">) {
|
||||
const publisher = await getExistingActivePersonalPublisher(ctx, user);
|
||||
if (!publisher) return true;
|
||||
if (user.personalPublisherId !== publisher._id) return true;
|
||||
if (publisher.kind !== "user" || publisher.linkedUserId !== user._id) return true;
|
||||
const member = await getPublisherMembership(ctx, publisher._id, user._id);
|
||||
return !member;
|
||||
}
|
||||
|
||||
function pushRepairError(errors: string[], label: string, error: unknown) {
|
||||
if (errors.length >= 10) return;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
errors.push(`${label}: ${message}`);
|
||||
}
|
||||
|
||||
function isPublisherHandleConflictError(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return /Publisher handle "@[^"]+" is already claimed/.test(message);
|
||||
}
|
||||
|
||||
async function resolvePersonalPublisherForOwnershipRepair(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
user: Doc<"users">,
|
||||
dryRun: boolean,
|
||||
) {
|
||||
if (dryRun) {
|
||||
const existing = await getExistingActivePersonalPublisher(ctx, user);
|
||||
if (existing) return existing;
|
||||
const handle = derivePersonalPublisherHandle(user);
|
||||
const conflict = await getPublisherByHandle(ctx, handle);
|
||||
if (conflict && conflict.linkedUserId !== user._id) {
|
||||
throw new ConvexError(`Publisher handle "@${handle}" is already claimed`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return await ensurePersonalPublisherForUser(ctx, user, {
|
||||
source: "maintenance.legacy_publisher_ownership",
|
||||
});
|
||||
}
|
||||
|
||||
async function repairLegacySkillOwnerPublisher(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
skill: Doc<"skills">,
|
||||
dryRun: boolean,
|
||||
) {
|
||||
if (skill.ownerPublisherId) return "skipped" as const;
|
||||
const owner = await ctx.db.get(skill.ownerUserId);
|
||||
if (!isActiveLegacyPublisherRepairUser(owner)) return "skipped" as const;
|
||||
|
||||
const publisher = await resolvePersonalPublisherForOwnershipRepair(ctx, owner, dryRun);
|
||||
if (!dryRun && (!publisher || !isPublisherActive(publisher))) return "skipped" as const;
|
||||
if (dryRun) return "repaired" as const;
|
||||
|
||||
// The trigger-wrapped mutation syncs skill search digest and publisher stats.
|
||||
// This repair only patches owner projections that triggers do not own.
|
||||
await ctx.db.patch(skill._id, { ownerPublisherId: publisher!._id });
|
||||
|
||||
const aliases = await ctx.db
|
||||
.query("skillSlugAliases")
|
||||
.withIndex("by_skill", (q) => q.eq("skillId", skill._id))
|
||||
.collect();
|
||||
for (const alias of aliases) {
|
||||
if (alias.ownerPublisherId === publisher!._id) continue;
|
||||
await ctx.db.patch(alias._id, { ownerPublisherId: publisher!._id });
|
||||
}
|
||||
|
||||
const embeddings = await ctx.db
|
||||
.query("skillEmbeddings")
|
||||
.withIndex("by_skill", (q) => q.eq("skillId", skill._id))
|
||||
.collect();
|
||||
for (const embedding of embeddings) {
|
||||
if (embedding.ownerPublisherId === publisher!._id) continue;
|
||||
await ctx.db.patch(embedding._id, { ownerPublisherId: publisher!._id });
|
||||
}
|
||||
|
||||
return "repaired" as const;
|
||||
}
|
||||
|
||||
async function repairLegacyPackageOwnerPublisher(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
pkg: Doc<"packages">,
|
||||
dryRun: boolean,
|
||||
) {
|
||||
if (pkg.ownerPublisherId) return "skipped" as const;
|
||||
const owner = await ctx.db.get(pkg.ownerUserId);
|
||||
if (!isActiveLegacyPublisherRepairUser(owner)) return "skipped" as const;
|
||||
|
||||
const publisher = await resolvePersonalPublisherForOwnershipRepair(ctx, owner, dryRun);
|
||||
if (!dryRun && (!publisher || !isPublisherActive(publisher))) return "skipped" as const;
|
||||
if (dryRun) return "repaired" as const;
|
||||
|
||||
// The trigger-wrapped mutation syncs package search digests and publisher stats.
|
||||
await ctx.db.patch(pkg._id, { ownerPublisherId: publisher!._id });
|
||||
return "repaired" as const;
|
||||
}
|
||||
|
||||
export async function repairLegacyPublisherOwnershipHandler(
|
||||
ctx: MutationCtx,
|
||||
args: {
|
||||
phase?: LegacyPublisherOwnershipPhase;
|
||||
cursor?: string;
|
||||
batchSize?: number;
|
||||
delayMs?: number;
|
||||
dryRun?: boolean;
|
||||
scheduleNext?: boolean;
|
||||
},
|
||||
): Promise<LegacyPublisherOwnershipRepairResult> {
|
||||
const phase = args.phase ?? "users";
|
||||
const dryRun = args.dryRun === true;
|
||||
const batchSize = clampInt(args.batchSize ?? 50, 1, 200);
|
||||
const delayMs = clampInt(args.delayMs ?? 500, 0, 60_000);
|
||||
const errors: string[] = [];
|
||||
|
||||
let scanned = 0;
|
||||
let repaired = 0;
|
||||
let skipped = 0;
|
||||
let continueCursor: string | null = null;
|
||||
let isDone = true;
|
||||
|
||||
if (phase === "users") {
|
||||
const page = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("by_active_handle", (q) =>
|
||||
q.eq("deletedAt", undefined).eq("deactivatedAt", undefined),
|
||||
)
|
||||
.paginate({ cursor: args.cursor ?? null, numItems: batchSize });
|
||||
continueCursor = page.continueCursor;
|
||||
isDone = page.isDone;
|
||||
|
||||
for (const user of page.page) {
|
||||
scanned++;
|
||||
if (!isActiveLegacyPublisherRepairUser(user)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (!(await needsPersonalPublisherRepair(ctx, user))) continue;
|
||||
if (dryRun) {
|
||||
await resolvePersonalPublisherForOwnershipRepair(ctx, user, true);
|
||||
} else {
|
||||
await ensurePersonalPublisherForUser(ctx, user, {
|
||||
source: "maintenance.legacy_publisher_ownership",
|
||||
});
|
||||
}
|
||||
repaired++;
|
||||
} catch (error) {
|
||||
if (!dryRun && !isPublisherHandleConflictError(error)) throw error;
|
||||
skipped++;
|
||||
pushRepairError(errors, `user:${user._id}`, error);
|
||||
}
|
||||
}
|
||||
} else if (phase === "skills") {
|
||||
const page = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", undefined))
|
||||
.paginate({ cursor: args.cursor ?? null, numItems: batchSize });
|
||||
continueCursor = page.continueCursor;
|
||||
isDone = page.isDone;
|
||||
|
||||
for (const skill of page.page) {
|
||||
scanned++;
|
||||
try {
|
||||
const result = await repairLegacySkillOwnerPublisher(ctx, skill, dryRun);
|
||||
if (result === "repaired") repaired++;
|
||||
else skipped++;
|
||||
} catch (error) {
|
||||
if (!dryRun && !isPublisherHandleConflictError(error)) throw error;
|
||||
skipped++;
|
||||
pushRepairError(errors, `skill:${skill._id}`, error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const page = await ctx.db
|
||||
.query("packages")
|
||||
.withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", undefined))
|
||||
.paginate({ cursor: args.cursor ?? null, numItems: batchSize });
|
||||
continueCursor = page.continueCursor;
|
||||
isDone = page.isDone;
|
||||
|
||||
for (const pkg of page.page) {
|
||||
scanned++;
|
||||
try {
|
||||
const result = await repairLegacyPackageOwnerPublisher(ctx, pkg, dryRun);
|
||||
if (result === "repaired") repaired++;
|
||||
else skipped++;
|
||||
} catch (error) {
|
||||
if (!dryRun && !isPublisherHandleConflictError(error)) throw error;
|
||||
skipped++;
|
||||
pushRepairError(errors, `package:${pkg._id}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nextPhase = isDone ? nextLegacyPublisherOwnershipPhase(phase) : phase;
|
||||
if (!dryRun && args.scheduleNext !== false && nextPhase) {
|
||||
await ctx.scheduler.runAfter(delayMs, internal.maintenance.repairLegacyPublisherOwnership, {
|
||||
phase: nextPhase,
|
||||
cursor: isDone ? undefined : (continueCursor ?? undefined),
|
||||
batchSize: args.batchSize,
|
||||
delayMs: args.delayMs,
|
||||
scheduleNext: args.scheduleNext,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
phase,
|
||||
dryRun,
|
||||
scanned,
|
||||
repaired,
|
||||
skipped,
|
||||
errors,
|
||||
cursor: continueCursor,
|
||||
isDone,
|
||||
...(nextPhase ? { nextPhase } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// Repair legacy personal publisher ownership after the publisher model rollout.
|
||||
// Dry run one phase:
|
||||
// npx convex run maintenance:repairLegacyPublisherOwnership '{"phase":"skills","dryRun":true,"scheduleNext":false}' --prod
|
||||
// Apply all phases, scheduled batch-by-batch:
|
||||
// npx convex run maintenance:repairLegacyPublisherOwnership '{"phase":"users","batchSize":50}' --prod
|
||||
export const repairLegacyPublisherOwnership = internalMutation({
|
||||
args: {
|
||||
phase: v.optional(v.union(v.literal("users"), v.literal("skills"), v.literal("packages"))),
|
||||
cursor: v.optional(v.string()),
|
||||
batchSize: v.optional(v.number()),
|
||||
delayMs: v.optional(v.number()),
|
||||
dryRun: v.optional(v.boolean()),
|
||||
scheduleNext: v.optional(v.boolean()),
|
||||
},
|
||||
handler: repairLegacyPublisherOwnershipHandler,
|
||||
});
|
||||
|
||||
const DIGEST_OWNER_BACKFILL_KEY = "digest-owner-backfill";
|
||||
|
||||
// Start/resume backfill:
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./_generated/server", () => ({
|
||||
internalMutation: (def: { handler: unknown }) => ({ _handler: def.handler }),
|
||||
}));
|
||||
|
||||
const managementDevSeed = await import("./managementDevSeed");
|
||||
|
||||
type Handler<TArgs, TResult> = (ctx: unknown, args: TArgs) => Promise<TResult>;
|
||||
type Wrapped<TArgs, TResult> = { _handler: Handler<TArgs, TResult> };
|
||||
type TestDoc = Record<string, unknown> & { _id: string };
|
||||
|
||||
const seedManagementQueuesHandler = (
|
||||
managementDevSeed.seedManagementQueues as unknown as Wrapped<
|
||||
Record<string, never>,
|
||||
{ reportsInserted: number; reportedSkills: number; duplicatePair: number }
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const clearManagementQueuesHandler = (
|
||||
managementDevSeed.clearManagementQueues as unknown as Wrapped<
|
||||
Record<string, never>,
|
||||
{ reportsDeleted: number; fingerprintsDeleted: number }
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const DEMO_REPORT_MARKER = "managementDevSeed:report";
|
||||
const DEMO_FINGERPRINT = "9f8c2a1b7e4d6c30a5b2f1d089c4e76b";
|
||||
|
||||
function chainEq(constraints: Record<string, unknown>) {
|
||||
return {
|
||||
eq(field: string, value: unknown) {
|
||||
constraints[field] = value;
|
||||
return chainEq(constraints);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function matches(doc: TestDoc, constraints: Record<string, unknown>) {
|
||||
return Object.entries(constraints).every(([key, value]) => doc[key] === value);
|
||||
}
|
||||
|
||||
function createDb(seedTables: Record<string, TestDoc[]>) {
|
||||
const tables = Object.fromEntries(
|
||||
Object.entries(seedTables).map(([name, docs]) => [name, docs.map((doc) => ({ ...doc }))]),
|
||||
);
|
||||
const queryCalls: Array<{
|
||||
table: string;
|
||||
indexName: string;
|
||||
constraints: Record<string, unknown>;
|
||||
}> = [];
|
||||
const inserts: Array<{ table: string; doc: TestDoc }> = [];
|
||||
let insertCounter = 0;
|
||||
|
||||
const list = (table: string) => {
|
||||
tables[table] ??= [];
|
||||
return tables[table];
|
||||
};
|
||||
|
||||
const takeRows = (table: string, numItems: number, constraints?: Record<string, unknown>) => {
|
||||
const rows = constraints ? list(table).filter((doc) => matches(doc, constraints)) : list(table);
|
||||
return rows.slice(0, numItems);
|
||||
};
|
||||
|
||||
return {
|
||||
inserts,
|
||||
queryCalls,
|
||||
tables,
|
||||
db: {
|
||||
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);
|
||||
},
|
||||
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>) => {
|
||||
const inserted = { ...doc, _id: `${table}:inserted-${insertCounter}` };
|
||||
insertCounter += 1;
|
||||
list(table).push(inserted);
|
||||
inserts.push({ table, doc: inserted });
|
||||
return inserted._id;
|
||||
},
|
||||
patch: async (id: string, patch: Record<string, unknown>) => {
|
||||
const table = id.split(":")[0] ?? "";
|
||||
const row = list(table).find((doc) => doc._id === id);
|
||||
if (row) Object.assign(row, patch);
|
||||
},
|
||||
query: (table: string) => ({
|
||||
order: () => ({
|
||||
take: async (numItems: number) => takeRows(table, numItems),
|
||||
}),
|
||||
take: async (numItems: number) => takeRows(table, numItems),
|
||||
withIndex: (indexName: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
|
||||
const constraints: Record<string, unknown> = {};
|
||||
build(chainEq(constraints));
|
||||
queryCalls.push({ table, indexName, constraints });
|
||||
return {
|
||||
order: () => ({
|
||||
take: async (numItems: number) => takeRows(table, numItems, constraints),
|
||||
}),
|
||||
take: async (numItems: number) => takeRows(table, numItems, constraints),
|
||||
};
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function restoreEnv(name: string, value: string | undefined) {
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
|
||||
describe("managementDevSeed", () => {
|
||||
const previousDeployment = process.env.CONVEX_DEPLOYMENT;
|
||||
const previousDevAuthDeployment = process.env.DEV_AUTH_CONVEX_DEPLOYMENT;
|
||||
const previousDevAuthEnabled = process.env.DEV_AUTH_ENABLED;
|
||||
const previousDevImpersonation = process.env.CLAW_HUB_ENABLE_DEV_IMPERSONATION;
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv("CONVEX_DEPLOYMENT", previousDeployment);
|
||||
restoreEnv("DEV_AUTH_CONVEX_DEPLOYMENT", previousDevAuthDeployment);
|
||||
restoreEnv("DEV_AUTH_ENABLED", previousDevAuthEnabled);
|
||||
restoreEnv("CLAW_HUB_ENABLE_DEV_IMPERSONATION", previousDevImpersonation);
|
||||
});
|
||||
|
||||
it("rejects production deployments before reading tables", async () => {
|
||||
process.env.CONVEX_DEPLOYMENT = "prod:wry-manatee-359";
|
||||
const query = vi.fn();
|
||||
|
||||
await expect(seedManagementQueuesHandler({ db: { query } }, {})).rejects.toThrow(
|
||||
"disabled outside local/dev deployments",
|
||||
);
|
||||
await expect(clearManagementQueuesHandler({ db: { query } }, {})).rejects.toThrow(
|
||||
"disabled outside local/dev deployments",
|
||||
);
|
||||
expect(query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("honors the explicit fallback deployment when the primary marker is blank", async () => {
|
||||
process.env.CONVEX_DEPLOYMENT = "";
|
||||
process.env.DEV_AUTH_CONVEX_DEPLOYMENT = "prod:wry-manatee-359";
|
||||
process.env.DEV_AUTH_ENABLED = "1";
|
||||
const query = vi.fn();
|
||||
|
||||
await expect(seedManagementQueuesHandler({ db: { query } }, {})).rejects.toThrow(
|
||||
"disabled outside local/dev deployments",
|
||||
);
|
||||
await expect(clearManagementQueuesHandler({ db: { query } }, {})).rejects.toThrow(
|
||||
"disabled outside local/dev deployments",
|
||||
);
|
||||
expect(query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("seeds content report and duplicate candidate rows for local dashboards", async () => {
|
||||
process.env.CONVEX_DEPLOYMENT = "";
|
||||
process.env.DEV_AUTH_CONVEX_DEPLOYMENT = "dev:admired-dodo-615";
|
||||
const { db, inserts, tables } = createDb({
|
||||
users: [{ _id: "users:reporter", handle: "local-admin" }],
|
||||
skills: [
|
||||
{ _id: "skills:one", latestVersionId: "skillVersions:one" },
|
||||
{ _id: "skills:two", latestVersionId: "skillVersions:two" },
|
||||
{ _id: "skills:three", latestVersionId: "skillVersions:three" },
|
||||
{ _id: "skills:hidden", latestVersionId: "skillVersions:hidden", softDeletedAt: 1 },
|
||||
],
|
||||
skillVersions: [
|
||||
{ _id: "skillVersions:one" },
|
||||
{ _id: "skillVersions:two" },
|
||||
{ _id: "skillVersions:three" },
|
||||
{ _id: "skillVersions:hidden" },
|
||||
],
|
||||
skillReports: [],
|
||||
skillVersionFingerprints: [],
|
||||
});
|
||||
|
||||
await expect(seedManagementQueuesHandler({ db }, {})).resolves.toEqual({
|
||||
reportsInserted: 6,
|
||||
reportedSkills: 3,
|
||||
duplicatePair: 2,
|
||||
});
|
||||
|
||||
expect(tables.skillReports).toHaveLength(6);
|
||||
expect(tables.skillReports.every((report) => report.triageNote === DEMO_REPORT_MARKER)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(tables.skills.find((skill) => skill._id === "skills:one")).toEqual(
|
||||
expect.objectContaining({ reportCount: 1 }),
|
||||
);
|
||||
expect(tables.skills.find((skill) => skill._id === "skills:two")).toEqual(
|
||||
expect.objectContaining({ reportCount: 2 }),
|
||||
);
|
||||
expect(tables.skills.find((skill) => skill._id === "skills:three")).toEqual(
|
||||
expect.objectContaining({ reportCount: 3 }),
|
||||
);
|
||||
expect(tables.skillVersions.find((version) => version._id === "skillVersions:one")).toEqual(
|
||||
expect.objectContaining({ fingerprint: DEMO_FINGERPRINT }),
|
||||
);
|
||||
expect(tables.skillVersions.find((version) => version._id === "skillVersions:two")).toEqual(
|
||||
expect.objectContaining({ fingerprint: DEMO_FINGERPRINT }),
|
||||
);
|
||||
expect(inserts.filter((insert) => insert.table === "skillVersionFingerprints")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not overwrite existing latest-version fingerprints", async () => {
|
||||
process.env.CONVEX_DEPLOYMENT = "";
|
||||
process.env.DEV_AUTH_CONVEX_DEPLOYMENT = "dev:admired-dodo-615";
|
||||
const { db, inserts, tables } = createDb({
|
||||
users: [{ _id: "users:reporter", handle: "local-admin" }],
|
||||
skills: [
|
||||
{ _id: "skills:one", latestVersionId: "skillVersions:one" },
|
||||
{ _id: "skills:two", latestVersionId: "skillVersions:two" },
|
||||
{ _id: "skills:three", latestVersionId: "skillVersions:three" },
|
||||
{ _id: "skills:four", latestVersionId: "skillVersions:four" },
|
||||
],
|
||||
skillVersions: [
|
||||
{ _id: "skillVersions:one", fingerprint: "real-fingerprint-one" },
|
||||
{ _id: "skillVersions:two", fingerprint: "real-fingerprint-two" },
|
||||
{ _id: "skillVersions:three" },
|
||||
{ _id: "skillVersions:four" },
|
||||
],
|
||||
skillReports: [],
|
||||
skillVersionFingerprints: [
|
||||
{
|
||||
_id: "skillVersionFingerprints:real-one",
|
||||
skillId: "skills:one",
|
||||
versionId: "skillVersions:one",
|
||||
fingerprint: "real-fingerprint-one",
|
||||
},
|
||||
{
|
||||
_id: "skillVersionFingerprints:real-two",
|
||||
skillId: "skills:two",
|
||||
versionId: "skillVersions:two",
|
||||
fingerprint: "real-fingerprint-two",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(seedManagementQueuesHandler({ db }, {})).resolves.toEqual({
|
||||
reportsInserted: 6,
|
||||
reportedSkills: 3,
|
||||
duplicatePair: 2,
|
||||
});
|
||||
|
||||
expect(tables.skillVersions.find((version) => version._id === "skillVersions:one")).toEqual(
|
||||
expect.objectContaining({ fingerprint: "real-fingerprint-one" }),
|
||||
);
|
||||
expect(tables.skillVersions.find((version) => version._id === "skillVersions:two")).toEqual(
|
||||
expect.objectContaining({ fingerprint: "real-fingerprint-two" }),
|
||||
);
|
||||
expect(tables.skillVersions.find((version) => version._id === "skillVersions:three")).toEqual(
|
||||
expect.objectContaining({ fingerprint: DEMO_FINGERPRINT }),
|
||||
);
|
||||
expect(tables.skillVersions.find((version) => version._id === "skillVersions:four")).toEqual(
|
||||
expect.objectContaining({ fingerprint: DEMO_FINGERPRINT }),
|
||||
);
|
||||
expect(
|
||||
inserts
|
||||
.filter((insert) => insert.table === "skillVersionFingerprints")
|
||||
.map((insert) => insert.doc.versionId),
|
||||
).toEqual(["skillVersions:three", "skillVersions:four"]);
|
||||
});
|
||||
|
||||
it("clears only marked demo management rows", async () => {
|
||||
process.env.CONVEX_DEPLOYMENT = "dev:admired-dodo-615";
|
||||
const { db, queryCalls, tables } = createDb({
|
||||
skills: [
|
||||
{
|
||||
_id: "skills:demo",
|
||||
reportCount: 2,
|
||||
lastReportedAt: 200,
|
||||
},
|
||||
{
|
||||
_id: "skills:real",
|
||||
reportCount: 1,
|
||||
lastReportedAt: 200,
|
||||
},
|
||||
],
|
||||
skillReports: [
|
||||
{
|
||||
_id: "skillReports:demo",
|
||||
skillId: "skills:demo",
|
||||
triageNote: DEMO_REPORT_MARKER,
|
||||
status: "open",
|
||||
createdAt: 100,
|
||||
},
|
||||
{
|
||||
_id: "skillReports:demo-real",
|
||||
skillId: "skills:demo",
|
||||
triageNote: "real-user-report",
|
||||
status: "open",
|
||||
createdAt: 200,
|
||||
},
|
||||
{
|
||||
_id: "skillReports:real",
|
||||
skillId: "skills:real",
|
||||
triageNote: "real-user-report",
|
||||
status: "open",
|
||||
createdAt: 200,
|
||||
},
|
||||
],
|
||||
skillVersions: [
|
||||
{ _id: "skillVersions:demo", fingerprint: DEMO_FINGERPRINT },
|
||||
{ _id: "skillVersions:real", fingerprint: "real-fingerprint" },
|
||||
],
|
||||
skillVersionFingerprints: [
|
||||
{
|
||||
_id: "skillVersionFingerprints:demo",
|
||||
versionId: "skillVersions:demo",
|
||||
fingerprint: DEMO_FINGERPRINT,
|
||||
},
|
||||
{
|
||||
_id: "skillVersionFingerprints:real",
|
||||
versionId: "skillVersions:real",
|
||||
fingerprint: "real-fingerprint",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(clearManagementQueuesHandler({ db }, {})).resolves.toEqual({
|
||||
reportsDeleted: 1,
|
||||
fingerprintsDeleted: 1,
|
||||
});
|
||||
|
||||
expect(tables.skillReports.map((report) => report._id)).toEqual([
|
||||
"skillReports:demo-real",
|
||||
"skillReports:real",
|
||||
]);
|
||||
expect(tables.skillVersionFingerprints.map((fingerprint) => fingerprint._id)).toEqual([
|
||||
"skillVersionFingerprints:real",
|
||||
]);
|
||||
expect(tables.skillVersions.find((version) => version._id === "skillVersions:demo")).toEqual(
|
||||
expect.objectContaining({ fingerprint: undefined }),
|
||||
);
|
||||
expect(tables.skillVersions.find((version) => version._id === "skillVersions:real")).toEqual(
|
||||
expect.objectContaining({ fingerprint: "real-fingerprint" }),
|
||||
);
|
||||
expect(tables.skills.find((skill) => skill._id === "skills:demo")).toEqual(
|
||||
expect.objectContaining({ reportCount: 1, lastReportedAt: 200 }),
|
||||
);
|
||||
expect(tables.skills.find((skill) => skill._id === "skills:real")).toEqual(
|
||||
expect.objectContaining({ reportCount: 1, lastReportedAt: 200 }),
|
||||
);
|
||||
expect(queryCalls).toContainEqual({
|
||||
table: "skillVersionFingerprints",
|
||||
indexName: "by_fingerprint",
|
||||
constraints: { fingerprint: DEMO_FINGERPRINT },
|
||||
});
|
||||
expect(queryCalls).toContainEqual({
|
||||
table: "skillReports",
|
||||
indexName: "by_skill_createdAt",
|
||||
constraints: { skillId: "skills:demo" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { MutationCtx } from "./_generated/server";
|
||||
// DEV-ONLY seed for the management Content-reports and Duplicate-candidates queues.
|
||||
// Uses the un-wrapped mutation builder (not convex/functions.ts) so patching skills
|
||||
// / versions and inserting report + fingerprint rows does NOT fire table triggers.
|
||||
// It operates on existing seeded skills rather than creating new ones, so the base
|
||||
// dev seed must have run first. All demo rows carry a marker so clearDemo can remove
|
||||
// them precisely.
|
||||
import { internalMutation } from "./_generated/server";
|
||||
import { assertLocalDevSeedAllowed } from "./lib/devSeed";
|
||||
|
||||
const DEMO_REPORT_MARKER = "managementDevSeed:report";
|
||||
// Hash-like so the dashboard's fingerprint chip reads like real data; still a
|
||||
// fixed constant so clearDemo can find and remove the seeded rows.
|
||||
const DEMO_FINGERPRINT = "9f8c2a1b7e4d6c30a5b2f1d089c4e76b";
|
||||
|
||||
const DEMO_REPORT_REASONS = [
|
||||
"Possible prompt-injection hidden in the skill instructions.",
|
||||
"Looks like a copy of another publisher's skill.",
|
||||
"Requests credentials it does not appear to need.",
|
||||
"Spammy catalog filler with no real functionality.",
|
||||
];
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
const REPORT_SCAN_LIMIT = 500;
|
||||
const SKILL_SCAN_LIMIT = 50;
|
||||
|
||||
type DuplicateDemoTarget = {
|
||||
skill: Doc<"skills">;
|
||||
versionId: Id<"skillVersions">;
|
||||
};
|
||||
|
||||
// Remove previously seeded demo reports + duplicate fingerprints so the seed is
|
||||
// idempotent and the dashboard can be reset.
|
||||
async function clearDemo(ctx: Pick<MutationCtx, "db">): Promise<{
|
||||
reportsDeleted: number;
|
||||
fingerprintsDeleted: number;
|
||||
}> {
|
||||
let reportsDeleted = 0;
|
||||
let fingerprintsDeleted = 0;
|
||||
|
||||
const affectedSkillIds = new Set<Id<"skills">>();
|
||||
const reports = await ctx.db.query("skillReports").order("desc").take(REPORT_SCAN_LIMIT);
|
||||
for (const report of reports) {
|
||||
if (report.triageNote !== DEMO_REPORT_MARKER) continue;
|
||||
affectedSkillIds.add(report.skillId);
|
||||
await ctx.db.delete(report._id);
|
||||
reportsDeleted += 1;
|
||||
}
|
||||
for (const skillId of affectedSkillIds) {
|
||||
const skill = await ctx.db.get(skillId);
|
||||
if (!skill) continue;
|
||||
await restoreSkillReportSummary(ctx, skillId);
|
||||
}
|
||||
|
||||
const fingerprints = await ctx.db
|
||||
.query("skillVersionFingerprints")
|
||||
.withIndex("by_fingerprint", (q) => q.eq("fingerprint", DEMO_FINGERPRINT))
|
||||
.take(100);
|
||||
for (const fingerprint of fingerprints) {
|
||||
const version = await ctx.db.get(fingerprint.versionId);
|
||||
if (version && version.fingerprint === DEMO_FINGERPRINT) {
|
||||
await ctx.db.patch(fingerprint.versionId, { fingerprint: undefined });
|
||||
}
|
||||
await ctx.db.delete(fingerprint._id);
|
||||
fingerprintsDeleted += 1;
|
||||
}
|
||||
|
||||
return { reportsDeleted, fingerprintsDeleted };
|
||||
}
|
||||
|
||||
async function restoreSkillReportSummary(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
skillId: Id<"skills">,
|
||||
): Promise<void> {
|
||||
const reports = await ctx.db
|
||||
.query("skillReports")
|
||||
.withIndex("by_skill_createdAt", (q) => q.eq("skillId", skillId))
|
||||
.order("desc")
|
||||
.take(REPORT_SCAN_LIMIT);
|
||||
const openReports = reports.filter((report) => (report.status ?? "open") === "open");
|
||||
|
||||
await ctx.db.patch(skillId, {
|
||||
reportCount: openReports.length > 0 ? openReports.length : undefined,
|
||||
lastReportedAt: openReports[0]?.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
async function findDuplicateDemoTargets(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
skills: Doc<"skills">[],
|
||||
): Promise<DuplicateDemoTarget[]> {
|
||||
const targets: DuplicateDemoTarget[] = [];
|
||||
for (const skill of skills) {
|
||||
const versionId = skill.latestVersionId;
|
||||
if (!versionId) continue;
|
||||
const version = await ctx.db.get(versionId);
|
||||
if (!version || version.fingerprint) continue;
|
||||
targets.push({ skill, versionId });
|
||||
if (targets.length === 2) break;
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
export const seedManagementQueues = internalMutation({
|
||||
args: {},
|
||||
handler: async (
|
||||
ctx,
|
||||
): Promise<{
|
||||
reportsInserted: number;
|
||||
reportedSkills: number;
|
||||
duplicatePair: number;
|
||||
}> => {
|
||||
assertLocalDevSeedAllowed("Management");
|
||||
await clearDemo(ctx);
|
||||
const now = Date.now();
|
||||
|
||||
const reporter = (await ctx.db.query("users").take(1))[0];
|
||||
if (!reporter) {
|
||||
throw new Error("No users found to attribute demo reports to; run the base dev seed first.");
|
||||
}
|
||||
|
||||
const skills = (await ctx.db.query("skills").order("desc").take(SKILL_SCAN_LIMIT)).filter(
|
||||
(skill) => !skill.softDeletedAt && skill.latestVersionId,
|
||||
);
|
||||
if (skills.length < 2) {
|
||||
throw new Error("Need at least 2 seeded skills; run the base dev seed first.");
|
||||
}
|
||||
|
||||
// Content reports: flag the first few skills with 1-3 reports each.
|
||||
const reportTargets = skills.slice(0, Math.min(3, skills.length));
|
||||
let reportsInserted = 0;
|
||||
for (let i = 0; i < reportTargets.length; i += 1) {
|
||||
const skill = reportTargets[i];
|
||||
const count = 1 + (i % 3);
|
||||
for (let r = 0; r < count; r += 1) {
|
||||
await ctx.db.insert("skillReports", {
|
||||
skillId: skill._id,
|
||||
userId: reporter._id,
|
||||
reason: DEMO_REPORT_REASONS[(i + r) % DEMO_REPORT_REASONS.length],
|
||||
status: "open",
|
||||
triageNote: DEMO_REPORT_MARKER,
|
||||
createdAt: now - (i * 3 + r) * HOUR_MS,
|
||||
});
|
||||
reportsInserted += 1;
|
||||
}
|
||||
await ctx.db.patch(skill._id, {
|
||||
reportCount: count,
|
||||
lastReportedAt: now - i * HOUR_MS,
|
||||
});
|
||||
}
|
||||
|
||||
// Duplicate candidates: give a pair of skills the same latest-version fingerprint
|
||||
// so each surfaces the other as a near-duplicate.
|
||||
const duplicatePair = await findDuplicateDemoTargets(ctx, skills);
|
||||
for (const { skill, versionId } of duplicatePair) {
|
||||
await ctx.db.patch(versionId, { fingerprint: DEMO_FINGERPRINT });
|
||||
await ctx.db.insert("skillVersionFingerprints", {
|
||||
skillId: skill._id,
|
||||
versionId,
|
||||
fingerprint: DEMO_FINGERPRINT,
|
||||
kind: "source",
|
||||
createdAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
reportsInserted,
|
||||
reportedSkills: reportTargets.length,
|
||||
duplicatePair: duplicatePair.length,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const clearManagementQueues = internalMutation({
|
||||
args: {},
|
||||
handler: async (ctx): Promise<{ reportsDeleted: number; fingerprintsDeleted: number }> => {
|
||||
assertLocalDevSeedAllowed("Management");
|
||||
return clearDemo(ctx);
|
||||
},
|
||||
});
|
||||
@@ -1142,6 +1142,37 @@ function makeInsertReleaseCtx(
|
||||
),
|
||||
};
|
||||
}
|
||||
if (table === "officialPublishers") {
|
||||
return {
|
||||
withIndex: vi.fn(
|
||||
(
|
||||
_indexName: string,
|
||||
buildQuery?: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
|
||||
) => {
|
||||
const filters = new Map<string, unknown>();
|
||||
const query = {
|
||||
eq(field: string, value: unknown) {
|
||||
filters.set(field, value);
|
||||
return query;
|
||||
},
|
||||
};
|
||||
buildQuery?.(query);
|
||||
const rawPublisherId = filters.get("publisherId");
|
||||
const publisherId = typeof rawPublisherId === "string" ? rawPublisherId : "";
|
||||
const publisher = recordsById[publisherId];
|
||||
return {
|
||||
unique: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
publisher?.handle === "openclaw"
|
||||
? { _id: "officialPublishers:openclaw", publisherId }
|
||||
: null,
|
||||
),
|
||||
};
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
}),
|
||||
insert,
|
||||
@@ -1256,6 +1287,38 @@ function makeTransferPackageOwnerCtx(options?: {
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (table === "officialPublishers") {
|
||||
return {
|
||||
withIndex: vi.fn((_indexName: string, builder: (q: unknown) => unknown) => {
|
||||
const terms: Record<string, unknown> = {};
|
||||
builder({
|
||||
eq: (field: string, value: unknown) => {
|
||||
terms[field] = value;
|
||||
return {};
|
||||
},
|
||||
});
|
||||
const ownerPublisher =
|
||||
terms.publisherId === "publishers:openclaw"
|
||||
? (options?.ownerPublisher ?? {
|
||||
_id: "publishers:openclaw",
|
||||
kind: "org",
|
||||
handle: "openclaw",
|
||||
displayName: "OpenClaw",
|
||||
trustedPublisher: true,
|
||||
})
|
||||
: null;
|
||||
return {
|
||||
unique: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
ownerPublisher?.handle === "openclaw"
|
||||
? { _id: "officialPublishers:openclaw", publisherId: terms.publisherId }
|
||||
: null,
|
||||
),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (
|
||||
table === "packageCapabilitySearchDigest" ||
|
||||
table === "packagePluginCategorySearchDigest"
|
||||
@@ -1407,6 +1470,13 @@ function makeUserTransferPackageOwnerCtx(options?: {
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "officialPublishers") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn().mockResolvedValue(null),
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
}),
|
||||
insert,
|
||||
@@ -3773,7 +3843,7 @@ describe("packages public queries", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects official package transfers to non-OpenClaw publishers", async () => {
|
||||
it("rejects official package transfers to non-official publishers", async () => {
|
||||
const { ctx } = makeTransferPackageOwnerCtx({
|
||||
ownerPublisher: {
|
||||
_id: "publishers:openclaw",
|
||||
|
||||
+1295
-13
File diff suppressed because it is too large
Load Diff
+476
-6
@@ -1,8 +1,16 @@
|
||||
import { v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { ActionCtx, MutationCtx } from "./_generated/server";
|
||||
import { internalAction, internalMutation, internalQuery } from "./functions";
|
||||
import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server";
|
||||
import {
|
||||
action,
|
||||
internalAction,
|
||||
internalMutation,
|
||||
internalQuery,
|
||||
mutation,
|
||||
query,
|
||||
} from "./functions";
|
||||
import { assertModerator, requireUser, requireUserFromAction } from "./lib/access";
|
||||
import {
|
||||
computePublisherAbuseRawScore,
|
||||
DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG,
|
||||
@@ -20,6 +28,10 @@ const MAX_MAX_PAGES = 50;
|
||||
const ACTION_CONTINUATION_DELAY_MS = 60_000;
|
||||
const MAX_ACTIVE_SKILL_FALLBACK_SCAN = 500;
|
||||
const MAX_ACTIVE_SKILL_FALLBACK_SCANS_PER_PAGE = 20;
|
||||
const MAX_REVIEW_DASHBOARD_SCAN_MULTIPLIER = 3;
|
||||
const MAX_REVIEW_DASHBOARD_SCORE_SCAN_MULTIPLIER = 32;
|
||||
const MAX_REVIEW_DASHBOARD_SCORE_SCAN = 2000;
|
||||
const MAX_BAN_REASON_LENGTH = 500;
|
||||
|
||||
type TriageStatus = Doc<"publisherAbuseReviewNominations">["status"];
|
||||
type ScoreRun = Doc<"publisherAbuseScoreRuns">;
|
||||
@@ -68,6 +80,193 @@ type ActiveSkillFallbackBudget = {
|
||||
remainingScans: number;
|
||||
};
|
||||
|
||||
export const listReviewDashboard = query({
|
||||
args: {
|
||||
limit: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUser(ctx);
|
||||
assertModerator(user);
|
||||
|
||||
const limit = clampInt(args.limit ?? 150, 1, 250);
|
||||
const latestRun = await getLatestPublisherAbuseScoreRun(ctx);
|
||||
const scoreRankRunId = latestRun?.status === "completed" ? latestRun._id : undefined;
|
||||
const pendingPotentialBanCandidateItems = await getPendingPublisherAbuseReviewItemsForLabel(
|
||||
ctx,
|
||||
{
|
||||
status: "pending",
|
||||
label: "potential_ban_candidate",
|
||||
limit,
|
||||
latestCompletedRunId: scoreRankRunId,
|
||||
},
|
||||
);
|
||||
const pendingReviewItems = await getPendingPublisherAbuseReviewItemsForLabel(ctx, {
|
||||
status: "pending",
|
||||
label: "review",
|
||||
limit,
|
||||
latestCompletedRunId: scoreRankRunId,
|
||||
});
|
||||
const pendingItems = [...pendingPotentialBanCandidateItems, ...pendingReviewItems]
|
||||
.sort(comparePublisherAbuseReviewItemsByLastScoredAt)
|
||||
.slice(0, limit);
|
||||
const recentResolvedItems = await getRecentResolvedPublisherAbuseReviewItems(ctx, 30);
|
||||
|
||||
return {
|
||||
latestRun: latestRun ? summarizePublisherAbuseRun(latestRun) : null,
|
||||
pendingItems,
|
||||
pendingPotentialBanCandidateItems,
|
||||
pendingReviewItems,
|
||||
recentResolvedItems,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getReviewNominationDetail = query({
|
||||
args: {
|
||||
nominationId: v.id("publisherAbuseReviewNominations"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUser(ctx);
|
||||
assertModerator(user);
|
||||
|
||||
const nomination = await ctx.db.get(args.nominationId);
|
||||
if (!nomination) return null;
|
||||
|
||||
const item = await summarizePublisherAbuseReviewNomination(ctx, nomination);
|
||||
const scoreHistory = await ctx.db
|
||||
.query("publisherAbuseScores")
|
||||
.withIndex("by_owner_key_and_created_at", (q) => q.eq("ownerKey", nomination.ownerKey))
|
||||
.order("desc")
|
||||
.take(5);
|
||||
const latestScoreRun = item.latestScore ? await ctx.db.get(item.latestScore.runId) : null;
|
||||
const events = await ctx.db
|
||||
.query("publisherAbuseReviewEvents")
|
||||
.withIndex("by_nomination_and_created_at", (q) => q.eq("nominationId", nomination._id))
|
||||
.order("desc")
|
||||
.take(20);
|
||||
|
||||
return {
|
||||
item,
|
||||
latestScoreRun: latestScoreRun ? summarizePublisherAbuseRun(latestScoreRun) : null,
|
||||
scoreHistory,
|
||||
events,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const banPublisherAbuseOwner = mutation({
|
||||
args: {
|
||||
nominationId: v.id("publisherAbuseReviewNominations"),
|
||||
expectedLatestScoreId: v.id("publisherAbuseScores"),
|
||||
expectedUpdatedAt: v.number(),
|
||||
reason: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUser(ctx);
|
||||
assertModerator(user);
|
||||
|
||||
const nomination = await ctx.db.get(args.nominationId);
|
||||
if (!nomination) throw new Error("Publisher abuse nomination not found");
|
||||
requireFreshPublisherAbuseReviewNomination(nomination, args);
|
||||
requireActionablePublisherAbuseReviewNomination(nomination);
|
||||
if (!nomination.ownerUserId) {
|
||||
throw new Error("Cannot ban publisher abuse nomination without a linked user");
|
||||
}
|
||||
|
||||
const reason = normalizeBanReason(args.reason);
|
||||
await ctx.runMutation(internal.users.banUserInternal, {
|
||||
actorUserId: user._id,
|
||||
targetUserId: nomination.ownerUserId,
|
||||
reason,
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
await setPublisherAbuseReviewStatusWithActor(ctx, {
|
||||
nomination,
|
||||
status: "banned",
|
||||
notes: reason,
|
||||
actorUserId: user._id,
|
||||
now,
|
||||
});
|
||||
|
||||
return { ok: true, status: "banned" as const };
|
||||
},
|
||||
});
|
||||
|
||||
async function setPublisherAbuseReviewStatusWithActor(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
args: {
|
||||
nomination: Doc<"publisherAbuseReviewNominations">;
|
||||
status: TriageStatus;
|
||||
notes: string | undefined;
|
||||
actorUserId: Id<"users">;
|
||||
now: number;
|
||||
},
|
||||
) {
|
||||
await ctx.db.patch(args.nomination._id, {
|
||||
status: args.status,
|
||||
reviewedByUserId: args.status === "pending" ? undefined : args.actorUserId,
|
||||
reviewedAt: args.status === "pending" ? undefined : args.now,
|
||||
notes: args.notes,
|
||||
updatedAt: args.now,
|
||||
});
|
||||
await ctx.db.insert("publisherAbuseReviewEvents", {
|
||||
nominationId: args.nomination._id,
|
||||
ownerKey: args.nomination.ownerKey,
|
||||
actorUserId: args.actorUserId,
|
||||
scoreId: args.nomination.latestScoreId,
|
||||
eventType: "triage_status_changed",
|
||||
previousStatus: args.nomination.status,
|
||||
nextStatus: args.status,
|
||||
notes: args.notes,
|
||||
createdAt: args.now,
|
||||
});
|
||||
}
|
||||
|
||||
function requireFreshPublisherAbuseReviewNomination(
|
||||
nomination: Doc<"publisherAbuseReviewNominations">,
|
||||
expected: { expectedLatestScoreId: Id<"publisherAbuseScores">; expectedUpdatedAt: number },
|
||||
) {
|
||||
if (
|
||||
nomination.latestScoreId !== expected.expectedLatestScoreId ||
|
||||
nomination.updatedAt !== expected.expectedUpdatedAt
|
||||
) {
|
||||
throw new Error("Publisher abuse nomination changed; refresh and try again");
|
||||
}
|
||||
}
|
||||
|
||||
function requireActionablePublisherAbuseReviewNomination(
|
||||
nomination: Doc<"publisherAbuseReviewNominations">,
|
||||
) {
|
||||
if (nomination.label !== "potential_ban_candidate") {
|
||||
throw new Error(
|
||||
"Only potential ban publisher abuse nominations can be manually resolved; review nominations are calibration signals.",
|
||||
);
|
||||
}
|
||||
if (nomination.status !== "pending") {
|
||||
throw new Error("Only pending publisher abuse nominations can be banned.");
|
||||
}
|
||||
}
|
||||
|
||||
export const startPublisherAbuseScoreRun = action({
|
||||
args: {},
|
||||
handler: async (
|
||||
ctx,
|
||||
): Promise<{
|
||||
ok: true;
|
||||
runId: Id<"publisherAbuseScoreRuns">;
|
||||
pages: number;
|
||||
isDone: boolean;
|
||||
}> => {
|
||||
const { userId, user } = await requireUserFromAction(ctx);
|
||||
assertModerator(user);
|
||||
return await ctx.runAction(internal.publisherAbuse.runPublisherAbuseScoreRunInternal, {
|
||||
trigger: "manual",
|
||||
actorUserId: userId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const getOrStartPublisherAbuseScoreRunInternal = internalMutation({
|
||||
args: {
|
||||
trigger: v.union(v.literal("cron"), v.literal("manual")),
|
||||
@@ -136,6 +335,7 @@ export const runPublisherAbuseScoreRunInternal = internalAction({
|
||||
maxPages: v.optional(v.number()),
|
||||
forceNew: v.optional(v.boolean()),
|
||||
trigger: v.optional(v.union(v.literal("cron"), v.literal("manual"))),
|
||||
actorUserId: v.optional(v.id("users")),
|
||||
},
|
||||
handler: runPublisherAbuseScoreRunInternalHandler,
|
||||
});
|
||||
@@ -348,6 +548,7 @@ export async function runPublisherAbuseScoreRunInternalHandler(
|
||||
maxPages?: number;
|
||||
forceNew?: boolean;
|
||||
trigger?: "cron" | "manual";
|
||||
actorUserId?: Id<"users">;
|
||||
},
|
||||
): Promise<{ ok: true; runId: Id<"publisherAbuseScoreRuns">; pages: number; isDone: boolean }> {
|
||||
const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE);
|
||||
@@ -358,6 +559,7 @@ export async function runPublisherAbuseScoreRunInternalHandler(
|
||||
})
|
||||
: await ctx.runMutation(internal.publisherAbuse.getOrStartPublisherAbuseScoreRunInternal, {
|
||||
trigger: args.trigger ?? "cron",
|
||||
actorUserId: args.actorUserId,
|
||||
forceNew: args.forceNew,
|
||||
});
|
||||
let pages = 0;
|
||||
@@ -604,8 +806,9 @@ async function upsertPublisherAbuseReviewNomination(
|
||||
|
||||
if (existing) {
|
||||
const shouldReopen =
|
||||
isReviewedNominationStatus(existing.status) &&
|
||||
isPublisherAbuseLabelEscalation(existing.label, args.score.label);
|
||||
(isReopenableNominationStatus(existing.status) &&
|
||||
isPublisherAbuseLabelEscalation(existing.label, args.score.label)) ||
|
||||
(await isBannedNominationForActiveOwner(ctx, existing, args.score));
|
||||
await ctx.db.patch(existing._id, {
|
||||
latestScoreId: args.score._id,
|
||||
label: args.score.label,
|
||||
@@ -703,8 +906,25 @@ async function updateExistingPublisherAbuseReviewNominationForPass(
|
||||
return existing._id;
|
||||
}
|
||||
|
||||
function isReviewedNominationStatus(status: TriageStatus) {
|
||||
return status === "reviewed_no_action" || status === "false_positive";
|
||||
function isReopenableNominationStatus(status: TriageStatus) {
|
||||
return (
|
||||
status === "reviewed_no_action" ||
|
||||
status === "false_positive" ||
|
||||
status === "needs_policy_discussion" ||
|
||||
status === "candidate_for_future_action"
|
||||
);
|
||||
}
|
||||
|
||||
async function isBannedNominationForActiveOwner(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
nomination: Doc<"publisherAbuseReviewNominations">,
|
||||
score: ScoreDoc,
|
||||
) {
|
||||
if (nomination.status !== "banned") return false;
|
||||
const ownerUserId = score.ownerUserId ?? nomination.ownerUserId;
|
||||
if (!ownerUserId) return false;
|
||||
const ownerUser = await ctx.db.get(ownerUserId);
|
||||
return Boolean(ownerUser && !ownerUser.deletedAt && !ownerUser.deactivatedAt);
|
||||
}
|
||||
|
||||
function isPublisherAbuseLabelEscalation(
|
||||
@@ -714,6 +934,256 @@ function isPublisherAbuseLabelEscalation(
|
||||
return publisherAbuseLabelSeverity(nextLabel) > publisherAbuseLabelSeverity(previousLabel);
|
||||
}
|
||||
|
||||
type PublisherAbuseReviewItem = Awaited<ReturnType<typeof summarizePublisherAbuseReviewNomination>>;
|
||||
type PendingPublisherAbuseReviewLabel = Exclude<PublisherAbuseLabel, "pass">;
|
||||
|
||||
async function getPendingPublisherAbuseReviewItemsForLabel(
|
||||
ctx: QueryCtx,
|
||||
args: {
|
||||
status: TriageStatus;
|
||||
label: PendingPublisherAbuseReviewLabel;
|
||||
limit: number;
|
||||
latestCompletedRunId: Id<"publisherAbuseScoreRuns"> | undefined;
|
||||
},
|
||||
) {
|
||||
if (!args.latestCompletedRunId) {
|
||||
return await getPendingPublisherAbuseReviewItemsForLabelFromLastScoredAt(ctx, args);
|
||||
}
|
||||
|
||||
const scoreRankItems = await getPendingPublisherAbuseReviewItemsForLabelFromScoreRank(ctx, {
|
||||
latestCompletedRunId: args.latestCompletedRunId,
|
||||
status: args.status,
|
||||
label: args.label,
|
||||
limit: args.limit,
|
||||
});
|
||||
if (scoreRankItems.length >= args.limit) return scoreRankItems;
|
||||
|
||||
const lastScoredItems = await getPendingPublisherAbuseReviewItemsForLabelFromLastScoredAt(
|
||||
ctx,
|
||||
args,
|
||||
);
|
||||
return mergePublisherAbuseReviewItems(scoreRankItems, lastScoredItems, args.limit);
|
||||
}
|
||||
|
||||
function mergePublisherAbuseReviewItems(
|
||||
primary: PublisherAbuseReviewItem[],
|
||||
fallback: PublisherAbuseReviewItem[],
|
||||
limit: number,
|
||||
) {
|
||||
const items = [...primary];
|
||||
const seen = new Set(primary.map((item) => item.nomination._id));
|
||||
for (const item of fallback) {
|
||||
if (seen.has(item.nomination._id)) continue;
|
||||
items.push(item);
|
||||
seen.add(item.nomination._id);
|
||||
if (items.length >= limit) break;
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function scoreRankScanLimit(limit: number) {
|
||||
return Math.min(
|
||||
limit * MAX_REVIEW_DASHBOARD_SCORE_SCAN_MULTIPLIER,
|
||||
MAX_REVIEW_DASHBOARD_SCORE_SCAN,
|
||||
);
|
||||
}
|
||||
|
||||
async function getPendingPublisherAbuseReviewItemsForLabelFromScoreRank(
|
||||
ctx: QueryCtx,
|
||||
args: {
|
||||
latestCompletedRunId: Id<"publisherAbuseScoreRuns">;
|
||||
status: TriageStatus;
|
||||
label: PendingPublisherAbuseReviewLabel;
|
||||
limit: number;
|
||||
},
|
||||
) {
|
||||
const items: PublisherAbuseReviewItem[] = [];
|
||||
const scores = await ctx.db
|
||||
.query("publisherAbuseScores")
|
||||
.withIndex("by_run_and_label_and_rank", (q) =>
|
||||
q.eq("runId", args.latestCompletedRunId).eq("label", args.label),
|
||||
)
|
||||
.order("asc")
|
||||
.take(scoreRankScanLimit(args.limit));
|
||||
|
||||
for (const score of scores) {
|
||||
const nomination = await ctx.db
|
||||
.query("publisherAbuseReviewNominations")
|
||||
.withIndex("by_owner_key_and_model_version", (q) =>
|
||||
q.eq("ownerKey", score.ownerKey).eq("modelVersion", score.modelVersion),
|
||||
)
|
||||
.first();
|
||||
if (
|
||||
!nomination ||
|
||||
nomination.status !== args.status ||
|
||||
nomination.label !== args.label ||
|
||||
nomination.latestScoreId !== score._id
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const item = await summarizePublisherAbuseReviewNomination(ctx, nomination);
|
||||
if (!isVisiblePublisherAbuseReviewItem(item)) continue;
|
||||
items.push(item);
|
||||
if (items.length >= args.limit) break;
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
async function getPendingPublisherAbuseReviewItemsForLabelFromLastScoredAt(
|
||||
ctx: QueryCtx,
|
||||
args: { status: TriageStatus; label: PendingPublisherAbuseReviewLabel; limit: number },
|
||||
) {
|
||||
const items: PublisherAbuseReviewItem[] = [];
|
||||
const scanLimit = args.limit * MAX_REVIEW_DASHBOARD_SCAN_MULTIPLIER;
|
||||
const nominations = await ctx.db
|
||||
.query("publisherAbuseReviewNominations")
|
||||
.withIndex("by_status_and_label_and_last_scored_at", (q) =>
|
||||
q.eq("status", args.status).eq("label", args.label),
|
||||
)
|
||||
.order("desc")
|
||||
.take(scanLimit);
|
||||
const pageItems = await summarizePublisherAbuseReviewNominations(ctx, nominations);
|
||||
for (const item of pageItems) {
|
||||
if (!isVisiblePublisherAbuseReviewItem(item)) continue;
|
||||
items.push(item);
|
||||
if (items.length >= args.limit) break;
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
async function getLatestPublisherAbuseScoreRun(ctx: QueryCtx) {
|
||||
return await ctx.db
|
||||
.query("publisherAbuseScoreRuns")
|
||||
.withIndex("by_started_at")
|
||||
.order("desc")
|
||||
.first();
|
||||
}
|
||||
|
||||
async function getRecentResolvedPublisherAbuseReviewItems(ctx: QueryCtx, limit: number) {
|
||||
const resolvedStatuses: TriageStatus[] = [
|
||||
"banned",
|
||||
"reviewed_no_action",
|
||||
"false_positive",
|
||||
"needs_policy_discussion",
|
||||
"candidate_for_future_action",
|
||||
];
|
||||
const nominations: Doc<"publisherAbuseReviewNominations">[] = [];
|
||||
for (const status of resolvedStatuses) {
|
||||
const page = await ctx.db
|
||||
.query("publisherAbuseReviewNominations")
|
||||
.withIndex("by_status_and_reviewed_at", (q) => q.eq("status", status))
|
||||
.order("desc")
|
||||
.take(limit);
|
||||
nominations.push(...page);
|
||||
}
|
||||
nominations.sort((left, right) => (right.reviewedAt ?? 0) - (left.reviewedAt ?? 0));
|
||||
return await summarizePublisherAbuseReviewNominations(ctx, nominations.slice(0, limit));
|
||||
}
|
||||
|
||||
async function summarizePublisherAbuseReviewNominations(
|
||||
ctx: QueryCtx,
|
||||
nominations: Doc<"publisherAbuseReviewNominations">[],
|
||||
) {
|
||||
const items = [];
|
||||
for (const nomination of nominations) {
|
||||
items.push(await summarizePublisherAbuseReviewNomination(ctx, nomination));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
async function summarizePublisherAbuseReviewNomination(
|
||||
ctx: QueryCtx,
|
||||
nomination: Doc<"publisherAbuseReviewNominations">,
|
||||
) {
|
||||
const score = await ctx.db.get(nomination.latestScoreId);
|
||||
const publisher = nomination.ownerPublisherId
|
||||
? await ctx.db.get(nomination.ownerPublisherId)
|
||||
: null;
|
||||
const ownerUser = nomination.ownerUserId ? await ctx.db.get(nomination.ownerUserId) : null;
|
||||
const openedByRun = await ctx.db.get(nomination.openedByRunId);
|
||||
|
||||
return {
|
||||
nomination,
|
||||
latestScore: score,
|
||||
publisher: publisher ? summarizePublisherForAbuseReview(publisher) : null,
|
||||
ownerUser: ownerUser ? summarizeUserForAbuseReview(ownerUser) : null,
|
||||
openedByRun: openedByRun ? summarizePublisherAbuseRun(openedByRun) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function isVisiblePublisherAbuseReviewItem(item: PublisherAbuseReviewItem) {
|
||||
return (
|
||||
item.nomination.label !== "pass" &&
|
||||
!item.ownerUser?.deletedAt &&
|
||||
!item.ownerUser?.deactivatedAt &&
|
||||
!item.publisher?.deletedAt &&
|
||||
!item.publisher?.deactivatedAt
|
||||
);
|
||||
}
|
||||
|
||||
function comparePublisherAbuseReviewItemsByLastScoredAt(
|
||||
left: PublisherAbuseReviewItem,
|
||||
right: PublisherAbuseReviewItem,
|
||||
) {
|
||||
if (left.nomination.lastScoredAt !== right.nomination.lastScoredAt) {
|
||||
return right.nomination.lastScoredAt - left.nomination.lastScoredAt;
|
||||
}
|
||||
return right.nomination._id.localeCompare(left.nomination._id);
|
||||
}
|
||||
|
||||
function summarizePublisherAbuseRun(run: Doc<"publisherAbuseScoreRuns">) {
|
||||
const {
|
||||
actorUserId: _actorUserId,
|
||||
collectCursor: _collectCursor,
|
||||
finalizeCursor: _finalizeCursor,
|
||||
modelConfig: _modelConfig,
|
||||
sumLogPressure: _sumLogPressure,
|
||||
sumSquaredLogPressure: _sumSquaredLogPressure,
|
||||
...summary
|
||||
} = run;
|
||||
return summary;
|
||||
}
|
||||
|
||||
function summarizePublisherForAbuseReview(publisher: Doc<"publishers">) {
|
||||
return {
|
||||
_id: publisher._id,
|
||||
handle: publisher.handle,
|
||||
displayName: publisher.displayName,
|
||||
kind: publisher.kind,
|
||||
linkedUserId: publisher.linkedUserId,
|
||||
publishedSkills: publisher.publishedSkills,
|
||||
publishedPackages: publisher.publishedPackages,
|
||||
totalInstalls: publisher.totalInstalls,
|
||||
totalStars: publisher.totalStars,
|
||||
totalDownloads: publisher.totalDownloads,
|
||||
skillTotalInstalls: publisher.skillTotalInstalls,
|
||||
skillTotalStars: publisher.skillTotalStars,
|
||||
skillTotalDownloads: publisher.skillTotalDownloads,
|
||||
deletedAt: publisher.deletedAt,
|
||||
deactivatedAt: publisher.deactivatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeUserForAbuseReview(user: Doc<"users">) {
|
||||
return {
|
||||
_id: user._id,
|
||||
handle: user.handle,
|
||||
name: user.name,
|
||||
displayName: user.displayName,
|
||||
role: user.role,
|
||||
image: user.image,
|
||||
deletedAt: user.deletedAt,
|
||||
deactivatedAt: user.deactivatedAt,
|
||||
banReason: user.banReason,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBanReason(rawReason?: string) {
|
||||
const reason = rawReason?.trim();
|
||||
if (!reason) return undefined;
|
||||
return reason.slice(0, MAX_BAN_REASON_LENGTH);
|
||||
}
|
||||
|
||||
function publisherAbuseLabelSeverity(label: PublisherAbuseLabel) {
|
||||
if (label === "potential_ban_candidate") return 2;
|
||||
if (label === "review") return 1;
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./_generated/server", () => ({
|
||||
internalMutation: (def: { handler: unknown }) => ({ _handler: def.handler }),
|
||||
}));
|
||||
|
||||
const publisherAbuseDevSeed = await import("./publisherAbuseDevSeed");
|
||||
|
||||
type Handler<TArgs, TResult> = (ctx: unknown, args: TArgs) => Promise<TResult>;
|
||||
type Wrapped<TArgs, TResult> = { _handler: Handler<TArgs, TResult> };
|
||||
|
||||
const clearSeedHandler = (
|
||||
publisherAbuseDevSeed.clearSeed as unknown as Wrapped<
|
||||
Record<string, never>,
|
||||
{
|
||||
runs: number;
|
||||
scores: number;
|
||||
nominations: number;
|
||||
events: number;
|
||||
users: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const seedHandler = (
|
||||
publisherAbuseDevSeed.seed as unknown as Wrapped<
|
||||
Record<string, never>,
|
||||
{ runId: string; inserted: number }
|
||||
>
|
||||
)._handler;
|
||||
|
||||
type TestDoc = Record<string, unknown> & { _id: string };
|
||||
|
||||
function chainEq(constraints: Record<string, unknown>) {
|
||||
return {
|
||||
eq(field: string, value: unknown) {
|
||||
constraints[field] = value;
|
||||
return chainEq(constraints);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function matches(doc: TestDoc, constraints: Record<string, unknown>) {
|
||||
return Object.entries(constraints).every(([key, value]) => doc[key] === value);
|
||||
}
|
||||
|
||||
function createDb(seedTables: Record<string, TestDoc[]>) {
|
||||
const tables = Object.fromEntries(
|
||||
Object.entries(seedTables).map(([name, docs]) => [name, [...docs]]),
|
||||
);
|
||||
let insertCounter = 0;
|
||||
const queryCalls: Array<{
|
||||
table: string;
|
||||
indexName: string;
|
||||
constraints: Record<string, unknown>;
|
||||
}> = [];
|
||||
|
||||
const list = (table: string) => {
|
||||
tables[table] ??= [];
|
||||
return tables[table];
|
||||
};
|
||||
|
||||
return {
|
||||
tables,
|
||||
queryCalls,
|
||||
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>) => {
|
||||
const id = `${table}:inserted-${insertCounter}`;
|
||||
insertCounter += 1;
|
||||
list(table).push({ ...doc, _id: id });
|
||||
return id;
|
||||
},
|
||||
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: (indexName: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
|
||||
const constraints: Record<string, unknown> = {};
|
||||
build(chainEq(constraints));
|
||||
queryCalls.push({ table, indexName, constraints });
|
||||
const matched = () => list(table).filter((doc) => matches(doc, constraints));
|
||||
return {
|
||||
collect: async () => {
|
||||
throw new Error("clearSeed must not collect whole tables");
|
||||
},
|
||||
paginate: async () => {
|
||||
throw new Error("clearSeed must not use built-in pagination");
|
||||
},
|
||||
take: async (numItems: number) => {
|
||||
return matched().slice(0, numItems);
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("publisherAbuseDevSeed.clearSeed", () => {
|
||||
const previousDeployment = process.env.CONVEX_DEPLOYMENT;
|
||||
const previousDevAuthDeployment = process.env.DEV_AUTH_CONVEX_DEPLOYMENT;
|
||||
const previousDevAuthEnabled = process.env.DEV_AUTH_ENABLED;
|
||||
const previousDevImpersonation = process.env.CLAW_HUB_ENABLE_DEV_IMPERSONATION;
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv("CONVEX_DEPLOYMENT", previousDeployment);
|
||||
restoreEnv("DEV_AUTH_CONVEX_DEPLOYMENT", previousDevAuthDeployment);
|
||||
restoreEnv("DEV_AUTH_ENABLED", previousDevAuthEnabled);
|
||||
restoreEnv("CLAW_HUB_ENABLE_DEV_IMPERSONATION", previousDevImpersonation);
|
||||
});
|
||||
|
||||
it("rejects production deployments before reading tables", async () => {
|
||||
process.env.CONVEX_DEPLOYMENT = "prod:wry-manatee-359";
|
||||
const query = vi.fn();
|
||||
|
||||
await expect(clearSeedHandler({ db: { query } }, {})).rejects.toThrow(
|
||||
"disabled outside local/dev deployments",
|
||||
);
|
||||
expect(query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("honors the explicit fallback deployment when the primary marker is blank", async () => {
|
||||
process.env.CONVEX_DEPLOYMENT = "";
|
||||
process.env.DEV_AUTH_CONVEX_DEPLOYMENT = "prod:wry-manatee-359";
|
||||
process.env.DEV_AUTH_ENABLED = "1";
|
||||
const query = vi.fn();
|
||||
|
||||
await expect(clearSeedHandler({ db: { query } }, {})).rejects.toThrow(
|
||||
"disabled outside local/dev deployments",
|
||||
);
|
||||
expect(query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes demo rows through bounded indexed pages", async () => {
|
||||
process.env.CONVEX_DEPLOYMENT = "dev:admired-dodo-615";
|
||||
const { db, queryCalls, tables } = createDb({
|
||||
publisherAbuseScores: [
|
||||
{
|
||||
_id: "publisherAbuseScores:demo",
|
||||
ownerKey: "user:demo-01",
|
||||
handleSnapshot: "demo-abuse-pub-01",
|
||||
runId: "publisherAbuseScoreRuns:demo",
|
||||
},
|
||||
{
|
||||
_id: "publisherAbuseScores:real",
|
||||
ownerKey: "user:real",
|
||||
handleSnapshot: "real",
|
||||
runId: "publisherAbuseScoreRuns:real",
|
||||
},
|
||||
],
|
||||
publisherAbuseReviewNominations: [
|
||||
{
|
||||
_id: "publisherAbuseReviewNominations:demo",
|
||||
ownerKey: "user:demo-01",
|
||||
handleSnapshot: "demo-abuse-pub-01",
|
||||
openedByRunId: "publisherAbuseScoreRuns:demo",
|
||||
},
|
||||
{
|
||||
_id: "publisherAbuseReviewNominations:real",
|
||||
ownerKey: "user:real",
|
||||
handleSnapshot: "real",
|
||||
openedByRunId: "publisherAbuseScoreRuns:real",
|
||||
},
|
||||
],
|
||||
publisherAbuseScoreRuns: [
|
||||
{ _id: "publisherAbuseScoreRuns:demo" },
|
||||
{ _id: "publisherAbuseScoreRuns:real" },
|
||||
],
|
||||
publisherAbuseReviewEvents: [
|
||||
{
|
||||
_id: "publisherAbuseReviewEvents:demo",
|
||||
ownerKey: "user:demo-01",
|
||||
nominationId: "publisherAbuseReviewNominations:demo",
|
||||
},
|
||||
{
|
||||
_id: "publisherAbuseReviewEvents:real",
|
||||
ownerKey: "user:real",
|
||||
nominationId: "publisherAbuseReviewNominations:real",
|
||||
},
|
||||
],
|
||||
users: [
|
||||
{ _id: "users:demo", handle: "demo-abuse-pub-01" },
|
||||
{ _id: "users:real", handle: "real" },
|
||||
],
|
||||
});
|
||||
|
||||
await expect(clearSeedHandler({ db }, {})).resolves.toEqual({
|
||||
runs: 1,
|
||||
scores: 1,
|
||||
nominations: 1,
|
||||
events: 1,
|
||||
users: 1,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
expect(tables.publisherAbuseScores.map((doc) => doc._id)).toEqual([
|
||||
"publisherAbuseScores:real",
|
||||
]);
|
||||
expect(tables.publisherAbuseReviewNominations.map((doc) => doc._id)).toEqual([
|
||||
"publisherAbuseReviewNominations:real",
|
||||
]);
|
||||
expect(tables.publisherAbuseScoreRuns.map((doc) => doc._id)).toEqual([
|
||||
"publisherAbuseScoreRuns:real",
|
||||
]);
|
||||
expect(tables.publisherAbuseReviewEvents.map((doc) => doc._id)).toEqual([
|
||||
"publisherAbuseReviewEvents:real",
|
||||
]);
|
||||
expect(tables.users.map((doc) => doc._id)).toEqual(["users:real"]);
|
||||
expect(queryCalls).toContainEqual({
|
||||
table: "publisherAbuseScores",
|
||||
indexName: "by_owner_key_and_created_at",
|
||||
constraints: { ownerKey: "user:demo-01" },
|
||||
});
|
||||
expect(queryCalls).toContainEqual({
|
||||
table: "publisherAbuseReviewNominations",
|
||||
indexName: "by_owner_key_and_model_version",
|
||||
constraints: { ownerKey: "user:demo-01" },
|
||||
});
|
||||
expect(queryCalls).toContainEqual({
|
||||
table: "publisherAbuseReviewEvents",
|
||||
indexName: "by_owner_key_and_created_at",
|
||||
constraints: { ownerKey: "user:demo-01" },
|
||||
});
|
||||
expect(queryCalls).toContainEqual({
|
||||
table: "users",
|
||||
indexName: "handle",
|
||||
constraints: { handle: "demo-abuse-pub-01" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("publisherAbuseDevSeed.seed", () => {
|
||||
const previousDeployment = process.env.CONVEX_DEPLOYMENT;
|
||||
const previousDevAuthDeployment = process.env.DEV_AUTH_CONVEX_DEPLOYMENT;
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv("CONVEX_DEPLOYMENT", previousDeployment);
|
||||
restoreEnv("DEV_AUTH_CONVEX_DEPLOYMENT", previousDevAuthDeployment);
|
||||
});
|
||||
|
||||
it("seeds a prod-scale nomination distribution across labels", async () => {
|
||||
process.env.CONVEX_DEPLOYMENT = "";
|
||||
process.env.DEV_AUTH_CONVEX_DEPLOYMENT = "dev:admired-dodo-615";
|
||||
const { db, tables } = createDb({});
|
||||
|
||||
const result = await seedHandler({ db }, {});
|
||||
|
||||
const nominations = tables.publisherAbuseReviewNominations ?? [];
|
||||
const pendingBan = nominations.filter(
|
||||
(doc) => doc.label === "potential_ban_candidate" && doc.status === "pending",
|
||||
);
|
||||
const pendingReview = nominations.filter(
|
||||
(doc) => doc.label === "review" && doc.status === "pending",
|
||||
);
|
||||
|
||||
expect(pendingBan).toHaveLength(15);
|
||||
expect(pendingReview).toHaveLength(124);
|
||||
expect(result.inserted).toBe(nominations.length);
|
||||
// Every ban candidate links a demo user so the inspector ban action is
|
||||
// exercisable; review nominations do not create users.
|
||||
expect(tables.users ?? []).toHaveLength(15);
|
||||
});
|
||||
|
||||
it("clears existing demo rows before inserting repeatable seed data", async () => {
|
||||
process.env.CONVEX_DEPLOYMENT = "";
|
||||
process.env.DEV_AUTH_CONVEX_DEPLOYMENT = "dev:admired-dodo-615";
|
||||
const { db, tables } = createDb({
|
||||
publisherAbuseScores: [
|
||||
{
|
||||
_id: "publisherAbuseScores:old-demo",
|
||||
ownerKey: "user:demo-01",
|
||||
handleSnapshot: "demo-abuse-pub-01",
|
||||
runId: "publisherAbuseScoreRuns:old-demo",
|
||||
},
|
||||
],
|
||||
publisherAbuseReviewNominations: [
|
||||
{
|
||||
_id: "publisherAbuseReviewNominations:old-demo",
|
||||
ownerKey: "user:demo-01",
|
||||
handleSnapshot: "demo-abuse-pub-01",
|
||||
openedByRunId: "publisherAbuseScoreRuns:old-demo",
|
||||
},
|
||||
],
|
||||
publisherAbuseScoreRuns: [{ _id: "publisherAbuseScoreRuns:old-demo" }],
|
||||
publisherAbuseReviewEvents: [
|
||||
{
|
||||
_id: "publisherAbuseReviewEvents:old-demo",
|
||||
ownerKey: "user:demo-01",
|
||||
nominationId: "publisherAbuseReviewNominations:old-demo",
|
||||
},
|
||||
],
|
||||
users: [{ _id: "users:old-demo", handle: "demo-abuse-pub-01" }],
|
||||
});
|
||||
|
||||
await seedHandler({ db }, {});
|
||||
|
||||
expect(tables.publisherAbuseScores.map((doc) => doc._id)).not.toContain(
|
||||
"publisherAbuseScores:old-demo",
|
||||
);
|
||||
expect(tables.publisherAbuseReviewNominations.map((doc) => doc._id)).not.toContain(
|
||||
"publisherAbuseReviewNominations:old-demo",
|
||||
);
|
||||
expect(tables.publisherAbuseScoreRuns.map((doc) => doc._id)).not.toContain(
|
||||
"publisherAbuseScoreRuns:old-demo",
|
||||
);
|
||||
expect(tables.publisherAbuseReviewEvents.map((doc) => doc._id)).not.toContain(
|
||||
"publisherAbuseReviewEvents:old-demo",
|
||||
);
|
||||
expect(tables.users.map((doc) => doc._id)).not.toContain("users:old-demo");
|
||||
expect(tables.users.filter((doc) => doc.handle === "demo-abuse-pub-01")).toHaveLength(1);
|
||||
expect(tables.users).toHaveLength(15);
|
||||
expect(tables.publisherAbuseReviewNominations).toHaveLength(145);
|
||||
});
|
||||
});
|
||||
|
||||
function restoreEnv(name: string, value: string | undefined) {
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { MutationCtx } from "./_generated/server";
|
||||
// DEV-ONLY seed: use the un-wrapped mutation builder (not convex/functions.ts) so
|
||||
// inserting/deleting demo rows does NOT fire table triggers. The users digest-sync
|
||||
// trigger runs a paginated query, and Convex allows only one paginated query per
|
||||
// mutation, so deleting several linked demo users through the wrapped builder fails.
|
||||
// Demo rows have no real packages/skills, so skipping digest sync is correct here.
|
||||
import { internalMutation } from "./_generated/server";
|
||||
import { assertLocalDevSeedAllowed } from "./lib/devSeed";
|
||||
import {
|
||||
computePublisherAbuseRawScore,
|
||||
DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG,
|
||||
PUBLISHER_ABUSE_MODEL_VERSION,
|
||||
type PublisherAbuseLabel,
|
||||
} from "./lib/publisherAbuseScoring";
|
||||
|
||||
// DEV-ONLY seed for the publisher-abuse review dashboard. It inserts one
|
||||
// completed score run plus a spread of synthetic scores/nominations so every
|
||||
// dashboard tab renders with realistic rows. All synthetic rows use the
|
||||
// "demo-" prefix on handle/ownerKey so `clearSeed` can remove them precisely.
|
||||
|
||||
const DEMO_HANDLE_PREFIX = "demo-abuse-pub-";
|
||||
const DEMO_OWNER_KEY_PREFIX = "user:demo-";
|
||||
const CLEAR_SEED_BATCH_SIZE = 100;
|
||||
|
||||
type TriageStatus =
|
||||
| "pending"
|
||||
| "reviewed_no_action"
|
||||
| "false_positive"
|
||||
| "needs_policy_discussion"
|
||||
| "candidate_for_future_action";
|
||||
|
||||
type SeedPublisher = {
|
||||
index: number;
|
||||
label: PublisherAbuseLabel;
|
||||
status: TriageStatus;
|
||||
zScore: number;
|
||||
publishedSkills: number;
|
||||
totalInstalls: number;
|
||||
totalStars: number;
|
||||
totalDownloads: number;
|
||||
reasonCodes: string[];
|
||||
notes?: string;
|
||||
// When true, also create an isolated demo user account and link it so the
|
||||
// inspector's "Ban user" action is enabled and exercisable in dev.
|
||||
linkUser?: boolean;
|
||||
};
|
||||
|
||||
// Prod-scale synthetic distribution so every dashboard tab renders with realistic
|
||||
// volume: 15 potential-ban candidates and 124 review nominations (both pending),
|
||||
// plus a small resolved/pass set for the Resolved tab. Counts mirror the reported
|
||||
// production review queue. Rows are deterministic (no randomness) so tests can
|
||||
// assert the distribution and clearSeed stays reproducible.
|
||||
const BAN_CANDIDATE_COUNT = 15;
|
||||
const REVIEW_PENDING_COUNT = 124;
|
||||
|
||||
const BAN_CANDIDATE_REASON_CODES = [
|
||||
"high_catalog_volume",
|
||||
"extreme_volume_low_engagement",
|
||||
"low_installs_per_skill",
|
||||
"low_stars_per_skill",
|
||||
"low_downloads_per_skill",
|
||||
];
|
||||
|
||||
const REVIEW_REASON_VARIANTS: string[][] = [
|
||||
["high_catalog_volume", "low_installs_per_skill", "low_stars_per_skill"],
|
||||
["high_catalog_volume", "low_installs_per_skill"],
|
||||
["high_catalog_volume", "low_stars_per_skill", "low_downloads_per_skill"],
|
||||
["high_catalog_volume", "low_installs_per_skill", "low_downloads_per_skill"],
|
||||
];
|
||||
|
||||
// Resolved + pass anchors keep the Resolved tab populated and exercise the
|
||||
// inspector's notes rendering. None link a demo user, so the only seeded demo
|
||||
// users are the 15 pending ban candidates.
|
||||
const RESOLVED_AND_PASS_PUBLISHERS: Array<Omit<SeedPublisher, "index">> = [
|
||||
{
|
||||
label: "potential_ban_candidate",
|
||||
status: "needs_policy_discussion",
|
||||
zScore: 2.75,
|
||||
publishedSkills: 2600,
|
||||
totalInstalls: 210,
|
||||
totalStars: 28,
|
||||
totalDownloads: 6400,
|
||||
reasonCodes: BAN_CANDIDATE_REASON_CODES,
|
||||
notes: "Escalated to policy: borderline catalog-stuffing pattern, awaiting decision.",
|
||||
},
|
||||
{
|
||||
label: "review",
|
||||
status: "false_positive",
|
||||
zScore: 1.8,
|
||||
publishedSkills: 340,
|
||||
totalInstalls: 520,
|
||||
totalStars: 40,
|
||||
totalDownloads: 48000,
|
||||
reasonCodes: ["high_catalog_volume", "low_installs_per_skill"],
|
||||
notes: "Confirmed legitimate bulk publisher; cleared after manual spot-check.",
|
||||
},
|
||||
{
|
||||
label: "review",
|
||||
status: "candidate_for_future_action",
|
||||
zScore: 2.0,
|
||||
publishedSkills: 480,
|
||||
totalInstalls: 360,
|
||||
totalStars: 17,
|
||||
totalDownloads: 29000,
|
||||
reasonCodes: ["high_catalog_volume", "low_installs_per_skill", "low_stars_per_skill"],
|
||||
notes: "Watchlist: revisit if catalog keeps growing without engagement.",
|
||||
},
|
||||
{
|
||||
label: "review",
|
||||
status: "reviewed_no_action",
|
||||
zScore: 1.6,
|
||||
publishedSkills: 290,
|
||||
totalInstalls: 470,
|
||||
totalStars: 33,
|
||||
totalDownloads: 31000,
|
||||
reasonCodes: ["high_catalog_volume", "low_installs_per_skill"],
|
||||
notes: "Reviewed: engagement within acceptable range for catalog size.",
|
||||
},
|
||||
{
|
||||
label: "pass",
|
||||
status: "reviewed_no_action",
|
||||
zScore: 0.4,
|
||||
publishedSkills: 120,
|
||||
totalInstalls: 9800,
|
||||
totalStars: 540,
|
||||
totalDownloads: 210000,
|
||||
reasonCodes: [],
|
||||
notes: "Healthy engagement per skill; no action needed.",
|
||||
},
|
||||
{
|
||||
label: "pass",
|
||||
status: "reviewed_no_action",
|
||||
zScore: 0.2,
|
||||
publishedSkills: 64,
|
||||
totalInstalls: 7200,
|
||||
totalStars: 410,
|
||||
totalDownloads: 150000,
|
||||
reasonCodes: [],
|
||||
notes: "Strong installs and stars per skill; clearly legitimate.",
|
||||
},
|
||||
];
|
||||
|
||||
function roundToTwo(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
// Ban candidates carry the highest z-scores (3.9 → 2.55) and link demo users so
|
||||
// the inspector ban action is exercisable; review nominations span the "on the
|
||||
// brink" band (2.4 → 1.3). Metrics vary per row so the inspector looks realistic.
|
||||
function buildSeedPublishers(): SeedPublisher[] {
|
||||
const publishers: SeedPublisher[] = [];
|
||||
let index = 1;
|
||||
|
||||
for (let i = 0; i < BAN_CANDIDATE_COUNT; i += 1) {
|
||||
const fraction = i / (BAN_CANDIDATE_COUNT - 1);
|
||||
publishers.push({
|
||||
index,
|
||||
label: "potential_ban_candidate",
|
||||
status: "pending",
|
||||
zScore: roundToTwo(3.9 - fraction * 1.35),
|
||||
publishedSkills: 4200 - i * 170,
|
||||
totalInstalls: 130 + (i % 6) * 16,
|
||||
totalStars: 15 + (i % 8) * 2,
|
||||
totalDownloads: 9800 - i * 300,
|
||||
reasonCodes: BAN_CANDIDATE_REASON_CODES,
|
||||
linkUser: true,
|
||||
});
|
||||
index += 1;
|
||||
}
|
||||
|
||||
for (let i = 0; i < REVIEW_PENDING_COUNT; i += 1) {
|
||||
const fraction = i / (REVIEW_PENDING_COUNT - 1);
|
||||
publishers.push({
|
||||
index,
|
||||
label: "review",
|
||||
status: "pending",
|
||||
zScore: roundToTwo(2.4 - fraction * 1.1),
|
||||
publishedSkills: 650 - i * 3,
|
||||
totalInstalls: 300 + (i % 9) * 30,
|
||||
totalStars: 14 + (i % 11) * 3,
|
||||
totalDownloads: 26000 + (i % 13) * 1500,
|
||||
reasonCodes: REVIEW_REASON_VARIANTS[i % REVIEW_REASON_VARIANTS.length],
|
||||
});
|
||||
index += 1;
|
||||
}
|
||||
|
||||
for (const publisher of RESOLVED_AND_PASS_PUBLISHERS) {
|
||||
publishers.push({ index, ...publisher });
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return publishers;
|
||||
}
|
||||
|
||||
const SEED_PUBLISHERS: SeedPublisher[] = buildSeedPublishers();
|
||||
|
||||
const SCANNED_PUBLISHERS = 194_083;
|
||||
const SCORED_PUBLISHERS = 10_349;
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
|
||||
function paddedIndex(index: number): string {
|
||||
return index.toString().padStart(2, "0");
|
||||
}
|
||||
|
||||
function isDemoHandle(handle: string): boolean {
|
||||
return handle.startsWith(DEMO_HANDLE_PREFIX);
|
||||
}
|
||||
|
||||
function isDemoOwnerKey(ownerKey: string): boolean {
|
||||
return ownerKey.startsWith(DEMO_OWNER_KEY_PREFIX);
|
||||
}
|
||||
|
||||
function demoHandle(index: number): string {
|
||||
return `${DEMO_HANDLE_PREFIX}${paddedIndex(index)}`;
|
||||
}
|
||||
|
||||
function demoOwnerKey(index: number): string {
|
||||
return `${DEMO_OWNER_KEY_PREFIX}${paddedIndex(index)}`;
|
||||
}
|
||||
|
||||
const DEMO_HANDLES = SEED_PUBLISHERS.map((publisher) => demoHandle(publisher.index));
|
||||
const DEMO_OWNER_KEYS = SEED_PUBLISHERS.map((publisher) => demoOwnerKey(publisher.index));
|
||||
|
||||
type ClearSeedCtx = Pick<MutationCtx, "db">;
|
||||
type ClearSeedResult = {
|
||||
runs: number;
|
||||
scores: number;
|
||||
nominations: number;
|
||||
events: number;
|
||||
users: number;
|
||||
hasMore: boolean;
|
||||
};
|
||||
|
||||
export const seed = internalMutation({
|
||||
args: {},
|
||||
handler: async (ctx): Promise<{ runId: Id<"publisherAbuseScoreRuns">; inserted: number }> => {
|
||||
assertLocalDevSeedAllowed("Publisher abuse");
|
||||
await clearDemoRows(ctx);
|
||||
|
||||
const now = Date.now();
|
||||
const startedAt = now - 2 * HOUR_MS;
|
||||
const completedAt = now - HOUR_MS;
|
||||
|
||||
const labelCounts: Record<PublisherAbuseLabel, number> = {
|
||||
pass: 0,
|
||||
review: 0,
|
||||
potential_ban_candidate: 0,
|
||||
};
|
||||
let nominatedPublishers = 0;
|
||||
let sumLogPressure = 0;
|
||||
let sumSquaredLogPressure = 0;
|
||||
for (const publisher of SEED_PUBLISHERS) {
|
||||
labelCounts[publisher.label] += 1;
|
||||
if (publisher.label !== "pass") nominatedPublishers += 1;
|
||||
const raw = computePublisherAbuseRawScore({
|
||||
ownerKey: demoOwnerKey(publisher.index),
|
||||
handleSnapshot: demoHandle(publisher.index),
|
||||
publishedSkills: publisher.publishedSkills,
|
||||
totalInstalls: publisher.totalInstalls,
|
||||
totalStars: publisher.totalStars,
|
||||
totalDownloads: publisher.totalDownloads,
|
||||
});
|
||||
sumLogPressure += raw.logPressure;
|
||||
sumSquaredLogPressure += raw.logPressure ** 2;
|
||||
}
|
||||
|
||||
const meanLogPressure = sumLogPressure / SEED_PUBLISHERS.length;
|
||||
const variance = Math.max(
|
||||
0,
|
||||
sumSquaredLogPressure / SEED_PUBLISHERS.length - meanLogPressure ** 2,
|
||||
);
|
||||
const stdDevLogPressure = Math.sqrt(variance);
|
||||
|
||||
const runId = await ctx.db.insert("publisherAbuseScoreRuns", {
|
||||
modelVersion: PUBLISHER_ABUSE_MODEL_VERSION,
|
||||
modelConfig: DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG,
|
||||
trigger: "manual",
|
||||
status: "completed",
|
||||
phase: "completed",
|
||||
startedAt,
|
||||
completedAt,
|
||||
updatedAt: completedAt,
|
||||
scannedPublishers: SCANNED_PUBLISHERS,
|
||||
scoredPublishers: SCORED_PUBLISHERS,
|
||||
finalizedScores: SCORED_PUBLISHERS,
|
||||
nominatedPublishers,
|
||||
passCount: labelCounts.pass,
|
||||
reviewCount: labelCounts.review,
|
||||
potentialBanCandidateCount: labelCounts.potential_ban_candidate,
|
||||
sumLogPressure,
|
||||
sumSquaredLogPressure,
|
||||
meanLogPressure,
|
||||
stdDevLogPressure,
|
||||
});
|
||||
|
||||
let rank = 1;
|
||||
for (const publisher of SEED_PUBLISHERS) {
|
||||
const handle = demoHandle(publisher.index);
|
||||
const ownerKey = demoOwnerKey(publisher.index);
|
||||
const raw = computePublisherAbuseRawScore({
|
||||
ownerKey,
|
||||
handleSnapshot: handle,
|
||||
publishedSkills: publisher.publishedSkills,
|
||||
totalInstalls: publisher.totalInstalls,
|
||||
totalStars: publisher.totalStars,
|
||||
totalDownloads: publisher.totalDownloads,
|
||||
});
|
||||
|
||||
const lastScoredAt = completedAt;
|
||||
const openedAt = completedAt;
|
||||
const reviewed = publisher.status !== "pending";
|
||||
const reviewedAt = reviewed ? completedAt + publisher.index * 60_000 : undefined;
|
||||
const updatedAt = reviewedAt ?? completedAt;
|
||||
|
||||
const ownerUserId = publisher.linkUser
|
||||
? await ctx.db.insert("users", {
|
||||
handle,
|
||||
name: `Demo Abuse Publisher ${paddedIndex(publisher.index)}`,
|
||||
role: "user",
|
||||
createdAt: now - DAY_MS,
|
||||
updatedAt: now - DAY_MS,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const scoreId = await ctx.db.insert("publisherAbuseScores", {
|
||||
runId,
|
||||
ownerKey,
|
||||
ownerPublisherId: undefined,
|
||||
ownerUserId,
|
||||
handleSnapshot: handle,
|
||||
modelVersion: PUBLISHER_ABUSE_MODEL_VERSION,
|
||||
label: publisher.label,
|
||||
rank,
|
||||
pressure: raw.pressure,
|
||||
logPressure: raw.logPressure,
|
||||
zScore: publisher.zScore,
|
||||
publishedSkills: raw.publishedSkills,
|
||||
totalInstalls: raw.totalInstalls,
|
||||
totalStars: raw.totalStars,
|
||||
totalDownloads: raw.totalDownloads,
|
||||
installsPerSkill: raw.installsPerSkill,
|
||||
starsPerSkill: raw.starsPerSkill,
|
||||
downloadsPerSkill: raw.downloadsPerSkill,
|
||||
reasonCodes: publisher.reasonCodes,
|
||||
createdAt: now - DAY_MS,
|
||||
});
|
||||
rank += 1;
|
||||
|
||||
await ctx.db.insert("publisherAbuseReviewNominations", {
|
||||
ownerKey,
|
||||
ownerPublisherId: undefined,
|
||||
ownerUserId,
|
||||
handleSnapshot: handle,
|
||||
latestScoreId: scoreId,
|
||||
modelVersion: PUBLISHER_ABUSE_MODEL_VERSION,
|
||||
label: publisher.label,
|
||||
status: publisher.status,
|
||||
openedAt,
|
||||
openedByRunId: runId,
|
||||
lastScoredAt,
|
||||
reviewedByUserId: undefined,
|
||||
reviewedAt,
|
||||
notes: publisher.notes,
|
||||
updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
return { runId, inserted: SEED_PUBLISHERS.length };
|
||||
},
|
||||
});
|
||||
|
||||
export const clearSeed = internalMutation({
|
||||
args: {},
|
||||
handler: async (ctx): Promise<ClearSeedResult> => {
|
||||
assertLocalDevSeedAllowed("Publisher abuse");
|
||||
return await clearDemoRows(ctx);
|
||||
},
|
||||
});
|
||||
|
||||
async function clearDemoRows(ctx: ClearSeedCtx): Promise<ClearSeedResult> {
|
||||
let runs = 0;
|
||||
let scores = 0;
|
||||
let nominations = 0;
|
||||
let events = 0;
|
||||
let users = 0;
|
||||
let hasMore = false;
|
||||
|
||||
const demoRunIds = new Set<Id<"publisherAbuseScoreRuns">>();
|
||||
for (const ownerKey of DEMO_OWNER_KEYS) {
|
||||
const page = await queryDemoScoresPage(ctx, ownerKey);
|
||||
hasMore ||= !page.isDone;
|
||||
for (const score of page.page) {
|
||||
if (!isDemoOwnerKey(score.ownerKey) && !isDemoHandle(score.handleSnapshot)) continue;
|
||||
demoRunIds.add(score.runId);
|
||||
await ctx.db.delete(score._id);
|
||||
scores += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (const ownerKey of DEMO_OWNER_KEYS) {
|
||||
const page = await queryDemoNominationsPage(ctx, ownerKey);
|
||||
hasMore ||= !page.isDone;
|
||||
for (const nomination of page.page) {
|
||||
if (!isDemoOwnerKey(nomination.ownerKey) && !isDemoHandle(nomination.handleSnapshot)) {
|
||||
continue;
|
||||
}
|
||||
demoRunIds.add(nomination.openedByRunId);
|
||||
await ctx.db.delete(nomination._id);
|
||||
nominations += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (const ownerKey of DEMO_OWNER_KEYS) {
|
||||
const page = await queryDemoEventsPage(ctx, ownerKey);
|
||||
hasMore ||= !page.isDone;
|
||||
for (const event of page.page) {
|
||||
if (!isDemoOwnerKey(event.ownerKey)) continue;
|
||||
await ctx.db.delete(event._id);
|
||||
events += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (const runId of demoRunIds) {
|
||||
const run = await ctx.db.get(runId);
|
||||
if (!run) continue;
|
||||
await ctx.db.delete(runId);
|
||||
runs += 1;
|
||||
}
|
||||
|
||||
for (const handle of DEMO_HANDLES) {
|
||||
const page = await queryDemoUsersPage(ctx, handle);
|
||||
hasMore ||= !page.isDone;
|
||||
for (const user of page.page) {
|
||||
if (!user.handle || !isDemoHandle(user.handle)) continue;
|
||||
await ctx.db.delete(user._id);
|
||||
users += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { runs, scores, nominations, events, users, hasMore };
|
||||
}
|
||||
|
||||
async function queryDemoScoresPage(
|
||||
ctx: ClearSeedCtx,
|
||||
ownerKey: string,
|
||||
): Promise<{ page: Doc<"publisherAbuseScores">[]; isDone: boolean }> {
|
||||
const rows = await ctx.db
|
||||
.query("publisherAbuseScores")
|
||||
.withIndex("by_owner_key_and_created_at", (q) => q.eq("ownerKey", ownerKey))
|
||||
.take(CLEAR_SEED_BATCH_SIZE + 1);
|
||||
return {
|
||||
page: rows.slice(0, CLEAR_SEED_BATCH_SIZE),
|
||||
isDone: rows.length <= CLEAR_SEED_BATCH_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
async function queryDemoNominationsPage(
|
||||
ctx: ClearSeedCtx,
|
||||
ownerKey: string,
|
||||
): Promise<{ page: Doc<"publisherAbuseReviewNominations">[]; isDone: boolean }> {
|
||||
const rows = await ctx.db
|
||||
.query("publisherAbuseReviewNominations")
|
||||
.withIndex("by_owner_key_and_model_version", (q) => q.eq("ownerKey", ownerKey))
|
||||
.take(CLEAR_SEED_BATCH_SIZE + 1);
|
||||
return {
|
||||
page: rows.slice(0, CLEAR_SEED_BATCH_SIZE),
|
||||
isDone: rows.length <= CLEAR_SEED_BATCH_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
async function queryDemoEventsPage(
|
||||
ctx: ClearSeedCtx,
|
||||
ownerKey: string,
|
||||
): Promise<{ page: Doc<"publisherAbuseReviewEvents">[]; isDone: boolean }> {
|
||||
const rows = await ctx.db
|
||||
.query("publisherAbuseReviewEvents")
|
||||
.withIndex("by_owner_key_and_created_at", (q) => q.eq("ownerKey", ownerKey))
|
||||
.take(CLEAR_SEED_BATCH_SIZE + 1);
|
||||
return {
|
||||
page: rows.slice(0, CLEAR_SEED_BATCH_SIZE),
|
||||
isDone: rows.length <= CLEAR_SEED_BATCH_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
async function queryDemoUsersPage(
|
||||
ctx: ClearSeedCtx,
|
||||
handle: string,
|
||||
): Promise<{ page: Doc<"users">[]; isDone: boolean }> {
|
||||
const rows = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("handle", (q) => q.eq("handle", handle))
|
||||
.take(CLEAR_SEED_BATCH_SIZE + 1);
|
||||
return {
|
||||
page: rows.slice(0, CLEAR_SEED_BATCH_SIZE),
|
||||
isDone: rows.length <= CLEAR_SEED_BATCH_SIZE,
|
||||
};
|
||||
}
|
||||
+531
-23
@@ -7,6 +7,7 @@ import {
|
||||
listPublic,
|
||||
listMine,
|
||||
listPublishedPage,
|
||||
getPublishedDisplayManifest,
|
||||
migrateLegacyPublisherHandleToOrgInternal,
|
||||
ensureOrgPublisherHandleInternal,
|
||||
removeOrgPublisherMemberInternal,
|
||||
@@ -103,7 +104,12 @@ const listPublicHandler = (
|
||||
listPublic as unknown as WrappedHandler<
|
||||
{ limit?: number; kind?: "user" | "org" },
|
||||
{
|
||||
items: Array<{ handle: string; kind: "user" | "org"; stats: { downloads: number } }>;
|
||||
items: Array<{
|
||||
handle: string;
|
||||
kind: "user" | "org";
|
||||
stats: { downloads: number };
|
||||
publishedItems?: Array<{ displayName: string }>;
|
||||
}>;
|
||||
total: number;
|
||||
counts: { all: number; individuals: number; organizations: number };
|
||||
limit: number;
|
||||
@@ -147,6 +153,25 @@ const listPublishedPageHandler = (
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const getPublishedDisplayManifestHandler = (
|
||||
getPublishedDisplayManifest as unknown as WrappedHandler<
|
||||
{
|
||||
handle: string;
|
||||
kind?: "skill" | "plugin";
|
||||
sort?: "downloads" | "recent";
|
||||
},
|
||||
{
|
||||
mode: "grouped";
|
||||
sourceRepos: string[];
|
||||
sections: Array<{
|
||||
title: string;
|
||||
sourceRepo: string | null;
|
||||
items: Array<{ displayName: string }>;
|
||||
}>;
|
||||
} | null
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const updateProfileHandler = (
|
||||
updateProfile as unknown as WrappedHandler<{
|
||||
publisherId: string;
|
||||
@@ -214,7 +239,31 @@ const resolvePublishTargetForUserInternalHandler = (
|
||||
function indexedRows<T>(rows: T[]) {
|
||||
return {
|
||||
collect: vi.fn(async () => rows),
|
||||
order: vi.fn(() => ({ take: vi.fn(async (limit: number) => rows.slice(0, limit)) })),
|
||||
order: vi.fn(() => ({
|
||||
take: vi.fn(async (limit: number) => rows.slice(0, limit)),
|
||||
paginate: vi.fn(async ({ cursor, numItems }: { cursor: string | null; numItems: number }) => {
|
||||
const offset = cursor ? Number(cursor) : 0;
|
||||
const page = rows.slice(offset, offset + numItems);
|
||||
const nextOffset = offset + page.length;
|
||||
const isDone = nextOffset >= rows.length;
|
||||
return {
|
||||
page,
|
||||
isDone,
|
||||
continueCursor: isDone ? "" : String(nextOffset),
|
||||
};
|
||||
}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function emptyOfficialPublishersQuery() {
|
||||
return {
|
||||
withIndex: vi.fn((indexName: string) => {
|
||||
if (indexName !== "by_publisher") {
|
||||
throw new Error(`unexpected officialPublishers index ${indexName}`);
|
||||
}
|
||||
return { unique: vi.fn(async () => null) };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -485,6 +534,9 @@ describe("publishers membership controls", () => {
|
||||
packageRows.filter((pkg) => pkg.ownerPublisherId === fields.ownerPublisherId),
|
||||
);
|
||||
}
|
||||
if (table === "officialPublishers" && indexName === "by_publisher") {
|
||||
return { unique: vi.fn(async () => null) };
|
||||
}
|
||||
throw new Error(`unexpected ${table} index ${indexName}`);
|
||||
}),
|
||||
})),
|
||||
@@ -558,10 +610,13 @@ describe("publishers membership controls", () => {
|
||||
}
|
||||
if (
|
||||
(table === "skills" || table === "packages") &&
|
||||
indexName === "by_owner_publisher_active_downloads"
|
||||
indexName === "by_owner_publisher_active_installs"
|
||||
) {
|
||||
return indexedRows([]);
|
||||
}
|
||||
if (table === "officialPublishers" && indexName === "by_publisher") {
|
||||
return { unique: vi.fn(async () => null) };
|
||||
}
|
||||
throw new Error(`unexpected ${table} index ${indexName}`);
|
||||
}),
|
||||
})),
|
||||
@@ -575,6 +630,113 @@ describe("publishers membership controls", () => {
|
||||
expect(result.items.map((item) => item.handle)).toEqual(["openclaw"]);
|
||||
});
|
||||
|
||||
it("omits hidden publisher preview skills without scanning extra pages", async () => {
|
||||
const publisherRows = [
|
||||
{
|
||||
_id: "publishers:nvidia",
|
||||
_creationTime: 1,
|
||||
kind: "org",
|
||||
handle: "nvidia",
|
||||
displayName: "NVIDIA",
|
||||
publishedSkills: 1,
|
||||
publishedPackages: 0,
|
||||
totalInstalls: 0,
|
||||
totalDownloads: 70,
|
||||
totalStars: 0,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
];
|
||||
const skillRows = [
|
||||
...Array.from({ length: 3 }, (_, index) => 100 - index).map((installs, index) => ({
|
||||
_id: `skills:hidden-${index}`,
|
||||
ownerPublisherId: "publishers:nvidia",
|
||||
ownerUserId: "users:nvidia",
|
||||
slug: `hidden-${index}`,
|
||||
displayName: `Hidden ${index}`,
|
||||
summary: "Pending verification.",
|
||||
icon: null,
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "hidden",
|
||||
statsDownloads: 1000 - index,
|
||||
statsStars: 0,
|
||||
statsInstallsAllTime: installs,
|
||||
stats: {
|
||||
downloads: 1000 - index,
|
||||
stars: 0,
|
||||
installsCurrent: installs,
|
||||
installsAllTime: installs,
|
||||
},
|
||||
updatedAt: installs,
|
||||
})),
|
||||
{
|
||||
_id: "skills:visible",
|
||||
ownerPublisherId: "publishers:nvidia",
|
||||
ownerUserId: "users:nvidia",
|
||||
slug: "visible",
|
||||
displayName: "Visible Skill",
|
||||
summary: "Shown.",
|
||||
icon: null,
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
statsDownloads: 70,
|
||||
statsStars: 0,
|
||||
statsInstallsAllTime: 1,
|
||||
stats: { downloads: 70, stars: 0, installsCurrent: 1, installsAllTime: 1 },
|
||||
updatedAt: 70,
|
||||
},
|
||||
];
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async () => null),
|
||||
query: vi.fn((table: string) => ({
|
||||
withIndex: vi.fn((indexName: string, buildQuery: (q: unknown) => unknown) => {
|
||||
const fields: Record<string, unknown> = {};
|
||||
const q = {
|
||||
eq: (field: string, value: unknown) => {
|
||||
fields[field] = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
buildQuery(q);
|
||||
if (table === "publishers" && indexName === "by_handle") {
|
||||
return { unique: vi.fn(async () => null) };
|
||||
}
|
||||
if (table === "publishers" && indexName === "by_active_total_downloads") {
|
||||
return {
|
||||
order: vi.fn(() => ({ collect: vi.fn(async () => publisherRows) })),
|
||||
};
|
||||
}
|
||||
if (table === "officialPublishers" && indexName === "by_publisher") {
|
||||
return { unique: vi.fn(async () => null) };
|
||||
}
|
||||
if (table === "skills" && indexName === "by_owner_publisher_active_installs") {
|
||||
return {
|
||||
order: vi.fn(() => ({
|
||||
take: vi.fn(async (limit: number) =>
|
||||
skillRows
|
||||
.filter((skill) => skill.ownerPublisherId === fields.ownerPublisherId)
|
||||
.slice(0, limit),
|
||||
),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (table === "packages" && indexName === "by_owner_publisher_active_installs") {
|
||||
return {
|
||||
order: vi.fn(() => ({ take: vi.fn(async () => []) })),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected ${table} index ${indexName}`);
|
||||
}),
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await listPublicHandler(ctx as never, { limit: 48 });
|
||||
|
||||
expect(result.items[0]?.publishedItems).toEqual([]);
|
||||
});
|
||||
|
||||
it("pages public publishers by kind and query", async () => {
|
||||
const publisherRows = [
|
||||
{
|
||||
@@ -667,10 +829,13 @@ describe("publishers membership controls", () => {
|
||||
}
|
||||
if (
|
||||
(table === "skills" || table === "packages") &&
|
||||
indexName === "by_owner_publisher_active_downloads"
|
||||
indexName === "by_owner_publisher_active_installs"
|
||||
) {
|
||||
return indexedRows([]);
|
||||
}
|
||||
if (table === "officialPublishers" && indexName === "by_publisher") {
|
||||
return { unique: vi.fn(async () => null) };
|
||||
}
|
||||
throw new Error(`unexpected ${table} index ${indexName}`);
|
||||
}),
|
||||
})),
|
||||
@@ -688,7 +853,7 @@ describe("publishers membership controls", () => {
|
||||
expect(result.page.map((item) => item.handle)).toEqual(["alice"]);
|
||||
});
|
||||
|
||||
it("orders public publisher card previews by downloads", async () => {
|
||||
it("orders public publisher card previews by installs while rendering downloads", async () => {
|
||||
const publisherRows = [
|
||||
{
|
||||
_id: "publishers:openclaw",
|
||||
@@ -713,8 +878,8 @@ describe("publishers membership controls", () => {
|
||||
displayName: "Popular Skill",
|
||||
statsDownloads: 98,
|
||||
statsStars: 1,
|
||||
statsInstallsAllTime: 1,
|
||||
stats: { downloads: 98, stars: 1, installsCurrent: 1, installsAllTime: 1 },
|
||||
statsInstallsAllTime: 35,
|
||||
stats: { downloads: 98, stars: 1, installsCurrent: 35, installsAllTime: 35 },
|
||||
updatedAt: 1,
|
||||
},
|
||||
];
|
||||
@@ -725,7 +890,7 @@ describe("publishers membership controls", () => {
|
||||
softDeletedAt: undefined,
|
||||
family: "code-plugin",
|
||||
displayName: "Popular Plugin",
|
||||
stats: { downloads: 128, stars: 1, installs: 1, versions: 1 },
|
||||
stats: { downloads: 128, stars: 1, installs: 5, versions: 1 },
|
||||
updatedAt: 1,
|
||||
},
|
||||
{
|
||||
@@ -734,7 +899,7 @@ describe("publishers membership controls", () => {
|
||||
softDeletedAt: undefined,
|
||||
family: "code-plugin",
|
||||
displayName: "Recent Plugin",
|
||||
stats: { downloads: 12, stars: 1, installs: 1, versions: 1 },
|
||||
stats: { downloads: 12, stars: 1, installs: 50, versions: 1 },
|
||||
updatedAt: 5,
|
||||
},
|
||||
{
|
||||
@@ -743,7 +908,7 @@ describe("publishers membership controls", () => {
|
||||
softDeletedAt: undefined,
|
||||
family: "code-plugin",
|
||||
displayName: "Recent Helper",
|
||||
stats: { downloads: 11, stars: 1, installs: 1, versions: 1 },
|
||||
stats: { downloads: 11, stars: 1, installs: 20, versions: 1 },
|
||||
updatedAt: 4,
|
||||
},
|
||||
{
|
||||
@@ -752,19 +917,24 @@ describe("publishers membership controls", () => {
|
||||
softDeletedAt: undefined,
|
||||
family: "code-plugin",
|
||||
displayName: "Recent Tool",
|
||||
stats: { downloads: 10, stars: 1, installs: 1, versions: 1 },
|
||||
stats: { downloads: 10, stars: 1, installs: 40, versions: 1 },
|
||||
updatedAt: 3,
|
||||
},
|
||||
];
|
||||
const rowsByDownloads = <
|
||||
T extends { updatedAt: number; stats?: { downloads: number }; statsDownloads?: number },
|
||||
const rowsByInstalls = <
|
||||
T extends {
|
||||
updatedAt: number;
|
||||
stats?: { installs?: number; installsAllTime?: number };
|
||||
statsInstallsAllTime?: number;
|
||||
},
|
||||
>(
|
||||
rows: T[],
|
||||
) =>
|
||||
[...rows].sort(
|
||||
(a, b) =>
|
||||
(b.statsDownloads ?? b.stats?.downloads ?? 0) -
|
||||
(a.statsDownloads ?? a.stats?.downloads ?? 0) || b.updatedAt - a.updatedAt,
|
||||
(b.statsInstallsAllTime ?? b.stats?.installs ?? b.stats?.installsAllTime ?? 0) -
|
||||
(a.statsInstallsAllTime ?? a.stats?.installs ?? a.stats?.installsAllTime ?? 0) ||
|
||||
b.updatedAt - a.updatedAt,
|
||||
);
|
||||
const ctx = {
|
||||
db: {
|
||||
@@ -789,16 +959,19 @@ describe("publishers membership controls", () => {
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (table === "skills" && indexName === "by_owner_publisher_active_downloads") {
|
||||
if (table === "officialPublishers" && indexName === "by_publisher") {
|
||||
return { unique: vi.fn(async () => null) };
|
||||
}
|
||||
if (table === "skills" && indexName === "by_owner_publisher_active_installs") {
|
||||
return indexedRows(
|
||||
rowsByDownloads(
|
||||
rowsByInstalls(
|
||||
skillRows.filter((skill) => skill.ownerPublisherId === fields.ownerPublisherId),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (table === "packages" && indexName === "by_owner_publisher_active_downloads") {
|
||||
if (table === "packages" && indexName === "by_owner_publisher_active_installs") {
|
||||
return indexedRows(
|
||||
rowsByDownloads(
|
||||
rowsByInstalls(
|
||||
packageRows.filter((pkg) => pkg.ownerPublisherId === fields.ownerPublisherId),
|
||||
),
|
||||
);
|
||||
@@ -814,11 +987,12 @@ describe("publishers membership controls", () => {
|
||||
});
|
||||
|
||||
expect(result.page[0]?.publishedItems.map((item) => item.displayName)).toEqual([
|
||||
"Popular Plugin",
|
||||
"Popular Skill",
|
||||
"Recent Plugin",
|
||||
"Recent Tool",
|
||||
"Popular Skill",
|
||||
]);
|
||||
expect(result.page[0]?.publishedItems.map((item) => item.downloads)).toEqual([128, 98, 12]);
|
||||
expect(result.page[0]?.publishedItems.map((item) => item.downloads)).toEqual([12, 10, 98]);
|
||||
expect(result.page[0]?.publishedItems[0]).not.toHaveProperty("installs");
|
||||
});
|
||||
|
||||
it("does not hydrate every publisher before filtering public publisher pages", async () => {
|
||||
@@ -864,11 +1038,14 @@ describe("publishers membership controls", () => {
|
||||
}
|
||||
if (
|
||||
(table === "skills" || table === "packages") &&
|
||||
indexName === "by_owner_publisher_active_downloads"
|
||||
indexName === "by_owner_publisher_active_installs"
|
||||
) {
|
||||
ownerPublisherQueries.push(String(fields.ownerPublisherId));
|
||||
return indexedRows([]);
|
||||
}
|
||||
if (table === "officialPublishers" && indexName === "by_publisher") {
|
||||
return { unique: vi.fn(async () => null) };
|
||||
}
|
||||
throw new Error(`unexpected ${table} index ${indexName}`);
|
||||
}),
|
||||
})),
|
||||
@@ -982,6 +1159,9 @@ describe("publishers membership controls", () => {
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (table === "officialPublishers" && indexName === "by_publisher") {
|
||||
return { unique: vi.fn(async () => null) };
|
||||
}
|
||||
throw new Error(`unexpected ${table} index ${indexName}`);
|
||||
}),
|
||||
})),
|
||||
@@ -998,6 +1178,97 @@ describe("publishers membership controls", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("excludes hidden and removed skills from publisher catalogs", async () => {
|
||||
const publisher = {
|
||||
_id: "publishers:nvidia",
|
||||
_creationTime: 1,
|
||||
kind: "org",
|
||||
handle: "nvidia",
|
||||
displayName: "NVIDIA",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
const skillRows = [
|
||||
{
|
||||
_id: "skills:visible",
|
||||
ownerPublisherId: "publishers:nvidia",
|
||||
ownerUserId: "users:nvidia",
|
||||
slug: "visible",
|
||||
displayName: "Visible Skill",
|
||||
summary: "Shown.",
|
||||
icon: null,
|
||||
moderationStatus: "active",
|
||||
stats: { downloads: 3, stars: 1, installsCurrent: 0, installsAllTime: 0 },
|
||||
updatedAt: 5,
|
||||
},
|
||||
{
|
||||
_id: "skills:hidden",
|
||||
ownerPublisherId: "publishers:nvidia",
|
||||
ownerUserId: "users:nvidia",
|
||||
slug: "hidden",
|
||||
displayName: "Hidden Skill",
|
||||
summary: "Pending verification.",
|
||||
icon: null,
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "pending.scan",
|
||||
stats: { downloads: 10, stars: 1, installsCurrent: 0, installsAllTime: 0 },
|
||||
updatedAt: 6,
|
||||
},
|
||||
{
|
||||
_id: "skills:removed",
|
||||
ownerPublisherId: "publishers:nvidia",
|
||||
ownerUserId: "users:nvidia",
|
||||
slug: "removed",
|
||||
displayName: "Removed Skill",
|
||||
summary: "Removed upstream.",
|
||||
icon: null,
|
||||
moderationStatus: "removed",
|
||||
moderationReason: "github.upstream.removed",
|
||||
stats: { downloads: 9, stars: 1, installsCurrent: 0, installsAllTime: 0 },
|
||||
updatedAt: 7,
|
||||
},
|
||||
];
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => (id === "publishers:nvidia" ? publisher : null)),
|
||||
query: vi.fn((table: string) => ({
|
||||
withIndex: vi.fn((indexName: string, buildQuery: (q: unknown) => unknown) => {
|
||||
const fields: Record<string, unknown> = {};
|
||||
const q = {
|
||||
eq: (field: string, value: unknown) => {
|
||||
fields[field] = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
buildQuery(q);
|
||||
if (table === "publishers" && indexName === "by_handle") {
|
||||
return {
|
||||
unique: vi.fn(async () => (fields.handle === "nvidia" ? publisher : null)),
|
||||
};
|
||||
}
|
||||
if (table === "skills" && indexName === "by_owner_publisher_active_updated") {
|
||||
return indexedRows(skillRows);
|
||||
}
|
||||
if (table === "packages" && indexName === "by_owner_publisher_active_updated") {
|
||||
return indexedRows([]);
|
||||
}
|
||||
if (table === "officialPublishers" && indexName === "by_publisher") {
|
||||
return { unique: vi.fn(async () => null) };
|
||||
}
|
||||
throw new Error(`unexpected ${table} index ${indexName}`);
|
||||
}),
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await listPublishedPageHandler(ctx as never, {
|
||||
handle: "nvidia",
|
||||
paginationOpts: { cursor: null, numItems: 12 },
|
||||
});
|
||||
|
||||
expect(result.page.map((item) => item.displayName)).toEqual(["Visible Skill"]);
|
||||
});
|
||||
|
||||
it("includes skill.icon on catalog items and surfaces null for plugins (F7)", async () => {
|
||||
// Regression guard for F2: listPublishedPage must mirror `skills.icon`
|
||||
// onto the catalog DTO so the publisher profile page (/p/<handle>) can
|
||||
@@ -1083,6 +1354,9 @@ describe("publishers membership controls", () => {
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (table === "officialPublishers" && indexName === "by_publisher") {
|
||||
return { unique: vi.fn(async () => null) };
|
||||
}
|
||||
throw new Error(`unexpected ${table} index ${indexName}`);
|
||||
}),
|
||||
})),
|
||||
@@ -1110,6 +1384,219 @@ describe("publishers membership controls", () => {
|
||||
expect(byName["Example Plugin"]).toMatchObject({ kind: "plugin", icon: null });
|
||||
});
|
||||
|
||||
it("returns GitHub-backed display manifest groups for publisher catalogs", async () => {
|
||||
const publisher = {
|
||||
_id: "publishers:nvidia",
|
||||
_creationTime: 1,
|
||||
kind: "org",
|
||||
handle: "nvidia",
|
||||
displayName: "NVIDIA",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
const githubSource = {
|
||||
_id: "githubSkillSources:nvidia",
|
||||
repo: "NVIDIA/skills",
|
||||
ownerPublisherId: "publishers:nvidia",
|
||||
displayManifestStatus: "ok",
|
||||
displayManifest: {
|
||||
notGrouped: "bottom",
|
||||
groupings: [
|
||||
{
|
||||
title: "Agentic AI",
|
||||
description: "Agentic AI skills.",
|
||||
skills: ["aiq-deploy", "missing-entry"],
|
||||
},
|
||||
{
|
||||
title: "Vision AI",
|
||||
skills: ["vision-helper"],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const skillRows = [
|
||||
{
|
||||
_id: "skills:aiq-deploy",
|
||||
ownerPublisherId: "publishers:nvidia",
|
||||
softDeletedAt: undefined,
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
summary: "Deploy AgentIQ workflows.",
|
||||
icon: null,
|
||||
installKind: "github",
|
||||
githubSourceId: "githubSkillSources:nvidia",
|
||||
githubPath: "skills/aiq-deploy",
|
||||
stats: { downloads: 10, stars: 2, installsCurrent: 1, installsAllTime: 3 },
|
||||
updatedAt: 8,
|
||||
},
|
||||
{
|
||||
_id: "skills:vision-helper",
|
||||
ownerPublisherId: "publishers:nvidia",
|
||||
softDeletedAt: undefined,
|
||||
slug: "vision-helper",
|
||||
displayName: "Vision Helper",
|
||||
summary: "Vision tools.",
|
||||
icon: null,
|
||||
installKind: "github",
|
||||
githubSourceId: "githubSkillSources:nvidia",
|
||||
githubPath: "skills/vision-helper",
|
||||
stats: { downloads: 7, stars: 1, installsCurrent: 1, installsAllTime: 2 },
|
||||
updatedAt: 6,
|
||||
},
|
||||
{
|
||||
_id: "skills:other",
|
||||
ownerPublisherId: "publishers:nvidia",
|
||||
softDeletedAt: undefined,
|
||||
slug: "other",
|
||||
displayName: "Other Skill",
|
||||
summary: "Not listed in the manifest.",
|
||||
icon: null,
|
||||
installKind: "github",
|
||||
githubSourceId: "githubSkillSources:nvidia",
|
||||
githubPath: "skills/other",
|
||||
stats: { downloads: 1, stars: 0, installsCurrent: 0, installsAllTime: 0 },
|
||||
updatedAt: 2,
|
||||
},
|
||||
];
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => (id === "publishers:nvidia" ? publisher : null)),
|
||||
query: vi.fn((table: string) => ({
|
||||
withIndex: vi.fn((indexName: string, buildQuery: (q: unknown) => unknown) => {
|
||||
const fields: Record<string, unknown> = {};
|
||||
const q = {
|
||||
eq: (field: string, value: unknown) => {
|
||||
fields[field] = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
buildQuery(q);
|
||||
if (table === "publishers" && indexName === "by_handle") {
|
||||
return {
|
||||
unique: vi.fn(async () => (fields.handle === "nvidia" ? publisher : null)),
|
||||
};
|
||||
}
|
||||
if (table === "skills" && indexName === "by_owner_publisher_active_updated") {
|
||||
return indexedRows(skillRows);
|
||||
}
|
||||
if (table === "packages" && indexName === "by_owner_publisher_active_updated") {
|
||||
return indexedRows([]);
|
||||
}
|
||||
if (table === "githubSkillSources" && indexName === "by_owner_publisher") {
|
||||
return indexedRows([githubSource]);
|
||||
}
|
||||
if (table === "officialPublishers" && indexName === "by_publisher") {
|
||||
return { unique: vi.fn(async () => null) };
|
||||
}
|
||||
throw new Error(`unexpected ${table} index ${indexName}`);
|
||||
}),
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await getPublishedDisplayManifestHandler(ctx as never, {
|
||||
handle: "nvidia",
|
||||
kind: "skill",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
mode: "grouped",
|
||||
sourceRepos: ["NVIDIA/skills"],
|
||||
sections: [
|
||||
{
|
||||
title: "Agentic AI",
|
||||
sourceRepo: "NVIDIA/skills",
|
||||
items: [{ displayName: "AIQ Deploy", sourceBacked: true }],
|
||||
},
|
||||
{
|
||||
title: "Vision AI",
|
||||
sourceRepo: "NVIDIA/skills",
|
||||
items: [{ displayName: "Vision Helper", sourceBacked: true }],
|
||||
},
|
||||
{
|
||||
title: "Other skills",
|
||||
sourceRepo: null,
|
||||
items: [{ displayName: "Other Skill", sourceBacked: true }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the normal catalog when no valid display manifest exists", async () => {
|
||||
const publisher = {
|
||||
_id: "publishers:nvidia",
|
||||
_creationTime: 1,
|
||||
kind: "org",
|
||||
handle: "nvidia",
|
||||
displayName: "NVIDIA",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => (id === "publishers:nvidia" ? publisher : null)),
|
||||
query: vi.fn((table: string) => ({
|
||||
withIndex: vi.fn((indexName: string, buildQuery: (q: unknown) => unknown) => {
|
||||
const fields: Record<string, unknown> = {};
|
||||
const q = {
|
||||
eq: (field: string, value: unknown) => {
|
||||
fields[field] = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
buildQuery(q);
|
||||
if (table === "publishers" && indexName === "by_handle") {
|
||||
return {
|
||||
unique: vi.fn(async () => (fields.handle === "nvidia" ? publisher : null)),
|
||||
};
|
||||
}
|
||||
if (table === "skills" && indexName === "by_owner_publisher_active_updated") {
|
||||
return indexedRows([
|
||||
{
|
||||
_id: "skills:aiq-deploy",
|
||||
ownerPublisherId: "publishers:nvidia",
|
||||
softDeletedAt: undefined,
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
summary: "Deploy AgentIQ workflows.",
|
||||
icon: null,
|
||||
installKind: "github",
|
||||
githubSourceId: "githubSkillSources:nvidia",
|
||||
stats: { downloads: 10, stars: 2, installsCurrent: 1, installsAllTime: 3 },
|
||||
updatedAt: 8,
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (table === "packages" && indexName === "by_owner_publisher_active_updated") {
|
||||
return indexedRows([]);
|
||||
}
|
||||
if (table === "githubSkillSources" && indexName === "by_owner_publisher") {
|
||||
return indexedRows([
|
||||
{
|
||||
_id: "githubSkillSources:nvidia",
|
||||
repo: "NVIDIA/skills",
|
||||
ownerPublisherId: "publishers:nvidia",
|
||||
displayManifestStatus: "invalid",
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (table === "officialPublishers" && indexName === "by_publisher") {
|
||||
return { unique: vi.fn(async () => null) };
|
||||
}
|
||||
throw new Error(`unexpected ${table} index ${indexName}`);
|
||||
}),
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
getPublishedDisplayManifestHandler(ctx as never, {
|
||||
handle: "nvidia",
|
||||
kind: "skill",
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("prevents admins from promoting members to owner", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:admin" as never);
|
||||
const ctx = {
|
||||
@@ -1764,6 +2251,9 @@ describe("publishers membership controls", () => {
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (table === "officialPublishers") {
|
||||
return emptyOfficialPublishersQuery();
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
patch,
|
||||
@@ -2114,6 +2604,9 @@ describe("publisher bootstrap", () => {
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "officialPublishers") {
|
||||
return emptyOfficialPublishersQuery();
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
@@ -2158,6 +2651,9 @@ describe("publisher bootstrap", () => {
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "officialPublishers") {
|
||||
return emptyOfficialPublishersQuery();
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
@@ -2213,6 +2709,9 @@ describe("publisher bootstrap", () => {
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "officialPublishers") {
|
||||
return emptyOfficialPublishersQuery();
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
@@ -2348,6 +2847,9 @@ describe("publisher bootstrap", () => {
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "officialPublishers") {
|
||||
return emptyOfficialPublishersQuery();
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
@@ -2416,6 +2918,9 @@ describe("self-serve org publisher creation", () => {
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "officialPublishers") {
|
||||
return emptyOfficialPublishersQuery();
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
});
|
||||
const ctx = {
|
||||
@@ -2647,6 +3152,9 @@ describe("self-serve org publisher creation", () => {
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "officialPublishers") {
|
||||
return emptyOfficialPublishersQuery();
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
});
|
||||
const ctx = {
|
||||
|
||||
+285
-8
@@ -10,6 +10,12 @@ import {
|
||||
formatReservedPublicOwnerHandleMessage,
|
||||
isReservedPublicOwnerHandle,
|
||||
} from "./lib/publicRouteReservations";
|
||||
import {
|
||||
buildGitHubSkillCatalogDisplay,
|
||||
type GitHubSkillCatalogDisplay,
|
||||
type GitHubSkillCatalogItem,
|
||||
type GitHubSkillCatalogSource,
|
||||
} from "./lib/publisherCatalogDisplay";
|
||||
import {
|
||||
canAccessPublisherOwnerScope,
|
||||
ensurePersonalPublisherForUser,
|
||||
@@ -46,10 +52,14 @@ type PublisherPublishedItem = {
|
||||
displayName: string;
|
||||
downloads: number;
|
||||
};
|
||||
type PublisherPublishedPreviewItem = PublisherPublishedItem & {
|
||||
installs: number;
|
||||
};
|
||||
|
||||
type PublisherCatalogItem = {
|
||||
_id: Id<"skills"> | Id<"packages">;
|
||||
kind: "skill" | "plugin";
|
||||
slug?: string;
|
||||
displayName: string;
|
||||
summary: string | null;
|
||||
// Mirrors `skills.icon` for `kind: "skill"` items so the publisher
|
||||
@@ -62,6 +72,11 @@ type PublisherCatalogItem = {
|
||||
stars: number;
|
||||
isOfficial: boolean;
|
||||
updatedAt: number;
|
||||
sourceBacked?: boolean;
|
||||
sourceId?: Id<"githubSkillSources"> | null;
|
||||
sourceRepo?: string | null;
|
||||
sourcePath?: string | null;
|
||||
sourceVerifiedCommit?: string | null;
|
||||
};
|
||||
|
||||
type PublisherCatalogSort = "downloads" | "recent";
|
||||
@@ -81,6 +96,10 @@ type PublisherListSummary = {
|
||||
item: PublisherListItem;
|
||||
};
|
||||
|
||||
function isPublicPublishedSkill(skill: Doc<"skills">) {
|
||||
return !skill.softDeletedAt && (!skill.moderationStatus || skill.moderationStatus === "active");
|
||||
}
|
||||
|
||||
type PublicPublisherKindFilter = "user" | "org";
|
||||
type PublisherListCounts = {
|
||||
all: number;
|
||||
@@ -171,7 +190,7 @@ async function getPublisherPublishedRows(
|
||||
)
|
||||
.collect(),
|
||||
]);
|
||||
return { skills, packages };
|
||||
return { skills: skills.filter(isPublicPublishedSkill), packages };
|
||||
}
|
||||
|
||||
async function getPublisherPublishedPreviewRows(
|
||||
@@ -181,20 +200,20 @@ async function getPublisherPublishedPreviewRows(
|
||||
const [skills, packages] = await Promise.all([
|
||||
ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_owner_publisher_active_downloads", (q) =>
|
||||
.withIndex("by_owner_publisher_active_installs", (q) =>
|
||||
q.eq("ownerPublisherId", publisherId).eq("softDeletedAt", undefined),
|
||||
)
|
||||
.order("desc")
|
||||
.take(PUBLISHER_LIST_PREVIEW_LIMIT),
|
||||
ctx.db
|
||||
.query("packages")
|
||||
.withIndex("by_owner_publisher_active_downloads", (q) =>
|
||||
.withIndex("by_owner_publisher_active_installs", (q) =>
|
||||
q.eq("ownerPublisherId", publisherId).eq("softDeletedAt", undefined),
|
||||
)
|
||||
.order("desc")
|
||||
.take(PUBLISHER_LIST_PREVIEW_LIMIT),
|
||||
]);
|
||||
return { skills, packages };
|
||||
return { skills: skills.filter(isPublicPublishedSkill), packages };
|
||||
}
|
||||
|
||||
function getIndexedPublisherStatsFromRows(rows: PublisherPublishedRows): PublisherListStats {
|
||||
@@ -218,20 +237,33 @@ function getIndexedPublisherStatsFromRows(rows: PublisherPublishedRows): Publish
|
||||
}
|
||||
|
||||
function getPublisherPublishedItems(rows: PublisherPublishedRows): PublisherPublishedItem[] {
|
||||
return [
|
||||
const items: PublisherPublishedPreviewItem[] = [
|
||||
...rows.skills.map((skill) => ({
|
||||
kind: "skill" as const,
|
||||
displayName: skill.displayName,
|
||||
downloads: readCanonicalStat(skill, "downloads"),
|
||||
installs: readCanonicalStat(skill, "installsAllTime"),
|
||||
})),
|
||||
...rows.packages.map((pkg) => ({
|
||||
kind: pkg.family === "skill" ? ("skill" as const) : ("plugin" as const),
|
||||
displayName: pkg.displayName,
|
||||
downloads: pkg.stats.downloads,
|
||||
installs: pkg.stats.installs,
|
||||
})),
|
||||
]
|
||||
.sort((a, b) => b.downloads - a.downloads || a.displayName.localeCompare(b.displayName))
|
||||
.slice(0, 3);
|
||||
];
|
||||
return items
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.installs - a.installs ||
|
||||
b.downloads - a.downloads ||
|
||||
a.displayName.localeCompare(b.displayName),
|
||||
)
|
||||
.slice(0, 3)
|
||||
.map((item) => ({
|
||||
kind: item.kind,
|
||||
displayName: item.displayName,
|
||||
downloads: item.downloads,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildPluginDetailHref(name: string) {
|
||||
@@ -277,6 +309,7 @@ function getPublisherCatalogItems(
|
||||
...rows.skills.map((skill) => ({
|
||||
_id: skill._id,
|
||||
kind: "skill" as const,
|
||||
slug: skill.slug,
|
||||
displayName: skill.displayName,
|
||||
summary: skill.summary ?? null,
|
||||
icon: skill.icon ?? null,
|
||||
@@ -285,6 +318,10 @@ function getPublisherCatalogItems(
|
||||
stars: readCanonicalStat(skill, "stars"),
|
||||
isOfficial: publisherOfficial || Boolean(skill.badges?.official),
|
||||
updatedAt: skill.updatedAt,
|
||||
sourceBacked: skill.installKind === "github",
|
||||
sourceId: skill.githubSourceId ?? null,
|
||||
sourceRepo: null,
|
||||
sourcePath: skill.githubPath ?? null,
|
||||
})),
|
||||
...rows.packages.map((pkg) => ({
|
||||
_id: pkg._id,
|
||||
@@ -301,6 +338,40 @@ function getPublisherCatalogItems(
|
||||
].sort(comparePublisherCatalogItems(sort));
|
||||
}
|
||||
|
||||
function toGitHubSkillCatalogSource(source: Doc<"githubSkillSources">): GitHubSkillCatalogSource {
|
||||
return {
|
||||
_id: source._id,
|
||||
repo: source.repo,
|
||||
displayManifestStatus: source.displayManifestStatus,
|
||||
displayManifest: source.displayManifest,
|
||||
};
|
||||
}
|
||||
|
||||
function toGitHubSkillCatalogItem(
|
||||
item: PublisherCatalogItem,
|
||||
sourceById: Map<string, Doc<"githubSkillSources">>,
|
||||
): GitHubSkillCatalogItem {
|
||||
const sourceId = item.sourceId ? String(item.sourceId) : null;
|
||||
return {
|
||||
_id: String(item._id),
|
||||
kind: item.kind,
|
||||
slug: item.slug ?? null,
|
||||
displayName: item.displayName,
|
||||
summary: item.summary,
|
||||
icon: item.icon,
|
||||
href: item.href,
|
||||
downloads: item.downloads,
|
||||
stars: item.stars,
|
||||
isOfficial: item.isOfficial,
|
||||
updatedAt: item.updatedAt,
|
||||
sourceBacked: item.sourceBacked ?? false,
|
||||
sourceId,
|
||||
sourceRepo: sourceId ? (sourceById.get(sourceId)?.repo ?? null) : null,
|
||||
sourcePath: item.sourcePath ?? null,
|
||||
sourceVerifiedCommit: item.sourceVerifiedCommit ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function toPublisherListItem(
|
||||
ctx: Pick<QueryCtx, "db">,
|
||||
publisher: Doc<"publishers">,
|
||||
@@ -1175,6 +1246,44 @@ export const listPublishedPage = query({
|
||||
},
|
||||
});
|
||||
|
||||
export const getPublishedDisplayManifest = query({
|
||||
args: {
|
||||
handle: v.string(),
|
||||
kind: v.optional(v.union(v.literal("skill"), v.literal("plugin"))),
|
||||
sort: v.optional(v.union(v.literal("downloads"), v.literal("recent"))),
|
||||
},
|
||||
handler: async (ctx, args): Promise<GitHubSkillCatalogDisplay | null> => {
|
||||
if (args.kind === "plugin") return null;
|
||||
|
||||
const publisher = await getPublisherByHandle(ctx, args.handle);
|
||||
if (!publisher || publisher.deletedAt || publisher.deactivatedAt) return null;
|
||||
|
||||
const sources = await ctx.db
|
||||
.query("githubSkillSources")
|
||||
.withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", publisher._id))
|
||||
.collect();
|
||||
if (sources.length === 0) return null;
|
||||
|
||||
const rows = await getPublisherPublishedRows(ctx, publisher._id);
|
||||
if (!args.kind && rows.packages.length > 0) return null;
|
||||
|
||||
const sourceById = new Map(sources.map((source) => [String(source._id), source]));
|
||||
const items = getPublisherCatalogItems(
|
||||
publisher,
|
||||
rows,
|
||||
await isOfficialPublisher(ctx, publisher),
|
||||
args.sort ?? "downloads",
|
||||
)
|
||||
.filter((item) => !args.kind || item.kind === args.kind)
|
||||
.map((item) => toGitHubSkillCatalogItem(item, sourceById));
|
||||
|
||||
return buildGitHubSkillCatalogDisplay({
|
||||
sources: sources.map(toGitHubSkillCatalogSource),
|
||||
items,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const listPublic = query({
|
||||
args: {
|
||||
limit: v.optional(v.number()),
|
||||
@@ -1523,6 +1632,174 @@ export const removeOrgPublisherMemberInternal = internalMutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const listOfficialPublishersInternal = internalQuery({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const actor = await ctx.db.get(args.actorUserId);
|
||||
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
||||
assertAdmin(actor);
|
||||
|
||||
const rows = await ctx.db
|
||||
.query("officialPublishers")
|
||||
.withIndex("by_created", (q) => q)
|
||||
.order("asc")
|
||||
.collect();
|
||||
const items = await Promise.all(
|
||||
rows.map(async (row) => {
|
||||
const [publisher, createdBy] = await Promise.all([
|
||||
ctx.db.get(row.publisherId),
|
||||
row.createdByUserId ? ctx.db.get(row.createdByUserId) : Promise.resolve(null),
|
||||
]);
|
||||
return {
|
||||
officialPublisherId: row._id,
|
||||
publisherId: row.publisherId,
|
||||
handle: publisher?.handle ?? null,
|
||||
displayName: publisher?.displayName ?? null,
|
||||
kind: publisher?.kind ?? null,
|
||||
active: Boolean(publisher && !publisher.deletedAt && !publisher.deactivatedAt),
|
||||
reason: row.reason ?? null,
|
||||
createdByUserId: row.createdByUserId ?? null,
|
||||
createdByHandle: createdBy?.handle ?? null,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return { ok: true as const, items };
|
||||
},
|
||||
});
|
||||
|
||||
export const addOfficialPublisherInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
handle: v.string(),
|
||||
reason: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const actor = await ctx.db.get(args.actorUserId);
|
||||
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
||||
assertAdmin(actor);
|
||||
|
||||
const handle = normalizePublisherHandle(args.handle);
|
||||
if (!handle) throw new ConvexError("Publisher handle is required");
|
||||
const reason = args.reason.trim();
|
||||
if (!reason) throw new ConvexError("Reason is required");
|
||||
if (reason.length > 500) throw new ConvexError("Reason too long (max 500 chars)");
|
||||
|
||||
const publisher = await getPublisherByHandle(ctx, handle);
|
||||
if (!publisher || publisher.deletedAt || publisher.deactivatedAt) {
|
||||
throw new ConvexError(`Publisher "@${handle}" not found`);
|
||||
}
|
||||
if (publisher.kind !== "org") {
|
||||
throw new ConvexError("Only org publishers can be marked official");
|
||||
}
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("officialPublishers")
|
||||
.withIndex("by_publisher", (q) => q.eq("publisherId", publisher._id))
|
||||
.unique();
|
||||
if (existing) {
|
||||
return {
|
||||
ok: true as const,
|
||||
added: false,
|
||||
publisherId: publisher._id,
|
||||
handle: publisher.handle,
|
||||
officialPublisherId: existing._id,
|
||||
};
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const officialPublisherId = await ctx.db.insert("officialPublishers", {
|
||||
publisherId: publisher._id,
|
||||
reason,
|
||||
createdByUserId: args.actorUserId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: args.actorUserId,
|
||||
action: "publisher.official.add",
|
||||
targetType: "publisher",
|
||||
targetId: publisher._id,
|
||||
metadata: {
|
||||
handle: publisher.handle,
|
||||
reason,
|
||||
},
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
added: true,
|
||||
publisherId: publisher._id,
|
||||
handle: publisher.handle,
|
||||
officialPublisherId,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const removeOfficialPublisherInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
handle: v.string(),
|
||||
reason: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const actor = await ctx.db.get(args.actorUserId);
|
||||
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
||||
assertAdmin(actor);
|
||||
|
||||
const handle = normalizePublisherHandle(args.handle);
|
||||
if (!handle) throw new ConvexError("Publisher handle is required");
|
||||
const reason = args.reason.trim();
|
||||
if (!reason) throw new ConvexError("Reason is required");
|
||||
if (reason.length > 500) throw new ConvexError("Reason too long (max 500 chars)");
|
||||
|
||||
const publisher = await getPublisherByHandle(ctx, handle);
|
||||
if (!publisher || publisher.deletedAt || publisher.deactivatedAt) {
|
||||
throw new ConvexError(`Publisher "@${handle}" not found`);
|
||||
}
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("officialPublishers")
|
||||
.withIndex("by_publisher", (q) => q.eq("publisherId", publisher._id))
|
||||
.unique();
|
||||
if (!existing) {
|
||||
return {
|
||||
ok: true as const,
|
||||
removed: false,
|
||||
publisherId: publisher._id,
|
||||
handle: publisher.handle,
|
||||
};
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
await ctx.db.delete(existing._id);
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: args.actorUserId,
|
||||
action: "publisher.official.remove",
|
||||
targetType: "publisher",
|
||||
targetId: publisher._id,
|
||||
metadata: {
|
||||
handle: publisher.handle,
|
||||
reason,
|
||||
officialPublisherId: existing._id,
|
||||
},
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
removed: true,
|
||||
publisherId: publisher._id,
|
||||
handle: publisher.handle,
|
||||
officialPublisherId: existing._id,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const createOrgPublisherForUserInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
|
||||
@@ -246,6 +246,94 @@ const publisherMembers = defineTable({
|
||||
.index("by_user", ["userId"])
|
||||
.index("by_publisher_user", ["publisherId", "userId"]);
|
||||
|
||||
const officialPublishers = defineTable({
|
||||
publisherId: v.id("publishers"),
|
||||
reason: v.optional(v.string()),
|
||||
createdByUserId: v.optional(v.id("users")),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_publisher", ["publisherId"])
|
||||
.index("by_created", ["createdAt"]);
|
||||
|
||||
const displayManifestStatusValidator = v.union(
|
||||
v.literal("ok"),
|
||||
v.literal("missing"),
|
||||
v.literal("invalid"),
|
||||
v.literal("failed"),
|
||||
);
|
||||
|
||||
const displayManifestValidator = v.object({
|
||||
notGrouped: v.optional(v.union(v.literal("top"), v.literal("bottom"))),
|
||||
groupings: v.array(
|
||||
v.object({
|
||||
title: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
skills: v.array(v.string()),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const githubSkillSourceInvalidSkillValidator = v.object({
|
||||
slug: v.string(),
|
||||
path: v.string(),
|
||||
displayName: v.string(),
|
||||
error: v.string(),
|
||||
});
|
||||
|
||||
const githubSkillSourceIssueValidator = v.object({
|
||||
slug: v.string(),
|
||||
path: v.string(),
|
||||
displayName: v.string(),
|
||||
kind: v.union(v.literal("invalid_slug"), v.literal("slug_conflict")),
|
||||
severity: v.union(v.literal("error"), v.literal("warning")),
|
||||
message: v.string(),
|
||||
existingOwnerHandle: v.optional(v.string()),
|
||||
});
|
||||
|
||||
const githubSkillSources = defineTable({
|
||||
repo: v.string(),
|
||||
ownerPublisherId: v.optional(v.id("publishers")),
|
||||
defaultBranch: v.optional(v.string()),
|
||||
lastSyncStatus: v.optional(v.union(v.literal("ok"), v.literal("failed"), v.literal("skipped"))),
|
||||
lastSyncError: v.optional(v.string()),
|
||||
lastSyncErrorAt: v.optional(v.number()),
|
||||
displayManifestKind: v.optional(v.literal("skills.sh")),
|
||||
displayManifestHash: v.optional(v.string()),
|
||||
displayManifestCommit: v.optional(v.string()),
|
||||
displayManifestFetchedAt: v.optional(v.number()),
|
||||
displayManifestStatus: v.optional(displayManifestStatusValidator),
|
||||
displayManifest: v.optional(displayManifestValidator),
|
||||
lastSyncIssues: v.optional(v.array(githubSkillSourceIssueValidator)),
|
||||
// Deprecated. Use lastSyncIssues; kept optional for deployed rows and rollback safety.
|
||||
lastSyncInvalidSkills: v.optional(v.array(githubSkillSourceInvalidSkillValidator)),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_repo", ["repo"])
|
||||
.index("by_owner_publisher", ["ownerPublisherId"])
|
||||
.index("by_owner_publisher_and_repo", ["ownerPublisherId", "repo"])
|
||||
.index("by_created", ["createdAt"])
|
||||
.index("by_updated", ["updatedAt"]);
|
||||
|
||||
const githubSkillContents = defineTable({
|
||||
skillId: v.id("skills"),
|
||||
githubSourceId: v.id("githubSkillSources"),
|
||||
githubPath: v.string(),
|
||||
skillMarkdownPath: v.string(),
|
||||
skillMarkdown: v.string(),
|
||||
skillCardMarkdownPath: v.optional(v.string()),
|
||||
skillCardMarkdown: v.optional(v.string()),
|
||||
githubCommit: v.string(),
|
||||
githubContentHash: v.string(),
|
||||
fetchedAt: v.number(),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_skill", ["skillId"])
|
||||
.index("by_skill_and_content_hash", ["skillId", "githubContentHash"])
|
||||
.index("by_github_source", ["githubSourceId"]);
|
||||
|
||||
// Shared validator fragments used by both `skills` and `skillSearchDigest`.
|
||||
const forkOfValidator = v.optional(
|
||||
v.object({
|
||||
@@ -292,6 +380,20 @@ const moderationStatusValidator = v.optional(
|
||||
v.union(v.literal("active"), v.literal("hidden"), v.literal("removed")),
|
||||
);
|
||||
|
||||
const githubSkillScanStatusValidator = v.union(
|
||||
v.literal("clean"),
|
||||
v.literal("suspicious"),
|
||||
v.literal("malicious"),
|
||||
v.literal("pending"),
|
||||
v.literal("failed"),
|
||||
);
|
||||
|
||||
const githubSkillCurrentStatusValidator = v.union(
|
||||
v.literal("present"),
|
||||
v.literal("missing"),
|
||||
v.literal("unknown"),
|
||||
);
|
||||
|
||||
const packageFamilyValidator = v.union(
|
||||
v.literal("skill"),
|
||||
v.literal("code-plugin"),
|
||||
@@ -324,6 +426,7 @@ const publisherAbuseDryRunLabelValidator = v.union(
|
||||
|
||||
const publisherAbuseTriageStatusValidator = v.union(
|
||||
v.literal("pending"),
|
||||
v.literal("banned"),
|
||||
v.literal("reviewed_no_action"),
|
||||
v.literal("false_positive"),
|
||||
v.literal("needs_policy_discussion"),
|
||||
@@ -510,6 +613,16 @@ const skills = defineTable({
|
||||
ownerPublisherId: v.optional(v.id("publishers")),
|
||||
canonicalSkillId: v.optional(v.id("skills")),
|
||||
forkOf: forkOfValidator,
|
||||
installKind: v.optional(v.literal("github")),
|
||||
githubSourceId: v.optional(v.id("githubSkillSources")),
|
||||
githubPath: v.optional(v.string()),
|
||||
githubHasSkillCard: v.optional(v.boolean()),
|
||||
githubCurrentCommit: v.optional(v.string()),
|
||||
githubCurrentContentHash: v.optional(v.string()),
|
||||
githubCurrentStatus: v.optional(githubSkillCurrentStatusValidator),
|
||||
githubCurrentCheckedAt: v.optional(v.number()),
|
||||
githubScanStatus: v.optional(githubSkillScanStatusValidator),
|
||||
githubRemovedAt: v.optional(v.number()),
|
||||
latestVersionId: v.optional(v.id("skillVersions")),
|
||||
latestVersionSummary: v.optional(
|
||||
v.object({
|
||||
@@ -605,6 +718,12 @@ const skills = defineTable({
|
||||
"statsDownloads",
|
||||
"updatedAt",
|
||||
])
|
||||
.index("by_owner_publisher_active_installs", [
|
||||
"ownerPublisherId",
|
||||
"softDeletedAt",
|
||||
"statsInstallsAllTime",
|
||||
"updatedAt",
|
||||
])
|
||||
.index("by_updated", ["updatedAt"])
|
||||
.index("by_stats_downloads", ["statsDownloads", "updatedAt"])
|
||||
.index("by_stats_stars", ["statsStars", "updatedAt"])
|
||||
@@ -624,6 +743,7 @@ const skills = defineTable({
|
||||
.index("by_canonical", ["canonicalSkillId"])
|
||||
.index("by_fork_of", ["forkOf.skillId"])
|
||||
.index("by_moderation", ["moderationStatus", "moderationReason"])
|
||||
.index("by_github_source", ["githubSourceId"])
|
||||
.index("by_nonsuspicious_updated", ["softDeletedAt", "isSuspicious", "updatedAt"])
|
||||
.index("by_nonsuspicious_created", ["softDeletedAt", "isSuspicious", "createdAt"])
|
||||
.index("by_nonsuspicious_name", ["softDeletedAt", "isSuspicious", "displayName"])
|
||||
@@ -919,6 +1039,8 @@ const skillSearchDigest = defineTable({
|
||||
forkOf: forkOfValidator,
|
||||
latestVersionId: v.optional(v.id("skillVersions")),
|
||||
latestVersionSkillId: v.optional(v.id("skills")),
|
||||
installKind: v.optional(v.literal("github")),
|
||||
githubHasSkillCard: v.optional(v.boolean()),
|
||||
latestVersionSummary: v.optional(
|
||||
v.object({
|
||||
version: v.string(),
|
||||
@@ -1073,6 +1195,12 @@ const packages = defineTable({
|
||||
"stats.downloads",
|
||||
"updatedAt",
|
||||
])
|
||||
.index("by_owner_publisher_active_installs", [
|
||||
"ownerPublisherId",
|
||||
"softDeletedAt",
|
||||
"stats.installs",
|
||||
"updatedAt",
|
||||
])
|
||||
.index("by_family_updated", ["family", "updatedAt"])
|
||||
.index("by_family_channel_updated", ["family", "channel", "updatedAt"])
|
||||
.index("by_family_official_updated", ["family", "isOfficial", "updatedAt"])
|
||||
@@ -2005,6 +2133,7 @@ const publisherAbuseScores = defineTable({
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_run_and_rank", ["runId", "rank"])
|
||||
.index("by_run_and_label_and_rank", ["runId", "label", "rank"])
|
||||
.index("by_run_and_pressure", ["runId", "pressure"])
|
||||
.index("by_owner_key_and_created_at", ["ownerKey", "createdAt"])
|
||||
.index("by_owner_key_and_model_version", ["ownerKey", "modelVersion"])
|
||||
@@ -2029,6 +2158,8 @@ const publisherAbuseReviewNominations = defineTable({
|
||||
})
|
||||
.index("by_owner_key_and_model_version", ["ownerKey", "modelVersion"])
|
||||
.index("by_status_and_last_scored_at", ["status", "lastScoredAt"])
|
||||
.index("by_status_and_updated_at", ["status", "updatedAt"])
|
||||
.index("by_status_and_reviewed_at", ["status", "reviewedAt"])
|
||||
.index("by_status_and_label_and_last_scored_at", ["status", "label", "lastScoredAt"])
|
||||
.index("by_label_and_status_and_last_scored_at", ["label", "status", "lastScoredAt"])
|
||||
.index("by_last_scored_at", ["lastScoredAt"]);
|
||||
@@ -2140,6 +2271,26 @@ const downloadDedupes = defineTable({
|
||||
.index("by_skill_identity_hour", ["skillId", "identityHash", "hourStart"])
|
||||
.index("by_hour", ["hourStart"]);
|
||||
|
||||
const downloadMetricTargetKind = v.union(v.literal("skill"), v.literal("package"));
|
||||
const downloadMetricIdentityKind = v.union(v.literal("user"), v.literal("ip"));
|
||||
|
||||
const downloadMetricDedupes = defineTable({
|
||||
targetKind: downloadMetricTargetKind,
|
||||
targetId: v.string(),
|
||||
identityKind: downloadMetricIdentityKind,
|
||||
identityHash: v.string(),
|
||||
dayStart: v.number(),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_target_identity_day", [
|
||||
"targetKind",
|
||||
"targetId",
|
||||
"identityKind",
|
||||
"identityHash",
|
||||
"dayStart",
|
||||
])
|
||||
.index("by_day", ["dayStart"]);
|
||||
|
||||
const reservedSlugs = defineTable({
|
||||
slug: v.string(),
|
||||
originalOwnerUserId: v.id("users"),
|
||||
@@ -2238,6 +2389,9 @@ export default defineSchema({
|
||||
users,
|
||||
publishers,
|
||||
publisherMembers,
|
||||
officialPublishers,
|
||||
githubSkillSources,
|
||||
githubSkillContents,
|
||||
skills,
|
||||
skillSlugAliases,
|
||||
packages,
|
||||
@@ -2293,6 +2447,7 @@ export default defineSchema({
|
||||
rateLimits,
|
||||
rateLimitShards,
|
||||
downloadDedupes,
|
||||
downloadMetricDedupes,
|
||||
reservedSlugs,
|
||||
reservedHandles,
|
||||
githubBackupSyncState,
|
||||
|
||||
@@ -384,7 +384,7 @@ describe("skills anti-spam guards", () => {
|
||||
),
|
||||
).rejects.toThrow(
|
||||
"This slug is locked to a deleted or banned account. " +
|
||||
"If you believe you are the rightful owner, please contact security@openclaw.ai to reclaim it.",
|
||||
"If you believe you are the rightful owner, open a GitHub issue to reclaim it: https://github.com/openclaw/clawhub/issues/new.",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -947,7 +947,7 @@ describe("skills.checkSlugAvailability", () => {
|
||||
reason: "taken",
|
||||
message:
|
||||
"This slug is locked to a deleted or banned account. " +
|
||||
"If you believe you are the rightful owner, please contact security@openclaw.ai to reclaim it.",
|
||||
"If you believe you are the rightful owner, open a GitHub issue to reclaim it: https://github.com/openclaw/clawhub/issues/new.",
|
||||
url: null,
|
||||
});
|
||||
});
|
||||
@@ -985,7 +985,7 @@ describe("skills.checkSlugAvailability", () => {
|
||||
reason: "taken",
|
||||
message:
|
||||
"This slug is locked to a deleted or banned account. " +
|
||||
"If you believe you are the rightful owner, please contact security@openclaw.ai to reclaim it.",
|
||||
"If you believe you are the rightful owner, open a GitHub issue to reclaim it: https://github.com/openclaw/clawhub/issues/new.",
|
||||
url: null,
|
||||
});
|
||||
});
|
||||
|
||||
+91
-5
@@ -135,7 +135,7 @@ const MAX_OWNER_SUMMARY_LENGTH = 500;
|
||||
|
||||
export { publishVersionForUser } from "./lib/skillPublish";
|
||||
|
||||
type ReadmeResult = { path: string; text: string };
|
||||
type ReadmeResult = { path: string; text: string; sourceBaseUrl?: string };
|
||||
type FileTextResult = {
|
||||
path: string;
|
||||
text: string;
|
||||
@@ -648,14 +648,16 @@ const NONSUSPICIOUS_SORT_INDEXES = {
|
||||
const MAX_FILTERED_PUBLIC_LIST_SCAN_PAGES = 12;
|
||||
const MAX_FILTERED_PUBLIC_LIST_SCAN_ROWS = 500;
|
||||
|
||||
// Convex document IDs are opaque strings (e.g. "r97c0xws..."), not "table:id" —
|
||||
// so just confirm the schema-typed id is actually present before ctx.db.get.
|
||||
function isSkillVersionId(
|
||||
value: Id<"skillVersions"> | null | undefined,
|
||||
): value is Id<"skillVersions"> {
|
||||
return typeof value === "string" && value.startsWith("skillVersions:");
|
||||
return typeof value === "string" && value.length > 0;
|
||||
}
|
||||
|
||||
function isUserId(value: Id<"users"> | null | undefined): value is Id<"users"> {
|
||||
return typeof value === "string" && value.startsWith("users:");
|
||||
return typeof value === "string" && value.length > 0;
|
||||
}
|
||||
|
||||
type OwnerTrustSignals = {
|
||||
@@ -834,7 +836,7 @@ function buildSlugTakenErrorMessage(skill: Doc<"skills">, owner: SkillOwnerRef)
|
||||
if (!owner || owner.deletedAt || owner.deactivatedAt) {
|
||||
return (
|
||||
"This slug is locked to a deleted or banned account. " +
|
||||
"If you believe you are the rightful owner, please contact security@openclaw.ai to reclaim it."
|
||||
"If you believe you are the rightful owner, open a GitHub issue to reclaim it: https://github.com/openclaw/clawhub/issues/new."
|
||||
);
|
||||
}
|
||||
const base = "Slug is already taken. Choose a different slug.";
|
||||
@@ -2305,6 +2307,8 @@ export const getBySlug = query({
|
||||
|
||||
const forkOf = await loadPublicSkillReference(ctx, skill.forkOf?.skillId);
|
||||
const canonical = await loadPublicSkillReference(ctx, skill.canonicalSkillId);
|
||||
const githubSource = skill.githubSourceId ? await ctx.db.get(skill.githubSourceId) : null;
|
||||
const githubSourceRepo = githubSource?.repo;
|
||||
|
||||
const publicSkill = toPublicSkill({ ...skill, badges });
|
||||
|
||||
@@ -2331,9 +2335,14 @@ export const getBySlug = query({
|
||||
displayName: skill.displayName,
|
||||
summary: skill.summary,
|
||||
ownerUserId: skill.ownerUserId,
|
||||
ownerPublisherId: skill.ownerPublisherId,
|
||||
canonicalSkillId: skill.canonicalSkillId,
|
||||
forkOf: skill.forkOf,
|
||||
latestVersionId: skill.latestVersionId,
|
||||
installKind: skill.installKind,
|
||||
githubPath: skill.githubPath,
|
||||
githubCurrentCommit: skill.githubCurrentCommit,
|
||||
githubHasSkillCard: skill.githubHasSkillCard,
|
||||
tags: skill.tags,
|
||||
badges,
|
||||
stats: skill.stats,
|
||||
@@ -2344,6 +2353,7 @@ export const getBySlug = query({
|
||||
...skillData,
|
||||
canonicalSkillId: canonical ? skillData.canonicalSkillId : undefined,
|
||||
forkOf: forkOf ? skillData.forkOf : undefined,
|
||||
...(githubSourceRepo ? { githubSourceRepo } : {}),
|
||||
};
|
||||
|
||||
// Moderation info - visible to owners for all states, or anyone for flagged skills (transparency)
|
||||
@@ -2593,7 +2603,7 @@ export const checkSlugAvailability = query({
|
||||
reason: "taken" as const,
|
||||
message:
|
||||
"This slug is locked to a deleted or banned account. " +
|
||||
"If you believe you are the rightful owner, please contact security@openclaw.ai to reclaim it.",
|
||||
"If you believe you are the rightful owner, open a GitHub issue to reclaim it: https://github.com/openclaw/clawhub/issues/new.",
|
||||
url: null,
|
||||
};
|
||||
}
|
||||
@@ -8370,6 +8380,82 @@ async function canReadSkillVersionFiles(ctx: ActionCtx, version: Doc<"skillVersi
|
||||
return Boolean(toPublicSkill(skill));
|
||||
}
|
||||
|
||||
async function canReadGitHubSkillContent(ctx: QueryCtx, skill: Doc<"skills">) {
|
||||
const authUserId = await getOptionalActiveAuthUserId(ctx);
|
||||
if (authUserId) {
|
||||
if (isDirectSkillOwner(skill, authUserId) && !skill.softDeletedAt) return true;
|
||||
if (skill.ownerPublisherId && !skill.softDeletedAt) {
|
||||
const canAccessOwnerScope = await canAccessPublisherOwnerScope(ctx, {
|
||||
publisher: await ctx.db.get(skill.ownerPublisherId),
|
||||
userId: authUserId,
|
||||
legacyOwnerUserId: skill.ownerUserId,
|
||||
});
|
||||
if (canAccessOwnerScope) return true;
|
||||
}
|
||||
const actor = await ctx.db.get(authUserId);
|
||||
if (actor?.role === "admin" || actor?.role === "moderator") return true;
|
||||
}
|
||||
|
||||
if (skill.softDeletedAt) return false;
|
||||
return Boolean(toPublicSkill(skill));
|
||||
}
|
||||
|
||||
export const getGitHubSkillContent = query({
|
||||
args: {
|
||||
skillId: v.id("skills"),
|
||||
kind: v.union(v.literal("readme"), v.literal("skill-card")),
|
||||
},
|
||||
handler: async (ctx, args): Promise<ReadmeResult | null> => {
|
||||
const skill = await ctx.db.get(args.skillId);
|
||||
if (!skill || skill.installKind !== "github") return null;
|
||||
if (skill.githubCurrentStatus !== "present") return null;
|
||||
if (!(await canReadGitHubSkillContent(ctx, skill))) return null;
|
||||
|
||||
const content = await ctx.db
|
||||
.query("githubSkillContents")
|
||||
.withIndex("by_skill", (q) => q.eq("skillId", args.skillId))
|
||||
.unique();
|
||||
if (!content) return null;
|
||||
if (content.githubContentHash !== skill.githubCurrentContentHash) return null;
|
||||
|
||||
const source = await ctx.db.get(content.githubSourceId);
|
||||
const resultSource = source
|
||||
? buildGitHubMarkdownSourceBaseUrl(source.repo, content.githubCommit, content.githubPath)
|
||||
: undefined;
|
||||
|
||||
if (args.kind === "skill-card") {
|
||||
if (!content.skillCardMarkdown || !content.skillCardMarkdownPath) return null;
|
||||
return {
|
||||
path: content.skillCardMarkdownPath,
|
||||
text: content.skillCardMarkdown,
|
||||
...(resultSource ? { sourceBaseUrl: resultSource } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
path: content.skillMarkdownPath,
|
||||
text: content.skillMarkdown,
|
||||
...(resultSource ? { sourceBaseUrl: resultSource } : {}),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function buildGitHubMarkdownSourceBaseUrl(repo: string, commit: string, githubPath: string) {
|
||||
if (!repo || !commit) return undefined;
|
||||
const encodedRepo = repo
|
||||
.split("/")
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join("/");
|
||||
const normalizedPath = githubPath.replace(/^\/+|\/+$/g, "");
|
||||
const encodedPath = normalizedPath
|
||||
? `/${normalizedPath
|
||||
.split("/")
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join("/")}`
|
||||
: "";
|
||||
return `https://github.com/${encodedRepo}/blob/${encodeURIComponent(commit)}${encodedPath}`;
|
||||
}
|
||||
|
||||
export const getReadme: ReturnType<typeof action> = action({
|
||||
args: { versionId: v.id("skillVersions") },
|
||||
handler: async (ctx, args): Promise<ReadmeResult> => {
|
||||
|
||||
@@ -32,6 +32,7 @@ const {
|
||||
syncGitHubProfileInternal,
|
||||
updateProfile,
|
||||
deleteAccount,
|
||||
upsertDevPersonaInternal,
|
||||
} = await import("./users");
|
||||
|
||||
type WrappedHandler<TArgs, TResult> = {
|
||||
@@ -47,6 +48,12 @@ const updateProfileHandler = (
|
||||
const deleteAccountHandler = (
|
||||
deleteAccount as unknown as WrappedHandler<Record<string, never>, void>
|
||||
)._handler;
|
||||
const upsertDevPersonaInternalHandler = (
|
||||
upsertDevPersonaInternal as unknown as WrappedHandler<
|
||||
{ persona: "owner" | "user" | "admin" | "officialOrgMember" },
|
||||
unknown
|
||||
>
|
||||
)._handler;
|
||||
|
||||
function makeCtx() {
|
||||
const patch = vi.fn();
|
||||
@@ -131,6 +138,176 @@ function makeCtx() {
|
||||
};
|
||||
}
|
||||
|
||||
function makeDevPersonaCtx() {
|
||||
const users = new Map<string, Record<string, unknown>>();
|
||||
const publishers = new Map<string, Record<string, unknown>>();
|
||||
const publisherMembers: Array<Record<string, unknown>> = [];
|
||||
const officialPublishers: Array<Record<string, unknown>> = [];
|
||||
const inserts: Array<{ table: string; value: Record<string, unknown> }> = [];
|
||||
const patches: Array<{ id: string; value: Record<string, unknown> }> = [];
|
||||
|
||||
const insert = vi.fn(async (table: string, value: Record<string, unknown>) => {
|
||||
const id = `${table}:${inserts.length + 1}`;
|
||||
const row = { _id: id, _creationTime: 1, ...value };
|
||||
inserts.push({ table, value: row });
|
||||
if (table === "users") users.set(id, row);
|
||||
if (table === "publishers") publishers.set(id, row);
|
||||
if (table === "publisherMembers") publisherMembers.push(row);
|
||||
if (table === "officialPublishers") officialPublishers.push(row);
|
||||
return id;
|
||||
});
|
||||
|
||||
const get = vi.fn(async (...args: string[]) => {
|
||||
const id = args.length === 2 ? args[1] : args[0];
|
||||
return users.get(id) ?? publishers.get(id) ?? null;
|
||||
});
|
||||
|
||||
const patch = vi.fn(async (id: string, value: Record<string, unknown>) => {
|
||||
patches.push({ id, value });
|
||||
const current = users.get(id) ?? publishers.get(id);
|
||||
if (current) Object.assign(current, value);
|
||||
});
|
||||
|
||||
const query = vi.fn((table: string) => {
|
||||
if (table === "users") {
|
||||
return {
|
||||
withIndex: vi.fn((name: string, builder?: (q: unknown) => unknown) => {
|
||||
if (name !== "handle") throw new Error(`Unexpected users index ${name}`);
|
||||
let handle = "";
|
||||
const q = {
|
||||
eq: (field: string, value: string) => {
|
||||
if (field === "handle") handle = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
builder?.(q);
|
||||
return {
|
||||
unique: vi.fn(
|
||||
async () => [...users.values()].find((user) => user.handle === handle) ?? null,
|
||||
),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: vi.fn((name: string, builder?: (q: unknown) => unknown) => {
|
||||
let handle = "";
|
||||
let linkedUserId = "";
|
||||
const q = {
|
||||
eq: (field: string, value: string) => {
|
||||
if (field === "handle") handle = value;
|
||||
if (field === "linkedUserId") linkedUserId = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
builder?.(q);
|
||||
if (name === "by_handle") {
|
||||
return {
|
||||
unique: vi.fn(
|
||||
async () =>
|
||||
[...publishers.values()].find((publisher) => publisher.handle === handle) ?? null,
|
||||
),
|
||||
};
|
||||
}
|
||||
if (name === "by_linked_user") {
|
||||
return {
|
||||
unique: vi.fn(
|
||||
async () =>
|
||||
[...publishers.values()].find(
|
||||
(publisher) => publisher.linkedUserId === linkedUserId,
|
||||
) ?? null,
|
||||
),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected publishers index ${name}`);
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: vi.fn((name: string, builder?: (q: unknown) => unknown) => {
|
||||
if (name !== "by_publisher_user") {
|
||||
throw new Error(`Unexpected publisherMembers index ${name}`);
|
||||
}
|
||||
let publisherId = "";
|
||||
let userId = "";
|
||||
const q = {
|
||||
eq: (field: string, value: string) => {
|
||||
if (field === "publisherId") publisherId = value;
|
||||
if (field === "userId") userId = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
builder?.(q);
|
||||
return {
|
||||
unique: vi.fn(
|
||||
async () =>
|
||||
publisherMembers.find(
|
||||
(member) => member.publisherId === publisherId && member.userId === userId,
|
||||
) ?? null,
|
||||
),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "reservedHandles") {
|
||||
return {
|
||||
withIndex: vi.fn((name: string) => {
|
||||
if (name !== "by_handle_active_updatedAt") {
|
||||
throw new Error(`Unexpected reservedHandles index ${name}`);
|
||||
}
|
||||
return { order: () => ({ take: vi.fn(async () => []) }) };
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "officialPublishers") {
|
||||
return {
|
||||
withIndex: vi.fn((name: string, builder?: (q: unknown) => unknown) => {
|
||||
if (name !== "by_publisher")
|
||||
throw new Error(`Unexpected officialPublishers index ${name}`);
|
||||
let publisherId = "";
|
||||
const q = {
|
||||
eq: (field: string, value: string) => {
|
||||
if (field === "publisherId") publisherId = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
builder?.(q);
|
||||
return {
|
||||
unique: vi.fn(
|
||||
async () =>
|
||||
officialPublishers.find((entry) => entry.publisherId === publisherId) ?? null,
|
||||
),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "packages" || table === "skills") {
|
||||
return {
|
||||
withIndex: vi.fn((name: string) => {
|
||||
if (name !== "by_owner") throw new Error(`Unexpected ${table} index ${name}`);
|
||||
return {
|
||||
collect: vi.fn(async () => []),
|
||||
paginate: vi.fn(async () => ({
|
||||
page: [],
|
||||
continueCursor: null,
|
||||
isDone: true,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
});
|
||||
|
||||
return {
|
||||
ctx: { db: { patch, get, insert, query, normalizeId: vi.fn() } } as never,
|
||||
inserts,
|
||||
patches,
|
||||
};
|
||||
}
|
||||
|
||||
function makeListCtx(
|
||||
users: Array<Record<string, unknown>>,
|
||||
options?: {
|
||||
@@ -834,6 +1011,54 @@ describe("ensureHandler", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("users.upsertDevPersonaInternal", () => {
|
||||
it("seeds a non-platform-admin user who manages an official org", async () => {
|
||||
process.env.DEV_AUTH_ENABLED = "1";
|
||||
process.env.CONVEX_DEPLOYMENT = "local:dev";
|
||||
process.env.CONVEX_SITE_URL = "http://localhost:3210";
|
||||
const { ctx, inserts } = makeDevPersonaCtx();
|
||||
|
||||
const userId = await upsertDevPersonaInternalHandler(ctx, {
|
||||
persona: "officialOrgMember",
|
||||
});
|
||||
|
||||
expect(userId).toBe("users:1");
|
||||
expect(inserts).toContainEqual(
|
||||
expect.objectContaining({
|
||||
table: "users",
|
||||
value: expect.objectContaining({
|
||||
handle: "local-official-member",
|
||||
displayName: "Local Official Org Member",
|
||||
role: "user",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const orgInsert = inserts.find(
|
||||
(entry) => entry.table === "publishers" && entry.value.handle === "local-official-org",
|
||||
);
|
||||
expect(orgInsert).toBeTruthy();
|
||||
expect(inserts).toContainEqual(
|
||||
expect.objectContaining({
|
||||
table: "officialPublishers",
|
||||
value: expect.objectContaining({
|
||||
publisherId: orgInsert?.value._id,
|
||||
reason: "dev-persona.official-org-member",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(inserts).toContainEqual(
|
||||
expect.objectContaining({
|
||||
table: "publisherMembers",
|
||||
value: expect.objectContaining({
|
||||
publisherId: orgInsert?.value._id,
|
||||
userId,
|
||||
role: "admin",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("me", () => {
|
||||
afterEach(() => {
|
||||
vi.mocked(getAuthUserId).mockReset();
|
||||
|
||||
+80
-2
@@ -19,6 +19,7 @@ import {
|
||||
getActiveUserByHandleOrPersonalPublisher,
|
||||
getPersonalPublisherForUser,
|
||||
getPublisherByHandle,
|
||||
getPublisherMembership,
|
||||
getUserByHandleOrPersonalPublisher,
|
||||
} from "./lib/publishers";
|
||||
import {
|
||||
@@ -126,6 +127,17 @@ const DEV_PERSONAS = {
|
||||
displayName: "Local Admin",
|
||||
role: "admin",
|
||||
},
|
||||
officialOrgMember: {
|
||||
handle: "local-official-member",
|
||||
displayName: "Local Official Org Member",
|
||||
role: "user",
|
||||
},
|
||||
} as const;
|
||||
|
||||
const DEV_OFFICIAL_ORG = {
|
||||
handle: "local-official-org",
|
||||
displayName: "Local Official Org",
|
||||
reason: "dev-persona.official-org-member",
|
||||
} as const;
|
||||
|
||||
type DevPersona = keyof typeof DEV_PERSONAS;
|
||||
@@ -141,9 +153,19 @@ export const getByIdInternal = internalQuery({
|
||||
});
|
||||
|
||||
export const upsertDevPersonaInternal = internalMutation({
|
||||
args: { persona: v.union(v.literal("owner"), v.literal("user"), v.literal("admin")) },
|
||||
args: {
|
||||
persona: v.union(
|
||||
v.literal("owner"),
|
||||
v.literal("user"),
|
||||
v.literal("admin"),
|
||||
v.literal("officialOrgMember"),
|
||||
),
|
||||
devAuthSecret: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<Id<"users">> => {
|
||||
if (!isLocalDevAuthEnabled()) throw new Error("Dev auth is disabled");
|
||||
if (!isLocalDevAuthEnabled(process.env, args.devAuthSecret)) {
|
||||
throw new Error("Dev auth is disabled");
|
||||
}
|
||||
|
||||
const persona = DEV_PERSONAS[args.persona as DevPersona];
|
||||
const now = Date.now();
|
||||
@@ -175,10 +197,66 @@ export const upsertDevPersonaInternal = internalMutation({
|
||||
actorUserId: user._id,
|
||||
source: "dev_persona.upsert",
|
||||
});
|
||||
if (args.persona === "officialOrgMember") {
|
||||
await ensureDevOfficialOrgMembership(ctx, user, now);
|
||||
}
|
||||
return userId;
|
||||
},
|
||||
});
|
||||
|
||||
async function ensureDevOfficialOrgMembership(ctx: MutationCtx, user: Doc<"users">, now: number) {
|
||||
let publisher = await getPublisherByHandle(ctx, DEV_OFFICIAL_ORG.handle);
|
||||
let publisherId = publisher?._id;
|
||||
|
||||
if (!publisherId) {
|
||||
publisherId = await ctx.db.insert("publishers", {
|
||||
kind: "org",
|
||||
handle: DEV_OFFICIAL_ORG.handle,
|
||||
displayName: DEV_OFFICIAL_ORG.displayName,
|
||||
bio: undefined,
|
||||
image: undefined,
|
||||
linkedUserId: undefined,
|
||||
trustedPublisher: undefined,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
} else if (publisher?.deletedAt || publisher?.deactivatedAt) {
|
||||
await ctx.db.patch(publisherId, {
|
||||
displayName: DEV_OFFICIAL_ORG.displayName,
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
const existingOfficial = await ctx.db
|
||||
.query("officialPublishers")
|
||||
.withIndex("by_publisher", (q) => q.eq("publisherId", publisherId))
|
||||
.unique();
|
||||
if (!existingOfficial) {
|
||||
await ctx.db.insert("officialPublishers", {
|
||||
publisherId,
|
||||
reason: DEV_OFFICIAL_ORG.reason,
|
||||
createdByUserId: user._id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
const membership = await getPublisherMembership(ctx, publisherId, user._id);
|
||||
if (!membership) {
|
||||
await ctx.db.insert("publisherMembers", {
|
||||
publisherId,
|
||||
userId: user._id,
|
||||
role: "admin",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
} else if (membership.role === "publisher") {
|
||||
await ctx.db.patch(membership._id, { role: "admin", updatedAt: now });
|
||||
}
|
||||
}
|
||||
|
||||
export const getByHandleInternal = internalQuery({
|
||||
args: { handle: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
|
||||
@@ -16,10 +16,15 @@ vi.mock("./lib/soulPublish", () => ({
|
||||
}));
|
||||
|
||||
const { getAuthUserId } = await import("@convex-dev/auth/server");
|
||||
const { getReadme: getSkillReadme, getFileText: getSkillFileText } = await import("./skills");
|
||||
const {
|
||||
getReadme: getSkillReadme,
|
||||
getFileText: getSkillFileText,
|
||||
getGitHubSkillContent,
|
||||
} = await import("./skills");
|
||||
const { getReadme: getSoulReadme, getFileText: getSoulFileText } = await import("./souls");
|
||||
const getSkillReadmeHandler = getSkillReadme as unknown as { _handler: Function };
|
||||
const getSkillFileTextHandler = getSkillFileText as unknown as { _handler: Function };
|
||||
const getGitHubSkillContentHandler = getGitHubSkillContent as unknown as { _handler: Function };
|
||||
const getSoulReadmeHandler = getSoulReadme as unknown as { _handler: Function };
|
||||
const getSoulFileTextHandler = getSoulFileText as unknown as { _handler: Function };
|
||||
|
||||
@@ -294,6 +299,54 @@ describe("version file access actions", () => {
|
||||
).rejects.toThrow("Version not available");
|
||||
});
|
||||
|
||||
it("returns null instead of throwing for public reads from malware-blocked GitHub skill content", async () => {
|
||||
const skill = {
|
||||
_id: "skills:github",
|
||||
_creationTime: 1,
|
||||
slug: "github-demo",
|
||||
displayName: "GitHub Demo",
|
||||
summary: "Summary",
|
||||
ownerUserId: "users:owner",
|
||||
canonicalSkillId: undefined,
|
||||
forkOf: undefined,
|
||||
latestVersionId: undefined,
|
||||
installKind: "github",
|
||||
githubCurrentStatus: "present",
|
||||
githubCurrentContentHash: "hash-a",
|
||||
tags: {},
|
||||
badges: undefined,
|
||||
stats: {
|
||||
downloads: 1,
|
||||
installsCurrent: 1,
|
||||
installsAllTime: 1,
|
||||
stars: 1,
|
||||
versions: 0,
|
||||
comments: 0,
|
||||
},
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "hidden",
|
||||
moderationFlags: ["blocked.malware"],
|
||||
moderationReason: "scanner.vt.malicious",
|
||||
};
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => (id === "skills:github" ? skill : null)),
|
||||
query: vi.fn(() => {
|
||||
throw new Error("Content should not be read when the skill is not publicly readable");
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
getGitHubSkillContentHandler._handler(ctx, {
|
||||
skillId: "skills:github",
|
||||
kind: "readme",
|
||||
} as never),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("still allows public access to visible skill files", async () => {
|
||||
const ctx = makeActionCtx({
|
||||
version: makeSkillVersion(),
|
||||
|
||||
+3
-1
@@ -17,7 +17,8 @@ Use GitHub to sign in at [clawhub.ai](https://clawhub.ai).
|
||||
|
||||
Deleted, banned, or disabled accounts cannot complete normal ClawHub sign-in.
|
||||
If sign-in returns you to a logged-out state, your account may not be in good
|
||||
standing.
|
||||
standing. [Open a GitHub issue](https://github.com/openclaw/clawhub/issues/new)
|
||||
if you believe this is a mistake.
|
||||
|
||||
## CLI login
|
||||
|
||||
@@ -86,3 +87,4 @@ Revoked, invalid, or missing tokens return `401 Unauthorized`. Sign in again
|
||||
with `clawhub login` or provide a fresh token with `clawhub login --token`.
|
||||
|
||||
Deleted, banned, or disabled accounts cannot continue using existing API tokens.
|
||||
If you believe this is a mistake, [open a GitHub issue](https://github.com/openclaw/clawhub/issues/new).
|
||||
|
||||
+3
-2
@@ -79,8 +79,9 @@ result in account bans, token revocation, hidden content, or removed listings.
|
||||
|
||||
Deleted, banned, or disabled accounts cannot use ClawHub API tokens. If CLI auth
|
||||
starts failing after account action, sign in to the web UI to review account
|
||||
state. If sign-in or normal CLI access is blocked, contact security@openclaw.ai
|
||||
for recovery review.
|
||||
state. If sign-in or normal CLI access is blocked,
|
||||
[open a GitHub issue](https://github.com/openclaw/clawhub/issues/new) for
|
||||
recovery review.
|
||||
|
||||
## Publisher guidance
|
||||
|
||||
|
||||
+125
-2
@@ -1,7 +1,7 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { createServer, type IncomingMessage } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -10,9 +10,10 @@ import {
|
||||
ApiRoutes,
|
||||
ApiV1SearchResponseSchema,
|
||||
ApiV1WhoamiResponseSchema,
|
||||
LegacyApiRoutes,
|
||||
parseArk,
|
||||
} from "clawhub-schema";
|
||||
import { unzipSync } from "fflate";
|
||||
import { strToU8, unzipSync, zipSync } from "fflate";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readGlobalConfig } from "../packages/clawhub/src/config";
|
||||
import { hashSkillFiles } from "../packages/clawhub/src/skills";
|
||||
@@ -459,6 +460,128 @@ describe("clawhub e2e", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("installs a GitHub-backed skill through the install resolver and reports install telemetry", async () => {
|
||||
const commit = "b".repeat(40);
|
||||
const telemetryBodies: unknown[] = [];
|
||||
const requestLog: string[] = [];
|
||||
const githubZipBytes = zipSync({
|
||||
"skills-main/skills/aiq-deploy/SKILL.md": strToU8("# AIQ Deploy\n"),
|
||||
"skills-main/skills/aiq-deploy/skill-card.md": strToU8("# Card\n"),
|
||||
"skills-main/skills/other/SKILL.md": strToU8("# Other\n"),
|
||||
});
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
requestLog.push(`${req.method ?? "GET"} ${url.pathname}`);
|
||||
if (req.method === "GET" && url.pathname === `${ApiRoutes.skills}/aiq-deploy`) {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
skill: {
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
summary: "Deploy AgentIQ workflows.",
|
||||
tags: {},
|
||||
stats: {},
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
latestVersion: null,
|
||||
owner: null,
|
||||
moderation: null,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === `${ApiRoutes.skills}/aiq-deploy/install`) {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
slug: "aiq-deploy",
|
||||
installKind: "github",
|
||||
github: {
|
||||
repo: "NVIDIA/skills",
|
||||
path: "skills/aiq-deploy",
|
||||
commit,
|
||||
contentHash: "hash-aiq-deploy",
|
||||
sourceUrl: `https://github.com/NVIDIA/skills/tree/${commit}/skills/aiq-deploy`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === `/NVIDIA/skills/zip/${commit}`) {
|
||||
res.writeHead(200, { "Content-Type": "application/zip" });
|
||||
res.end(Buffer.from(githubZipBytes));
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === LegacyApiRoutes.cliTelemetryInstall) {
|
||||
telemetryBodies.push(JSON.parse(await readRequestBody(req)) as unknown);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("not found");
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
const registry = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
const cfg = await makeTempConfig(registry, "test-token");
|
||||
const workdir = await mkdtemp(join(tmpdir(), "clawhub-e2e-github-install-"));
|
||||
try {
|
||||
const result = await spawnCommand(
|
||||
"bun",
|
||||
[
|
||||
"clawhub",
|
||||
"install",
|
||||
"aiq-deploy",
|
||||
"--workdir",
|
||||
workdir,
|
||||
"--site",
|
||||
registry,
|
||||
"--registry",
|
||||
registry,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
CLAWHUB_CONFIG_PATH: cfg.path,
|
||||
CLAWHUB_DISABLE_TELEMETRY: "",
|
||||
CLAWDHUB_DISABLE_TELEMETRY: "",
|
||||
CLAWHUB_GITHUB_CODELOAD_BASE_URL: registry,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
await expect(
|
||||
readFile(join(workdir, "skills", "aiq-deploy", "SKILL.md"), "utf8"),
|
||||
).resolves.toContain("# AIQ Deploy");
|
||||
await expect(
|
||||
readFile(join(workdir, "skills", "aiq-deploy", "skill-card.md"), "utf8"),
|
||||
).resolves.toContain("# Card");
|
||||
await expect(
|
||||
readFile(join(workdir, "skills", "aiq-deploy", "other", "SKILL.md")),
|
||||
).rejects.toThrow();
|
||||
if (telemetryBodies.length !== 1) {
|
||||
throw new Error(`Expected one install telemetry request, saw: ${requestLog.join(", ")}`);
|
||||
}
|
||||
expect(telemetryBodies[0]).toMatchObject({
|
||||
roots: [
|
||||
{
|
||||
skills: [{ slug: "aiq-deploy", version: commit }],
|
||||
},
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
await rm(cfg.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("sync dry-run finds skills from clawdbot.json roots", async () => {
|
||||
const registry = getRegistry();
|
||||
const site = getSite();
|
||||
|
||||
@@ -5,6 +5,37 @@ import { waitForHydration } from "../helpers/runtimeErrors";
|
||||
|
||||
type DevPersona = "owner" | "user" | "admin";
|
||||
|
||||
// The quality gate fingerprints line shape, so vary local-auth fixtures by slug.
|
||||
const FINGERPRINT_SALT_LINES = [
|
||||
"Ready.",
|
||||
"Local publish path ready.",
|
||||
"The local publish path records browser state with enough detail for maintainers.",
|
||||
"- Upload.",
|
||||
"- Validate the local publish form.",
|
||||
"- Validate the local publish form after selecting owner, version, and generated files.",
|
||||
"1. Check final route.",
|
||||
"### Local browser release evidence and storage handoff notes",
|
||||
] as const;
|
||||
|
||||
function hashFixtureInput(value: string) {
|
||||
let hash = 0;
|
||||
for (const char of value) {
|
||||
hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
function fingerprintSaltBlock(args: { slug: string; versionLabel: string }) {
|
||||
const hash = hashFixtureInput(`${args.versionLabel}:${args.slug}:local-auth`);
|
||||
const lines: string[] = [];
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
const code = (hash >>> (index * 3)) & 7;
|
||||
lines.push(FINGERPRINT_SALT_LINES[code] ?? FINGERPRINT_SALT_LINES[0]);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function devPersonaHeaderPattern(persona: DevPersona, expectedHandle: string) {
|
||||
const displayName =
|
||||
persona === "owner" ? "Local Owner" : persona === "user" ? "Local User" : "Local Admin";
|
||||
@@ -39,6 +70,8 @@ The skill documents a realistic release process so the publish quality gate sees
|
||||
This ${args.versionLabel} payload is intentionally deterministic and text-only.
|
||||
It avoids external credentials, network access, binary files, and production state.
|
||||
Maintainers can run it against a disposable local Convex backend to prove the UI still supports the full version lifecycle.
|
||||
|
||||
${fingerprintSaltBlock(args)}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
import { expectHealthyPage, trackRuntimeErrors, waitForHydration } from "./helpers/runtimeErrors";
|
||||
|
||||
type SeedFixtures = {
|
||||
skill: {
|
||||
displayName: string;
|
||||
ownerHandle: string;
|
||||
slug: string;
|
||||
};
|
||||
plugin: {
|
||||
displayName: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
|
||||
type PublicRouteCase = {
|
||||
label: string;
|
||||
path: (fixtures: SeedFixtures) => string;
|
||||
assert: (page: Page, fixtures: SeedFixtures) => Promise<void>;
|
||||
};
|
||||
|
||||
function pluginDetailPath(name: string) {
|
||||
const scopedMatch = /^@([^/]+)\/([^/]+)$/.exec(name.trim());
|
||||
if (scopedMatch) {
|
||||
return `/plugins/@${encodeURIComponent(scopedMatch[1]!)}/${encodeURIComponent(scopedMatch[2]!)}`;
|
||||
}
|
||||
return `/plugins/${encodeURIComponent(name.trim())}`;
|
||||
}
|
||||
|
||||
function seedApiUrl(path: string) {
|
||||
const convexSiteUrl = process.env.VITE_CONVEX_SITE_URL?.trim();
|
||||
return convexSiteUrl ? new URL(path, convexSiteUrl).toString() : path;
|
||||
}
|
||||
|
||||
async function fetchSeedFixtures(request: APIRequestContext): Promise<SeedFixtures> {
|
||||
const skillPath = "/api/v1/skills/gifgrep";
|
||||
const skillResponse = await request.get(seedApiUrl(skillPath));
|
||||
expect(
|
||||
skillResponse.ok(),
|
||||
`seed skill fixture ${skillPath} returned ${skillResponse.status()}`,
|
||||
).toBe(true);
|
||||
const skillPayload = (await skillResponse.json()) as {
|
||||
owner?: { handle?: string | null };
|
||||
skill?: { displayName?: string | null; slug?: string | null };
|
||||
};
|
||||
const ownerHandle = skillPayload.owner?.handle?.trim();
|
||||
const skillSlug = skillPayload.skill?.slug?.trim();
|
||||
const skillDisplayName = skillPayload.skill?.displayName?.trim();
|
||||
expect(ownerHandle, "gifgrep seed fixture needs an owner handle").toBeTruthy();
|
||||
expect(skillSlug, "gifgrep seed fixture needs a slug").toBeTruthy();
|
||||
expect(skillDisplayName, "gifgrep seed fixture needs a display name").toBeTruthy();
|
||||
|
||||
const pluginPath = "/api/v1/plugins?limit=1";
|
||||
const pluginResponse = await request.get(seedApiUrl(pluginPath));
|
||||
expect(
|
||||
pluginResponse.ok(),
|
||||
`seed plugin catalog ${pluginPath} returned ${pluginResponse.status()}`,
|
||||
).toBe(true);
|
||||
const pluginPayload = (await pluginResponse.json()) as {
|
||||
items?: Array<{ displayName?: string | null; name?: string | null }>;
|
||||
};
|
||||
const plugin = pluginPayload.items?.find((item) => item.name?.trim() && item.displayName?.trim());
|
||||
expect(plugin, "seed plugin catalog needs at least one public plugin").toBeTruthy();
|
||||
|
||||
return {
|
||||
skill: {
|
||||
displayName: skillDisplayName!,
|
||||
ownerHandle: ownerHandle!,
|
||||
slug: skillSlug!,
|
||||
},
|
||||
plugin: {
|
||||
displayName: plugin!.displayName!.trim(),
|
||||
name: plugin!.name!.trim(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function publicRouteCases(): PublicRouteCase[] {
|
||||
return [
|
||||
{
|
||||
label: "home",
|
||||
path: () => "/",
|
||||
assert: async (page) => {
|
||||
await expect(page.locator("body")).toContainText("ClawHub");
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "skills browse",
|
||||
path: () => "/skills",
|
||||
assert: async (page) => {
|
||||
await expect(page.getByRole("heading", { name: /^Skills/ })).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "plugins browse",
|
||||
path: () => "/plugins",
|
||||
assert: async (page) => {
|
||||
await expect(page.getByRole("heading", { name: /^Plugins/ })).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "publishers browse",
|
||||
path: () => "/publishers",
|
||||
assert: async (page) => {
|
||||
await expect(page.getByRole("heading", { name: /^Publishers/ })).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "souls browse",
|
||||
path: () => "/souls",
|
||||
assert: async (page) => {
|
||||
await expect(page.getByRole("heading", { name: /SOUL\.md discovery/i })).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "search results",
|
||||
path: () => "/search?q=gifgrep",
|
||||
assert: async (page) => {
|
||||
await expect(
|
||||
page.getByRole("heading", { name: /Search results for "gifgrep"/ }),
|
||||
).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "skill detail",
|
||||
path: (fixtures) =>
|
||||
`/${encodeURIComponent(fixtures.skill.ownerHandle)}/${encodeURIComponent(fixtures.skill.slug)}`,
|
||||
assert: async (page, fixtures) => {
|
||||
await expect(
|
||||
page.getByRole("heading", { name: fixtures.skill.displayName }).first(),
|
||||
).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "skill security audit",
|
||||
path: (fixtures) =>
|
||||
`/${encodeURIComponent(fixtures.skill.ownerHandle)}/${encodeURIComponent(
|
||||
fixtures.skill.slug,
|
||||
)}/security-audit`,
|
||||
assert: async (page) => {
|
||||
await expect(page.getByText("Security Audit").first()).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "publisher profile",
|
||||
path: (fixtures) => `/user/${encodeURIComponent(fixtures.skill.ownerHandle)}`,
|
||||
assert: async (page) => {
|
||||
await expect(page.getByText("Publisher catalog")).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "plugin detail",
|
||||
path: (fixtures) => pluginDetailPath(fixtures.plugin.name),
|
||||
assert: async (page, fixtures) => {
|
||||
await expect(
|
||||
page.getByRole("heading", { name: fixtures.plugin.displayName }).first(),
|
||||
).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "plugin security audit",
|
||||
path: (fixtures) => `${pluginDetailPath(fixtures.plugin.name)}/security-audit`,
|
||||
assert: async (page) => {
|
||||
await expect(
|
||||
page.getByText(/Security Audit|Security audit is unavailable/i).first(),
|
||||
).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "signed-out skill publish",
|
||||
path: () => "/skills/publish",
|
||||
assert: async (page) => {
|
||||
await expect(page.getByText("Sign in to publish a skill")).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "signed-out plugin publish",
|
||||
path: () => "/plugins/publish",
|
||||
assert: async (page) => {
|
||||
await expect(page.getByText("Sign in to publish a plugin")).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "signed-out import",
|
||||
path: () => "/import",
|
||||
assert: async (page) => {
|
||||
await expect(page.getByText("Sign in to import and publish skills")).toBeVisible();
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function expectPublicRouteHealthy(
|
||||
page: Page,
|
||||
route: PublicRouteCase,
|
||||
fixtures: SeedFixtures,
|
||||
) {
|
||||
const errors = trackRuntimeErrors(page);
|
||||
const path = route.path(fixtures);
|
||||
const response = await page.goto(path, { waitUntil: "domcontentloaded" });
|
||||
expect(response, `${route.label} should return a response`).not.toBeNull();
|
||||
expect(response!.status(), `${route.label} should not return a 5xx response`).toBeLessThan(500);
|
||||
await expect(page.locator("body")).not.toContainText(/\bServer Error\b/i);
|
||||
await waitForHydration(page);
|
||||
await route.assert(page, fixtures);
|
||||
await expectHealthyPage(page, errors);
|
||||
}
|
||||
|
||||
for (const route of publicRouteCases()) {
|
||||
test(`public route renders: ${route.label}`, async ({ page, request }) => {
|
||||
const fixtures = await fetchSeedFixtures(request);
|
||||
await expectPublicRouteHealthy(page, route, fixtures);
|
||||
});
|
||||
}
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
"ci:e2e-http": "bun run test:e2e:prod-http && bunx vitest run -c vitest.e2e.config.ts e2e/clawhub.e2e.test.ts --testNamePattern \"prints CLI version|search endpoint returns a results array|cli search does not error|package publish --dry-run from a GitHub repo|package publish --dry-run --json|package publish help shows|skill verify help omits the redundant json flag\" && bunx vitest run -c vitest.e2e.config.ts e2e/permissions.e2e.test.ts",
|
||||
"ci:packages": "bun run --cwd packages/schema build && bun run --cwd packages/clawhub verify && bun run --cwd packages/clawhub-mod verify",
|
||||
"ci:playwright": "VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run build && VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run test:pw",
|
||||
"ci:playwright-smoke": "VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run build && VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run test:pw -- --project=chromium e2e/ci-smoke.pw.test.ts",
|
||||
"ci:playwright-smoke": "VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run build && VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run test:pw -- --project=chromium e2e/ci-smoke.pw.test.ts e2e/public-routes-smoke.pw.test.ts",
|
||||
"ci:pr": "bun run ci:static && bun run ci:unit && bun run ci:packages && bun run ci:types-build && bun run ci:e2e-http",
|
||||
"ci:static": "bun run check:peers && bun audit --ignore GHSA-rmmr-r34h-pfm5 && bun run format:check && bun run lint && bun run deadcode:ci",
|
||||
"ci:types-build": "bunx tsc --noEmit && bunx tsc -p packages/schema/tsconfig.json --noEmit && bunx tsc -p packages/clawhub/tsconfig.json --noEmit && bun run --cwd packages/clawhub-mod typecheck && VITE_CONVEX_URL=https://example.invalid bun run build",
|
||||
|
||||
@@ -32,7 +32,14 @@ import {
|
||||
cmdSetRole,
|
||||
cmdUnbanUser,
|
||||
} from "./commands/moderation.js";
|
||||
import { cmdCreateOrg, cmdRemoveOrgMember, cmdRepairScopedPackages } from "./commands/orgs.js";
|
||||
import {
|
||||
cmdAddOfficialOrg,
|
||||
cmdCreateOrg,
|
||||
cmdListOfficialOrgs,
|
||||
cmdRemoveOfficialOrg,
|
||||
cmdRemoveOrgMember,
|
||||
cmdRepairScopedPackages,
|
||||
} from "./commands/orgs.js";
|
||||
import {
|
||||
cmdBackfillPackageArtifacts,
|
||||
cmdDeletePackageTrustedPublisher,
|
||||
@@ -347,6 +354,45 @@ registerOrgCommands(org);
|
||||
registerSkillModerationCommands(skills);
|
||||
|
||||
function registerOrgCommands(command: Command) {
|
||||
const official = command
|
||||
.command("official")
|
||||
.description("Manage official org publishers")
|
||||
.showHelpAfterError()
|
||||
.showSuggestionAfterError();
|
||||
|
||||
official
|
||||
.command("list")
|
||||
.description("List official org publishers")
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdListOfficialOrgs(opts, options);
|
||||
});
|
||||
|
||||
official
|
||||
.command("add")
|
||||
.description("Mark an org publisher as official")
|
||||
.argument("<handle>", "Org publisher handle")
|
||||
.requiredOption("--reason <reason>", "Audit reason")
|
||||
.option("--yes", "Skip confirmation")
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (handle, options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdAddOfficialOrg(opts, handle, options, isInputAllowed());
|
||||
});
|
||||
|
||||
official
|
||||
.command("remove")
|
||||
.description("Remove an org publisher from the official list")
|
||||
.argument("<handle>", "Org publisher handle")
|
||||
.requiredOption("--reason <reason>", "Audit reason")
|
||||
.option("--yes", "Skip confirmation")
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (handle, options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdRemoveOfficialOrg(opts, handle, options, isInputAllowed());
|
||||
});
|
||||
|
||||
command
|
||||
.command("create")
|
||||
.description("Create or update an org publisher")
|
||||
|
||||
@@ -22,7 +22,14 @@ vi.mock("../../../clawhub/src/cli/registry.js", () => registryMocks.moduleFactor
|
||||
vi.mock("../../../clawhub/src/http.js", () => httpMocks.moduleFactory());
|
||||
vi.mock("../../../clawhub/src/cli/ui.js", () => uiMocks.moduleFactory());
|
||||
|
||||
const { cmdCreateOrg, cmdRemoveOrgMember, cmdRepairScopedPackages } = await import("./orgs");
|
||||
const {
|
||||
cmdAddOfficialOrg,
|
||||
cmdCreateOrg,
|
||||
cmdListOfficialOrgs,
|
||||
cmdRemoveOfficialOrg,
|
||||
cmdRemoveOrgMember,
|
||||
cmdRepairScopedPackages,
|
||||
} = await import("./orgs");
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -169,6 +176,121 @@ describe("cmdRemoveOrgMember", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("official org commands", () => {
|
||||
it("lists official org publishers", async () => {
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
items: [
|
||||
{
|
||||
officialPublisherId: "officialPublishers:1",
|
||||
publisherId: "publishers:openclaw",
|
||||
handle: "openclaw",
|
||||
displayName: "OpenClaw",
|
||||
kind: "org",
|
||||
active: true,
|
||||
reason: "platform-owned publisher",
|
||||
createdByUserId: "users:admin",
|
||||
createdByHandle: "patrick-erichsen-2",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await cmdListOfficialOrgs(makeGlobalOpts(), { json: true });
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(authTokenMocks.requireAuthToken).toHaveBeenCalled();
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
"https://clawhub.ai",
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
path: "/api/v1/users/publisher-official",
|
||||
token: "tkn",
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires --yes to add an official org when input is disabled", async () => {
|
||||
await expect(
|
||||
cmdAddOfficialOrg(
|
||||
makeGlobalOpts(),
|
||||
"nvidia",
|
||||
{ reason: "NVIDIA source-backed catalog" },
|
||||
false,
|
||||
),
|
||||
).rejects.toThrow(/--yes/i);
|
||||
expect(httpMocks.apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks an org publisher official", async () => {
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
publisherId: "publishers:nvidia",
|
||||
handle: "nvidia",
|
||||
added: true,
|
||||
officialPublisherId: "officialPublishers:nvidia",
|
||||
});
|
||||
|
||||
const result = await cmdAddOfficialOrg(
|
||||
makeGlobalOpts(),
|
||||
"@NVIDIA",
|
||||
{ reason: "NVIDIA source-backed catalog", yes: true, json: true },
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ ok: true, handle: "nvidia", added: true });
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
"https://clawhub.ai",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
path: "/api/v1/users/publisher-official",
|
||||
token: "tkn",
|
||||
body: {
|
||||
action: "add",
|
||||
handle: "nvidia",
|
||||
reason: "NVIDIA source-backed catalog",
|
||||
},
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes an org publisher from the official list", async () => {
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
publisherId: "publishers:nvidia",
|
||||
handle: "nvidia",
|
||||
removed: true,
|
||||
officialPublisherId: "officialPublishers:nvidia",
|
||||
});
|
||||
|
||||
const result = await cmdRemoveOfficialOrg(
|
||||
makeGlobalOpts(),
|
||||
"nvidia",
|
||||
{ reason: "requested by publisher", yes: true, json: true },
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ ok: true, handle: "nvidia", removed: true });
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
"https://clawhub.ai",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
path: "/api/v1/users/publisher-official",
|
||||
token: "tkn",
|
||||
body: {
|
||||
action: "remove",
|
||||
handle: "nvidia",
|
||||
reason: "requested by publisher",
|
||||
},
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdRepairScopedPackages", () => {
|
||||
it("plans scoped package repairs from CSV without touching the API by default", async () => {
|
||||
const csv = await withCsv(
|
||||
|
||||
@@ -2,10 +2,18 @@ import { readFile, writeFile } from "node:fs/promises";
|
||||
import { requireAuthToken } from "../../../clawhub/src/cli/authToken.js";
|
||||
import { getRegistry } from "../../../clawhub/src/cli/registry.js";
|
||||
import type { GlobalOpts } from "../../../clawhub/src/cli/types.js";
|
||||
import { createSpinner, fail, formatError } from "../../../clawhub/src/cli/ui.js";
|
||||
import {
|
||||
createSpinner,
|
||||
fail,
|
||||
formatError,
|
||||
isInteractive,
|
||||
promptConfirm,
|
||||
} from "../../../clawhub/src/cli/ui.js";
|
||||
import { apiRequest } from "../../../clawhub/src/http.js";
|
||||
import type { ApiV1PackageRepairNameResponse } from "../../../clawhub/src/schema/index.js";
|
||||
import {
|
||||
ApiV1OfficialPublisherListResponseSchema,
|
||||
ApiV1OfficialPublisherUpdateResponseSchema,
|
||||
ApiV1PackageRepairNameResponseSchema,
|
||||
ApiRoutes,
|
||||
ApiV1PublisherEnsureResponseSchema,
|
||||
@@ -26,6 +34,16 @@ type OrgRemoveMemberOptions = {
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type OrgOfficialListOptions = {
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type OrgOfficialWriteOptions = {
|
||||
reason?: string;
|
||||
yes?: boolean;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type ScopedPackageRepairOptions = {
|
||||
apply?: boolean;
|
||||
json?: boolean;
|
||||
@@ -162,6 +180,143 @@ export async function cmdRemoveOrgMember(
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdListOfficialOrgs(opts: GlobalOpts, options: OrgOfficialListOptions = {}) {
|
||||
const token = await requireAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
const spinner = options.json ? null : createSpinner("Listing official org publishers");
|
||||
try {
|
||||
const result = await apiRequest(
|
||||
registry,
|
||||
{
|
||||
method: "GET",
|
||||
path: `${ApiRoutes.users}/publisher-official`,
|
||||
token,
|
||||
},
|
||||
ApiV1OfficialPublisherListResponseSchema,
|
||||
);
|
||||
|
||||
spinner?.stop();
|
||||
if (options.json) {
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
return result;
|
||||
}
|
||||
|
||||
const items = result.items.filter((item) => item.kind === "org" && item.active);
|
||||
if (items.length === 0) {
|
||||
console.log("No official org publishers.");
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const handle = item.handle ? `@${item.handle}` : item.publisherId;
|
||||
const displayName =
|
||||
item.displayName && item.displayName !== item.handle ? item.displayName : "";
|
||||
const reason = item.reason ? ` - ${item.reason}` : "";
|
||||
console.log([handle, displayName].filter(Boolean).join(" ") + reason);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
spinner?.fail(formatError(error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdAddOfficialOrg(
|
||||
opts: GlobalOpts,
|
||||
handle: string,
|
||||
options: OrgOfficialWriteOptions = {},
|
||||
inputAllowed: boolean,
|
||||
) {
|
||||
const orgHandle = normalizeHandleOrFail(handle, "Org handle");
|
||||
const reason = normalizeReasonOrFail(options.reason);
|
||||
await confirmOfficialOrgUpdate(
|
||||
`Mark @${orgHandle} official? (admin only; affects official badge and GitHub sync eligibility)`,
|
||||
options,
|
||||
inputAllowed,
|
||||
);
|
||||
|
||||
const token = await requireAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
const spinner = options.json ? null : createSpinner(`Marking @${orgHandle} official`);
|
||||
try {
|
||||
const result = await apiRequest(
|
||||
registry,
|
||||
{
|
||||
method: "POST",
|
||||
path: `${ApiRoutes.users}/publisher-official`,
|
||||
token,
|
||||
body: {
|
||||
action: "add",
|
||||
handle: orgHandle,
|
||||
reason,
|
||||
},
|
||||
},
|
||||
ApiV1OfficialPublisherUpdateResponseSchema,
|
||||
);
|
||||
|
||||
spinner?.succeed(
|
||||
result.added ? `Marked @${result.handle} official` : `@${result.handle} is already official`,
|
||||
);
|
||||
if (options.json) {
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
spinner?.fail(formatError(error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdRemoveOfficialOrg(
|
||||
opts: GlobalOpts,
|
||||
handle: string,
|
||||
options: OrgOfficialWriteOptions = {},
|
||||
inputAllowed: boolean,
|
||||
) {
|
||||
const orgHandle = normalizeHandleOrFail(handle, "Org handle");
|
||||
const reason = normalizeReasonOrFail(options.reason);
|
||||
await confirmOfficialOrgUpdate(
|
||||
`Remove @${orgHandle} from official org publishers? (admin only)`,
|
||||
options,
|
||||
inputAllowed,
|
||||
);
|
||||
|
||||
const token = await requireAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
const spinner = options.json
|
||||
? null
|
||||
: createSpinner(`Removing @${orgHandle} from official org publishers`);
|
||||
try {
|
||||
const result = await apiRequest(
|
||||
registry,
|
||||
{
|
||||
method: "POST",
|
||||
path: `${ApiRoutes.users}/publisher-official`,
|
||||
token,
|
||||
body: {
|
||||
action: "remove",
|
||||
handle: orgHandle,
|
||||
reason,
|
||||
},
|
||||
},
|
||||
ApiV1OfficialPublisherUpdateResponseSchema,
|
||||
);
|
||||
|
||||
spinner?.succeed(
|
||||
result.removed
|
||||
? `Removed @${result.handle} from official org publishers`
|
||||
: `@${result.handle} was not official`,
|
||||
);
|
||||
if (options.json) {
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
spinner?.fail(formatError(error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdRepairScopedPackages(
|
||||
opts: GlobalOpts,
|
||||
csvPath: string,
|
||||
@@ -287,6 +442,24 @@ function summarizeScopedPackageRepairs(
|
||||
return { ok: failed === 0, dryRun, total, planned, applied, failed, items };
|
||||
}
|
||||
|
||||
function normalizeReasonOrFail(rawReason: string | undefined) {
|
||||
const reason = rawReason?.trim();
|
||||
if (!reason) fail("--reason required");
|
||||
if (reason.length > 500) fail("--reason must be 500 characters or fewer");
|
||||
return reason;
|
||||
}
|
||||
|
||||
async function confirmOfficialOrgUpdate(
|
||||
prompt: string,
|
||||
options: OrgOfficialWriteOptions,
|
||||
inputAllowed: boolean,
|
||||
) {
|
||||
if (options.yes) return;
|
||||
if (!isInteractive() || inputAllowed === false) fail("Pass --yes (no input)");
|
||||
const confirmed = await promptConfirm(prompt);
|
||||
if (!confirmed) fail("Canceled");
|
||||
}
|
||||
|
||||
function parseScopedPackageRepairCsv(content: string): ScopedPackageRepairRow[] {
|
||||
const records = parseCsvRecords(content).filter((record) =>
|
||||
record.some((cell) => cell.trim().length > 0),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawhub",
|
||||
"version": "0.19.0",
|
||||
"version": "0.19.1",
|
||||
"description": "ClawHub CLI \\u2014 install, update, search, and publish skills plus OpenClaw packages.",
|
||||
"homepage": "https://clawhub.ai",
|
||||
"bugs": {
|
||||
|
||||
@@ -271,9 +271,10 @@ registerCommand(program, ["install"])
|
||||
.argument("<slug>", "Skill slug")
|
||||
.option("--version <version>", "Version to install")
|
||||
.option("--force", "Overwrite existing folder")
|
||||
.option("--force-install", "Install a pending GitHub-backed skill before ClawHub scan completes")
|
||||
.action(async (slug, options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdInstall(opts, slug, options.version, options.force);
|
||||
await cmdInstall(opts, slug, options.version, options.force, options.forceInstall);
|
||||
});
|
||||
|
||||
registerCommand(program, ["update"])
|
||||
@@ -282,6 +283,7 @@ registerCommand(program, ["update"])
|
||||
.option("--all", "Update all installed skills")
|
||||
.option("--version <version>", "Update to specific version (single slug only)")
|
||||
.option("--force", "Overwrite when local files do not match any version")
|
||||
.option("--force-install", "Install a pending GitHub-backed skill before ClawHub scan completes")
|
||||
.action(async (slug, options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdUpdate(opts, slug, options, isInputAllowed());
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
createUiModuleMocks,
|
||||
makeGlobalOpts,
|
||||
} from "../../../test/cliCommandTestKit.js";
|
||||
import { ApiRoutes } from "../../schema/index.js";
|
||||
import { ApiRoutes, LegacyApiRoutes } from "../../schema/index.js";
|
||||
import * as skillStore from "../../skills.js";
|
||||
|
||||
const fsMocks = vi.hoisted(() => ({
|
||||
@@ -37,6 +37,7 @@ const httpMocks = createHttpModuleMocks();
|
||||
const uiMocks = createUiModuleMocks();
|
||||
const mockApiRequest = httpMocks.apiRequest;
|
||||
const mockDownloadZip = httpMocks.downloadZip;
|
||||
const mockFetchBinary = httpMocks.fetchBinary;
|
||||
const mockGetOptionalAuthToken = authTokenMocks.getOptionalAuthToken;
|
||||
const mockSpinner = uiMocks.spinner;
|
||||
const mockIsInteractive = vi.fn(() => false);
|
||||
@@ -53,6 +54,7 @@ vi.mock("../ui.js", () => ({
|
||||
}));
|
||||
|
||||
const extractZipToDirMock = vi.spyOn(skillStore, "extractZipToDir");
|
||||
const extractGitHubZipPathToDirMock = vi.spyOn(skillStore, "extractGitHubZipPathToDir");
|
||||
const hashSkillFilesMock = vi.spyOn(skillStore, "hashSkillFiles");
|
||||
const listTextFilesMock = vi.spyOn(skillStore, "listTextFiles");
|
||||
const readLockfileMock = vi.spyOn(skillStore, "readLockfile");
|
||||
@@ -79,6 +81,7 @@ const {
|
||||
formatExploreLine,
|
||||
} = await import("./skills.js");
|
||||
const {
|
||||
extractGitHubZipPathToDir,
|
||||
extractZipToDir,
|
||||
hashSkillFiles,
|
||||
listTextFiles,
|
||||
@@ -100,6 +103,7 @@ beforeEach(() => {
|
||||
rmMock.mockResolvedValue(undefined);
|
||||
statMock.mockRejectedValue(new Error("missing"));
|
||||
extractZipToDirMock.mockResolvedValue(undefined);
|
||||
extractGitHubZipPathToDirMock.mockResolvedValue(undefined);
|
||||
hashSkillFilesMock.mockReturnValue({ fingerprint: "hash", files: [] });
|
||||
listTextFilesMock.mockResolvedValue([]);
|
||||
readLockfileMock.mockResolvedValue({ version: 1, skills: {} });
|
||||
@@ -114,6 +118,7 @@ afterEach(() => {
|
||||
|
||||
afterAll(() => {
|
||||
extractZipToDirMock.mockRestore();
|
||||
extractGitHubZipPathToDirMock.mockRestore();
|
||||
hashSkillFilesMock.mockRestore();
|
||||
listTextFilesMock.mockRestore();
|
||||
readLockfileMock.mockRestore();
|
||||
@@ -430,6 +435,105 @@ describe("cmdUpdate", () => {
|
||||
expect(mockLog).toHaveBeenCalledWith("Skipped 1 pinned skill: demo");
|
||||
});
|
||||
|
||||
it("continues update --all when a source-backed resolver response blocks one skill", async () => {
|
||||
mockApiRequest
|
||||
.mockResolvedValueOnce({
|
||||
latestVersion: null,
|
||||
moderation: null,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
slug: "stale-github",
|
||||
reason: "github_verification_pending",
|
||||
message: "stale-github changed upstream; waiting for ClawHub scan.",
|
||||
status: 423,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
latestVersion: { version: "2.0.0" },
|
||||
moderation: null,
|
||||
});
|
||||
mockDownloadZip.mockResolvedValue(new Uint8Array([1, 2, 3]));
|
||||
vi.mocked(readLockfile).mockResolvedValue({
|
||||
version: 1,
|
||||
skills: {
|
||||
"stale-github": { version: "a".repeat(40), installedAt: 123 },
|
||||
demo: { version: "1.0.0", installedAt: 456 },
|
||||
},
|
||||
});
|
||||
vi.mocked(writeLockfile).mockResolvedValue();
|
||||
vi.mocked(writeSkillOrigin).mockResolvedValue();
|
||||
vi.mocked(extractZipToDir).mockResolvedValue();
|
||||
vi.mocked(listTextFiles).mockResolvedValue([]);
|
||||
|
||||
await cmdUpdate(makeOpts(), undefined, { all: true }, false);
|
||||
|
||||
expect(mockSpinner.fail).toHaveBeenCalledWith(
|
||||
"stale-github: stale-github changed upstream; waiting for ClawHub scan.",
|
||||
);
|
||||
expect(mockDownloadZip).toHaveBeenCalledWith(
|
||||
"https://clawhub.ai",
|
||||
expect.objectContaining({ slug: "demo", version: "2.0.0" }),
|
||||
);
|
||||
expect(writeLockfile).toHaveBeenCalledWith("/work", {
|
||||
version: 1,
|
||||
skills: {
|
||||
"stale-github": { version: "a".repeat(40), installedAt: 123 },
|
||||
demo: { version: "2.0.0", installedAt: expect.any(Number) },
|
||||
},
|
||||
});
|
||||
const [, resolverArgs] = mockApiRequest.mock.calls[1] ?? [];
|
||||
expect(resolverArgs).toEqual(
|
||||
expect.objectContaining({
|
||||
path: `${ApiRoutes.skills}/${encodeURIComponent("stale-github")}/install`,
|
||||
acceptedStatuses: [403, 409, 410, 423],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes force-install to source-backed update resolution", async () => {
|
||||
const commit = "d".repeat(40);
|
||||
mockApiRequest
|
||||
.mockResolvedValueOnce({
|
||||
latestVersion: null,
|
||||
moderation: null,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
slug: "aiq-deploy",
|
||||
installKind: "github",
|
||||
github: {
|
||||
repo: "NVIDIA/skills",
|
||||
path: "skills/aiq-deploy",
|
||||
commit,
|
||||
contentHash: "hash-aiq-deploy",
|
||||
sourceUrl: `https://github.com/NVIDIA/skills/tree/${commit}/skills/aiq-deploy`,
|
||||
},
|
||||
});
|
||||
mockFetchBinary.mockResolvedValue(new Uint8Array([1, 2, 3]));
|
||||
vi.mocked(readLockfile).mockResolvedValue({
|
||||
version: 1,
|
||||
skills: { "aiq-deploy": { version: "a".repeat(40), installedAt: 123 } },
|
||||
});
|
||||
vi.mocked(readSkillOrigin).mockResolvedValue({
|
||||
version: 1,
|
||||
registry: "https://clawhub.ai",
|
||||
slug: "aiq-deploy",
|
||||
installedVersion: "a".repeat(40),
|
||||
installedAt: 123,
|
||||
fingerprint: "hash",
|
||||
});
|
||||
vi.mocked(stat).mockResolvedValue({} as unknown as Awaited<ReturnType<typeof stat>>);
|
||||
|
||||
await cmdUpdate(makeOpts(), "aiq-deploy", { forceInstall: true }, false);
|
||||
|
||||
const [, resolverArgs] = mockApiRequest.mock.calls[1] ?? [];
|
||||
expect(resolverArgs).toEqual(
|
||||
expect.objectContaining({
|
||||
path: `${ApiRoutes.skills}/${encodeURIComponent("aiq-deploy")}/install?forceInstall=1`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses path-based skill lookup when no local fingerprint is available", async () => {
|
||||
mockApiRequest.mockResolvedValue({ latestVersion: { version: "1.0.0" } });
|
||||
mockDownloadZip.mockResolvedValue(new Uint8Array([1, 2, 3]));
|
||||
@@ -453,6 +557,162 @@ describe("cmdUpdate", () => {
|
||||
expect(args?.url).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not overwrite GitHub-backed local files when the origin fingerprint is missing", async () => {
|
||||
const commit = "b".repeat(40);
|
||||
mockApiRequest
|
||||
.mockResolvedValueOnce({
|
||||
latestVersion: null,
|
||||
moderation: null,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
slug: "aiq-deploy",
|
||||
installKind: "github",
|
||||
github: {
|
||||
repo: "NVIDIA/skills",
|
||||
path: "skills/aiq-deploy",
|
||||
commit,
|
||||
contentHash: "hash-aiq-deploy",
|
||||
sourceUrl: `https://github.com/NVIDIA/skills/tree/${commit}/skills/aiq-deploy`,
|
||||
},
|
||||
});
|
||||
vi.mocked(readLockfile).mockResolvedValue({
|
||||
version: 1,
|
||||
skills: { "aiq-deploy": { version: "a".repeat(40), installedAt: 123 } },
|
||||
});
|
||||
vi.mocked(readSkillOrigin).mockResolvedValue({
|
||||
version: 1,
|
||||
registry: "https://clawhub.ai",
|
||||
slug: "aiq-deploy",
|
||||
installedVersion: "a".repeat(40),
|
||||
installedAt: 123,
|
||||
});
|
||||
vi.mocked(listTextFiles).mockResolvedValue([
|
||||
{ relPath: "SKILL.md", bytes: new Uint8Array([1]) },
|
||||
]);
|
||||
vi.mocked(hashSkillFiles).mockReturnValue({ fingerprint: "local-fingerprint", files: [] });
|
||||
vi.mocked(stat).mockResolvedValue({} as unknown as Awaited<ReturnType<typeof stat>>);
|
||||
|
||||
await cmdUpdate(makeOpts(), "aiq-deploy", {}, false);
|
||||
|
||||
expect(mockLog).toHaveBeenCalledWith(
|
||||
"aiq-deploy: local changes (no match). Use --force to overwrite.",
|
||||
);
|
||||
expect(rm).not.toHaveBeenCalled();
|
||||
expect(mockFetchBinary).not.toHaveBeenCalled();
|
||||
expect(extractGitHubZipPathToDir).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reinstalls GitHub-backed skills when only the lockfile remains", async () => {
|
||||
const commit = "c".repeat(40);
|
||||
mockApiRequest
|
||||
.mockResolvedValueOnce({
|
||||
latestVersion: null,
|
||||
moderation: null,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
slug: "aiq-deploy",
|
||||
installKind: "github",
|
||||
github: {
|
||||
repo: "NVIDIA/skills",
|
||||
path: "skills/aiq-deploy",
|
||||
commit,
|
||||
contentHash: "hash-aiq-deploy",
|
||||
sourceUrl: `https://github.com/NVIDIA/skills/tree/${commit}/skills/aiq-deploy`,
|
||||
},
|
||||
});
|
||||
mockFetchBinary.mockResolvedValue(new Uint8Array([1, 2, 3]));
|
||||
vi.mocked(readLockfile).mockResolvedValue({
|
||||
version: 1,
|
||||
skills: { "aiq-deploy": { version: commit, installedAt: 123 } },
|
||||
});
|
||||
vi.mocked(readSkillOrigin).mockResolvedValue(null);
|
||||
vi.mocked(listTextFiles).mockResolvedValueOnce([
|
||||
{ relPath: "SKILL.md", bytes: new Uint8Array([1]) },
|
||||
]);
|
||||
vi.mocked(hashSkillFiles).mockReturnValue({ fingerprint: "clean-fingerprint", files: [] });
|
||||
vi.mocked(stat).mockRejectedValue(new Error("missing"));
|
||||
|
||||
await cmdUpdate(makeOpts(), "aiq-deploy", {}, false);
|
||||
|
||||
expect(mockFetchBinary).toHaveBeenCalledWith("https://clawhub.ai", {
|
||||
url: `https://codeload.github.com/NVIDIA/skills/zip/${commit}`,
|
||||
});
|
||||
expect(extractGitHubZipPathToDir).toHaveBeenCalledWith(
|
||||
new Uint8Array([1, 2, 3]),
|
||||
"/work/skills/aiq-deploy",
|
||||
"skills/aiq-deploy",
|
||||
);
|
||||
expect(mockSpinner.succeed).toHaveBeenCalledWith(
|
||||
`aiq-deploy: updated -> ${commit.slice(0, 12)}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("overwrites confirmed GitHub-backed local changes even when already at latest commit", async () => {
|
||||
const commit = "b".repeat(40);
|
||||
mockIsInteractive.mockReturnValue(true);
|
||||
mockPromptConfirm.mockResolvedValue(true);
|
||||
mockApiRequest
|
||||
.mockResolvedValueOnce({
|
||||
latestVersion: null,
|
||||
moderation: null,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
slug: "aiq-deploy",
|
||||
installKind: "github",
|
||||
github: {
|
||||
repo: "NVIDIA/skills",
|
||||
path: "skills/aiq-deploy",
|
||||
commit,
|
||||
contentHash: "hash-aiq-deploy",
|
||||
sourceUrl: `https://github.com/NVIDIA/skills/tree/${commit}/skills/aiq-deploy`,
|
||||
},
|
||||
});
|
||||
mockFetchBinary.mockResolvedValue(new Uint8Array([1, 2, 3]));
|
||||
vi.mocked(readLockfile).mockResolvedValue({
|
||||
version: 1,
|
||||
skills: { "aiq-deploy": { version: commit, installedAt: 123 } },
|
||||
});
|
||||
vi.mocked(readSkillOrigin).mockResolvedValue({
|
||||
version: 1,
|
||||
registry: "https://clawhub.ai",
|
||||
slug: "aiq-deploy",
|
||||
installedVersion: commit,
|
||||
installedAt: 123,
|
||||
fingerprint: "clean-fingerprint",
|
||||
});
|
||||
vi.mocked(listTextFiles)
|
||||
.mockResolvedValueOnce([{ relPath: "SKILL.md", bytes: new Uint8Array([9]) }])
|
||||
.mockResolvedValueOnce([{ relPath: "SKILL.md", bytes: new Uint8Array([1]) }]);
|
||||
vi.mocked(hashSkillFiles)
|
||||
.mockReturnValueOnce({ fingerprint: "dirty-fingerprint", files: [] })
|
||||
.mockReturnValueOnce({ fingerprint: "clean-fingerprint", files: [] });
|
||||
vi.mocked(stat).mockResolvedValue({} as unknown as Awaited<ReturnType<typeof stat>>);
|
||||
|
||||
await cmdUpdate(makeOpts(), "aiq-deploy", {}, true);
|
||||
|
||||
expect(mockPromptConfirm).toHaveBeenCalledWith(
|
||||
`aiq-deploy: local changes (no match). Overwrite with ${commit.slice(0, 12)}?`,
|
||||
);
|
||||
expect(rm).toHaveBeenCalledWith("/work/skills/aiq-deploy", {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
expect(mockFetchBinary).toHaveBeenCalledWith("https://clawhub.ai", {
|
||||
url: `https://codeload.github.com/NVIDIA/skills/zip/${commit}`,
|
||||
});
|
||||
expect(extractGitHubZipPathToDir).toHaveBeenCalledWith(
|
||||
new Uint8Array([1, 2, 3]),
|
||||
"/work/skills/aiq-deploy",
|
||||
"skills/aiq-deploy",
|
||||
);
|
||||
expect(mockSpinner.succeed).toHaveBeenCalledWith(
|
||||
`aiq-deploy: updated -> ${commit.slice(0, 12)}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("trusts the stored install fingerprint when the resolve endpoint cannot match", async () => {
|
||||
mockApiRequest
|
||||
.mockResolvedValueOnce({
|
||||
@@ -622,19 +882,22 @@ describe("cmdList", () => {
|
||||
describe("cmdInstall", () => {
|
||||
it("passes optional auth token to API + download requests", async () => {
|
||||
mockGetOptionalAuthToken.mockResolvedValue("tkn");
|
||||
mockApiRequest.mockResolvedValue({
|
||||
skill: {
|
||||
slug: "demo",
|
||||
displayName: "Demo",
|
||||
summary: null,
|
||||
tags: {},
|
||||
stats: {},
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
},
|
||||
latestVersion: { version: "1.0.0" },
|
||||
owner: null,
|
||||
moderation: null,
|
||||
mockApiRequest.mockImplementation(async (_registry, args) => {
|
||||
if (args.path === LegacyApiRoutes.cliTelemetryInstall) return { ok: true };
|
||||
return {
|
||||
skill: {
|
||||
slug: "demo",
|
||||
displayName: "Demo",
|
||||
summary: null,
|
||||
tags: {},
|
||||
stats: {},
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
},
|
||||
latestVersion: { version: "1.0.0" },
|
||||
owner: null,
|
||||
moderation: null,
|
||||
};
|
||||
});
|
||||
mockDownloadZip.mockResolvedValue(new Uint8Array([1, 2, 3]));
|
||||
vi.mocked(readLockfile).mockResolvedValue({ version: 1, skills: {} });
|
||||
@@ -650,6 +913,180 @@ describe("cmdInstall", () => {
|
||||
expect(requestArgs?.token).toBe("tkn");
|
||||
const [, zipArgs] = mockDownloadZip.mock.calls[0] ?? [];
|
||||
expect(zipArgs?.token).toBe("tkn");
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
"https://clawhub.ai",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
path: LegacyApiRoutes.cliTelemetryInstall,
|
||||
token: "tkn",
|
||||
body: {
|
||||
roots: [
|
||||
{
|
||||
rootId: expect.any(String),
|
||||
label: expect.any(String),
|
||||
skills: [{ slug: "demo", version: "1.0.0" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not fail installs when install telemetry fails", async () => {
|
||||
mockGetOptionalAuthToken.mockResolvedValue("tkn");
|
||||
mockApiRequest.mockImplementation(async (_registry, args) => {
|
||||
if (args.path === LegacyApiRoutes.cliTelemetryInstall) throw new Error("telemetry down");
|
||||
return {
|
||||
skill: {
|
||||
slug: "demo",
|
||||
displayName: "Demo",
|
||||
summary: null,
|
||||
tags: {},
|
||||
stats: {},
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
},
|
||||
latestVersion: { version: "1.0.0" },
|
||||
owner: null,
|
||||
moderation: null,
|
||||
};
|
||||
});
|
||||
mockDownloadZip.mockResolvedValue(new Uint8Array([1, 2, 3]));
|
||||
vi.mocked(readLockfile).mockResolvedValue({ version: 1, skills: {} });
|
||||
vi.mocked(writeLockfile).mockResolvedValue();
|
||||
vi.mocked(writeSkillOrigin).mockResolvedValue();
|
||||
vi.mocked(extractZipToDir).mockResolvedValue();
|
||||
vi.mocked(stat).mockRejectedValue(new Error("missing"));
|
||||
|
||||
await expect(cmdInstall(makeOpts(), "demo")).resolves.toBeUndefined();
|
||||
|
||||
expect(writeLockfile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("installs source-backed skills from the GitHub resolver response", async () => {
|
||||
const commit = "a".repeat(40);
|
||||
mockGetOptionalAuthToken.mockResolvedValue("tkn");
|
||||
mockApiRequest
|
||||
.mockResolvedValueOnce({
|
||||
skill: {
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
summary: null,
|
||||
tags: {},
|
||||
stats: {},
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
},
|
||||
latestVersion: null,
|
||||
owner: null,
|
||||
moderation: null,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
slug: "aiq-deploy",
|
||||
installKind: "github",
|
||||
github: {
|
||||
repo: "NVIDIA/skills",
|
||||
path: "skills/aiq-deploy",
|
||||
commit,
|
||||
contentHash: "hash-aiq-deploy",
|
||||
sourceUrl: `https://github.com/NVIDIA/skills/tree/${commit}/skills/aiq-deploy`,
|
||||
},
|
||||
});
|
||||
mockFetchBinary.mockResolvedValue(new Uint8Array([1, 2, 3]));
|
||||
vi.mocked(readLockfile).mockResolvedValue({ version: 1, skills: {} });
|
||||
vi.mocked(writeLockfile).mockResolvedValue();
|
||||
vi.mocked(writeSkillOrigin).mockResolvedValue();
|
||||
vi.mocked(extractGitHubZipPathToDir).mockResolvedValue();
|
||||
vi.mocked(listTextFiles).mockResolvedValue([
|
||||
{ relPath: "SKILL.md", bytes: new Uint8Array([1]) },
|
||||
]);
|
||||
vi.mocked(hashSkillFiles).mockReturnValue({ fingerprint: "hash", files: [] });
|
||||
vi.mocked(stat).mockRejectedValue(new Error("missing"));
|
||||
|
||||
await cmdInstall(makeOpts(), "aiq-deploy");
|
||||
|
||||
expect(mockApiRequest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"https://clawhub.ai",
|
||||
{
|
||||
method: "GET",
|
||||
path: `${ApiRoutes.skills}/${encodeURIComponent("aiq-deploy")}/install`,
|
||||
token: "tkn",
|
||||
acceptedStatuses: [403, 409, 410, 423],
|
||||
},
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mockDownloadZip).not.toHaveBeenCalled();
|
||||
expect(mockFetchBinary).toHaveBeenCalledWith("https://clawhub.ai", {
|
||||
url: `https://codeload.github.com/NVIDIA/skills/zip/${commit}`,
|
||||
});
|
||||
expect(extractGitHubZipPathToDir).toHaveBeenCalledWith(
|
||||
new Uint8Array([1, 2, 3]),
|
||||
"/work/skills/aiq-deploy",
|
||||
"skills/aiq-deploy",
|
||||
);
|
||||
expect(writeSkillOrigin).toHaveBeenCalledWith("/work/skills/aiq-deploy", {
|
||||
version: 1,
|
||||
registry: "https://clawhub.ai",
|
||||
slug: "aiq-deploy",
|
||||
installedVersion: commit,
|
||||
installedAt: expect.any(Number),
|
||||
fingerprint: "hash",
|
||||
});
|
||||
expect(writeLockfile).toHaveBeenCalledWith("/work", {
|
||||
version: 1,
|
||||
skills: {
|
||||
"aiq-deploy": { version: commit, installedAt: expect.any(Number) },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("passes force-install to source-backed install resolution", async () => {
|
||||
const commit = "a".repeat(40);
|
||||
mockGetOptionalAuthToken.mockResolvedValue("tkn");
|
||||
mockApiRequest
|
||||
.mockResolvedValueOnce({
|
||||
skill: {
|
||||
slug: "aiq-deploy",
|
||||
displayName: "AIQ Deploy",
|
||||
summary: null,
|
||||
tags: {},
|
||||
stats: {},
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
},
|
||||
latestVersion: null,
|
||||
owner: null,
|
||||
moderation: null,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
slug: "aiq-deploy",
|
||||
installKind: "github",
|
||||
github: {
|
||||
repo: "NVIDIA/skills",
|
||||
path: "skills/aiq-deploy",
|
||||
commit,
|
||||
contentHash: "hash-aiq-deploy",
|
||||
sourceUrl: `https://github.com/NVIDIA/skills/tree/${commit}/skills/aiq-deploy`,
|
||||
},
|
||||
});
|
||||
mockFetchBinary.mockResolvedValue(new Uint8Array([1, 2, 3]));
|
||||
|
||||
await cmdInstall(makeOpts(), "aiq-deploy", undefined, false, true);
|
||||
|
||||
expect(mockApiRequest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"https://clawhub.ai",
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
path: `${ApiRoutes.skills}/${encodeURIComponent("aiq-deploy")}/install?forceInstall=1`,
|
||||
token: "tkn",
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks force reinstall when a skill is pinned", async () => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { mkdir, rm, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import semver from "semver";
|
||||
import { apiRequest, downloadZip, registryUrl } from "../../http.js";
|
||||
import { apiRequest, downloadZip, fetchBinary, registryUrl } from "../../http.js";
|
||||
import {
|
||||
ApiRoutes,
|
||||
ApiV1SkillInstallResolveResponseSchema,
|
||||
ApiV1SearchResponseSchema,
|
||||
ApiV1SkillListResponseSchema,
|
||||
ApiV1SkillReportListResponseSchema,
|
||||
@@ -12,11 +13,13 @@ import {
|
||||
ApiV1SkillResolveResponseSchema,
|
||||
ApiV1SkillResponseSchema,
|
||||
ApiV1SkillVersionResponseSchema,
|
||||
type ApiV1SkillInstallResolveResponse,
|
||||
type SkillReportFinalAction,
|
||||
type SkillReportListStatus,
|
||||
type SkillReportStatus,
|
||||
} from "../../schema/index.js";
|
||||
import {
|
||||
extractGitHubZipPathToDir,
|
||||
extractZipToDir,
|
||||
hashSkillFiles,
|
||||
listManualSkills,
|
||||
@@ -31,6 +34,7 @@ import { getRegistry } from "../registry.js";
|
||||
import type { GlobalOpts, ResolveResult } from "../types.js";
|
||||
import { createSpinner, fail, formatError, isInteractive, promptConfirm } from "../ui.js";
|
||||
import { presentModerationPlan, reportModerationPlan } from "./moderationPlan.js";
|
||||
import { reportInstalledSkillsTelemetryIfEnabled } from "./syncHelpers.js";
|
||||
|
||||
type SkillReportOptions = {
|
||||
version?: string;
|
||||
@@ -54,6 +58,11 @@ type SkillReportTriageOptions = {
|
||||
yes?: boolean;
|
||||
};
|
||||
|
||||
type GitHubInstallResolution = Extract<
|
||||
ApiV1SkillInstallResolveResponse,
|
||||
{ ok: true; installKind: "github" }
|
||||
>;
|
||||
|
||||
function normalizeSkillSlugOrFail(raw: string) {
|
||||
const slug = raw.trim();
|
||||
if (!slug) fail("Slug required");
|
||||
@@ -135,6 +144,7 @@ export async function cmdInstall(
|
||||
slug: string,
|
||||
versionFlag?: string,
|
||||
force = false,
|
||||
forceInstall = false,
|
||||
) {
|
||||
const trimmed = normalizeSkillSlugOrFail(slug);
|
||||
|
||||
@@ -185,8 +195,20 @@ export async function cmdInstall(
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedVersion = versionFlag ?? skillMeta.latestVersion?.version ?? null;
|
||||
if (!resolvedVersion) fail("Could not resolve latest version");
|
||||
let resolvedVersion = versionFlag ?? skillMeta.latestVersion?.version ?? null;
|
||||
let githubInstall: GitHubInstallResolution | null = null;
|
||||
if (!resolvedVersion && !versionFlag) {
|
||||
const resolvedInstall = await resolveLatestSkillInstall(registry, trimmed, token, {
|
||||
forceInstall,
|
||||
});
|
||||
if (!resolvedInstall.ok) fail(resolvedInstall.message);
|
||||
if (resolvedInstall.installKind === "github") {
|
||||
githubInstall = resolvedInstall;
|
||||
} else {
|
||||
resolvedVersion = resolvedInstall.archive.version;
|
||||
}
|
||||
}
|
||||
if (!resolvedVersion && !githubInstall) fail("Could not resolve latest version");
|
||||
|
||||
if (versionFlag) {
|
||||
await apiRequest(
|
||||
@@ -194,7 +216,7 @@ export async function cmdInstall(
|
||||
{
|
||||
method: "GET",
|
||||
path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}/versions/${encodeURIComponent(
|
||||
resolvedVersion,
|
||||
versionFlag,
|
||||
)}`,
|
||||
token,
|
||||
},
|
||||
@@ -206,9 +228,17 @@ export async function cmdInstall(
|
||||
await rm(target, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
spinner.text = `Downloading ${trimmed}@${resolvedVersion}`;
|
||||
const zip = await downloadZip(registry, { slug: trimmed, version: resolvedVersion, token });
|
||||
await extractZipToDir(zip, target);
|
||||
if (githubInstall) {
|
||||
spinner.text = `Downloading ${trimmed}@${formatGitHubVersion(githubInstall.github.commit)}`;
|
||||
await installGitHubSkill(registry, githubInstall, target);
|
||||
resolvedVersion = githubInstall.github.commit;
|
||||
} else {
|
||||
const archiveVersion = resolvedVersion;
|
||||
if (!archiveVersion) fail("Could not resolve latest version");
|
||||
spinner.text = `Downloading ${trimmed}@${archiveVersion}`;
|
||||
const zip = await downloadZip(registry, { slug: trimmed, version: archiveVersion, token });
|
||||
await extractZipToDir(zip, target);
|
||||
}
|
||||
const installedFiles = await listTextFiles(target);
|
||||
const installedFingerprint =
|
||||
installedFiles.length > 0 ? hashSkillFiles(installedFiles).fingerprint : undefined;
|
||||
@@ -217,13 +247,19 @@ export async function cmdInstall(
|
||||
version: 1,
|
||||
registry,
|
||||
slug: trimmed,
|
||||
installedVersion: resolvedVersion,
|
||||
installedVersion: resolvedVersion!,
|
||||
installedAt: Date.now(),
|
||||
fingerprint: installedFingerprint,
|
||||
});
|
||||
|
||||
lock.skills[trimmed] = withPinnedMetadata(resolvedVersion, Date.now(), existingEntry);
|
||||
lock.skills[trimmed] = withPinnedMetadata(resolvedVersion!, Date.now(), existingEntry);
|
||||
await writeLockfile(opts.workdir, lock);
|
||||
await reportInstalledSkillsTelemetryIfEnabled({
|
||||
token,
|
||||
registry,
|
||||
root: opts.dir,
|
||||
skills: lock.skills,
|
||||
});
|
||||
spinner.succeed(`OK. Installed ${trimmed} -> ${target}`);
|
||||
} catch (error) {
|
||||
spinner.fail(formatError(error));
|
||||
@@ -234,7 +270,7 @@ export async function cmdInstall(
|
||||
export async function cmdUpdate(
|
||||
opts: GlobalOpts,
|
||||
slugArg: string | undefined,
|
||||
options: { all?: boolean; version?: string; force?: boolean },
|
||||
options: { all?: boolean; version?: string; force?: boolean; forceInstall?: boolean },
|
||||
inputAllowed: boolean,
|
||||
) {
|
||||
const slug = slugArg ? normalizeSkillSlugOrFail(slugArg) : undefined;
|
||||
@@ -320,11 +356,97 @@ export async function cmdUpdate(
|
||||
}
|
||||
}
|
||||
|
||||
let latestInstall: ApiV1SkillInstallResolveResponse | null = null;
|
||||
if (!skillMeta.latestVersion && !options.version) {
|
||||
latestInstall = await resolveLatestSkillInstall(registry, entry, token, {
|
||||
forceInstall: Boolean(options.forceInstall),
|
||||
});
|
||||
if (!latestInstall.ok) {
|
||||
spinner.fail(`${entry}: ${latestInstall.message}`);
|
||||
continue;
|
||||
}
|
||||
if (latestInstall.installKind === "github") {
|
||||
const targetVersion = latestInstall.github.commit;
|
||||
const originFingerprint =
|
||||
existingOrigin?.slug === entry ? existingOrigin.fingerprint : undefined;
|
||||
const hasLocalChanges = Boolean(
|
||||
exists &&
|
||||
localFingerprint &&
|
||||
(!originFingerprint || originFingerprint !== localFingerprint),
|
||||
);
|
||||
const matched =
|
||||
existingOrigin?.slug === entry &&
|
||||
originFingerprint &&
|
||||
localFingerprint &&
|
||||
originFingerprint === localFingerprint
|
||||
? existingOrigin.installedVersion
|
||||
: null;
|
||||
|
||||
if (hasLocalChanges && !options.force) {
|
||||
spinner.stop();
|
||||
if (!allowPrompt) {
|
||||
console.log(`${entry}: local changes (no match). Use --force to overwrite.`);
|
||||
continue;
|
||||
}
|
||||
const confirm = await promptConfirm(
|
||||
`${entry}: local changes (no match). Overwrite with ${formatGitHubVersion(targetVersion)}?`,
|
||||
);
|
||||
if (!confirm) {
|
||||
console.log(`${entry}: skipped`);
|
||||
continue;
|
||||
}
|
||||
spinner.start(`Updating ${entry} -> ${formatGitHubVersion(targetVersion)}`);
|
||||
}
|
||||
|
||||
if (matched === targetVersion && !options.force && !hasLocalChanges) {
|
||||
if (lock.skills[entry]?.version !== targetVersion) {
|
||||
lock.skills[entry] = withPinnedMetadata(
|
||||
targetVersion,
|
||||
lock.skills[entry]?.installedAt ?? Date.now(),
|
||||
lock.skills[entry],
|
||||
);
|
||||
}
|
||||
spinner.succeed(`${entry}: up to date (${formatGitHubVersion(targetVersion)})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (spinner.isSpinning) {
|
||||
spinner.text = `Updating ${entry} -> ${formatGitHubVersion(targetVersion)}`;
|
||||
} else {
|
||||
spinner.start(`Updating ${entry} -> ${formatGitHubVersion(targetVersion)}`);
|
||||
}
|
||||
await rm(target, { recursive: true, force: true });
|
||||
await installGitHubSkill(registry, latestInstall, target);
|
||||
const installedFiles = await listTextFiles(target);
|
||||
const installedFingerprint =
|
||||
installedFiles.length > 0 ? hashSkillFiles(installedFiles).fingerprint : undefined;
|
||||
|
||||
await writeSkillOrigin(target, {
|
||||
version: 1,
|
||||
registry: existingOrigin?.registry ?? registry,
|
||||
slug: entry,
|
||||
installedVersion: targetVersion,
|
||||
installedAt: existingOrigin?.installedAt ?? Date.now(),
|
||||
fingerprint: installedFingerprint,
|
||||
});
|
||||
|
||||
lock.skills[entry] = withPinnedMetadata(targetVersion, Date.now(), lock.skills[entry]);
|
||||
spinner.succeed(`${entry}: updated -> ${formatGitHubVersion(targetVersion)}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const latestVersion =
|
||||
skillMeta.latestVersion ??
|
||||
(latestInstall?.ok && latestInstall.installKind === "archive"
|
||||
? { version: latestInstall.archive.version }
|
||||
: null);
|
||||
|
||||
let resolveResult: ResolveResult;
|
||||
if (localFingerprint) {
|
||||
resolveResult = await resolveSkillVersion(registry, entry, localFingerprint, token);
|
||||
} else {
|
||||
resolveResult = { match: null, latestVersion: skillMeta.latestVersion ?? null };
|
||||
resolveResult = { match: null, latestVersion };
|
||||
}
|
||||
|
||||
const latest = resolveResult.latestVersion?.version ?? null;
|
||||
@@ -776,6 +898,58 @@ async function resolveSkillVersion(registry: string, slug: string, hash: string,
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveLatestSkillInstall(
|
||||
registry: string,
|
||||
slug: string,
|
||||
token?: string,
|
||||
options: { forceInstall?: boolean } = {},
|
||||
) {
|
||||
const path = `${ApiRoutes.skills}/${encodeURIComponent(slug)}/install${
|
||||
options.forceInstall ? "?forceInstall=1" : ""
|
||||
}`;
|
||||
return await apiRequest(
|
||||
registry,
|
||||
{
|
||||
method: "GET",
|
||||
path,
|
||||
token,
|
||||
acceptedStatuses: [403, 409, 410, 423],
|
||||
},
|
||||
ApiV1SkillInstallResolveResponseSchema,
|
||||
);
|
||||
}
|
||||
|
||||
async function installGitHubSkill(
|
||||
registry: string,
|
||||
resolution: GitHubInstallResolution,
|
||||
target: string,
|
||||
) {
|
||||
const zip = await fetchBinary(registry, {
|
||||
url: gitHubZipUrl(resolution.github.repo, resolution.github.commit),
|
||||
});
|
||||
await extractGitHubZipPathToDir(zip, target, resolution.github.path);
|
||||
}
|
||||
|
||||
function gitHubZipUrl(repo: string, commit: string) {
|
||||
const base = (
|
||||
process.env.CLAWHUB_GITHUB_CODELOAD_BASE_URL ||
|
||||
process.env.OPENCLAW_CLAWHUB_GITHUB_CODELOAD_BASE_URL ||
|
||||
"https://codeload.github.com"
|
||||
).replace(/\/+$/, "");
|
||||
return `${base}/${encodeGitHubRepo(repo)}/zip/${encodeURIComponent(commit)}`;
|
||||
}
|
||||
|
||||
function encodeGitHubRepo(repo: string) {
|
||||
return repo
|
||||
.split("/")
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join("/");
|
||||
}
|
||||
|
||||
function formatGitHubVersion(commit: string) {
|
||||
return commit.length > 12 ? commit.slice(0, 12) : commit;
|
||||
}
|
||||
|
||||
async function fileExists(path: string) {
|
||||
try {
|
||||
await stat(path);
|
||||
|
||||
@@ -121,7 +121,7 @@ describe("cmdSync", () => {
|
||||
interactive = false;
|
||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
||||
if (args.path === "/api/v1/whoami") return { user: { handle: "steipete" } };
|
||||
if (args.path === "/api/cli/telemetry/sync") return { ok: true };
|
||||
if (args.path === "/api/cli/telemetry/install") return { ok: true };
|
||||
if (args.path.startsWith("/api/v1/resolve?")) {
|
||||
const u = new URL(`https://x.test${args.path}`);
|
||||
const slug = u.searchParams.get("slug");
|
||||
@@ -236,7 +236,7 @@ describe("cmdSync", () => {
|
||||
});
|
||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
||||
if (args.path === "/api/v1/whoami") return { user: { handle: "steipete" } };
|
||||
if (args.path === "/api/cli/telemetry/sync") return { ok: true };
|
||||
if (args.path === "/api/cli/telemetry/install") return { ok: true };
|
||||
if (args.path.startsWith("/api/v1/resolve?")) {
|
||||
const u = new URL(`https://x.test${args.path}`);
|
||||
const slug = u.searchParams.get("slug");
|
||||
@@ -281,7 +281,7 @@ describe("cmdSync", () => {
|
||||
});
|
||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
||||
if (args.path === "/api/v1/whoami") return { user: { handle: "mikehollinger" } };
|
||||
if (args.path === "/api/cli/telemetry/sync") return { ok: true };
|
||||
if (args.path === "/api/cli/telemetry/install") return { ok: true };
|
||||
if (args.path.startsWith("/api/v1/resolve?")) {
|
||||
const u = new URL(`https://x.test${args.path}`);
|
||||
const slug = u.searchParams.get("slug");
|
||||
@@ -343,7 +343,7 @@ describe("cmdSync", () => {
|
||||
});
|
||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
||||
if (args.path === "/api/v1/whoami") return { user: { handle: "mikehollinger" } };
|
||||
if (args.path === "/api/cli/telemetry/sync") return { ok: true };
|
||||
if (args.path === "/api/cli/telemetry/install") return { ok: true };
|
||||
if (args.path.startsWith("/api/v1/resolve?")) {
|
||||
throw new Error("Skill not found");
|
||||
}
|
||||
@@ -374,7 +374,7 @@ describe("cmdSync", () => {
|
||||
interactive = false;
|
||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
||||
if (args.path === "/api/v1/whoami") return { user: { handle: "steipete" } };
|
||||
if (args.path === "/api/cli/telemetry/sync") return { ok: true };
|
||||
if (args.path === "/api/cli/telemetry/install") return { ok: true };
|
||||
if (args.path.startsWith("/api/v1/resolve?")) {
|
||||
const u = new URL(`https://x.test${args.path}`);
|
||||
const slug = u.searchParams.get("slug");
|
||||
@@ -403,7 +403,7 @@ describe("cmdSync", () => {
|
||||
interactive = false;
|
||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
||||
if (args.path === "/api/v1/whoami") return { user: { handle: "steipete" } };
|
||||
if (args.path === "/api/cli/telemetry/sync") return { ok: true };
|
||||
if (args.path === "/api/cli/telemetry/install") return { ok: true };
|
||||
if (args.path.startsWith("/api/v1/resolve?")) {
|
||||
return { match: { version: "1.0.0" }, latestVersion: { version: "1.0.0" } };
|
||||
}
|
||||
@@ -435,7 +435,7 @@ describe("cmdSync", () => {
|
||||
|
||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
||||
if (args.path === "/api/v1/whoami") return { user: { handle: "steipete" } };
|
||||
if (args.path === "/api/cli/telemetry/sync") return { ok: true };
|
||||
if (args.path === "/api/cli/telemetry/install") return { ok: true };
|
||||
if (args.path.startsWith("/api/v1/resolve?")) {
|
||||
return { match: null, latestVersion: null };
|
||||
}
|
||||
@@ -465,7 +465,7 @@ describe("cmdSync", () => {
|
||||
});
|
||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
||||
if (args.path === "/api/v1/whoami") return { user: { handle: "steipete" } };
|
||||
if (args.path === "/api/cli/telemetry/sync") return { ok: true };
|
||||
if (args.path === "/api/cli/telemetry/install") return { ok: true };
|
||||
if (args.path.startsWith("/api/v1/resolve?")) {
|
||||
throw new Error("Skill not found");
|
||||
}
|
||||
@@ -548,7 +548,7 @@ describe("cmdSync", () => {
|
||||
interactive = true;
|
||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
||||
if (args.path === "/api/v1/whoami") return { user: { handle: "steipete" } };
|
||||
if (args.path === "/api/cli/telemetry/sync") return { ok: true };
|
||||
if (args.path === "/api/cli/telemetry/install") return { ok: true };
|
||||
if (args.path.startsWith("/api/v1/resolve?")) {
|
||||
const u = new URL(`https://x.test${args.path}`);
|
||||
const slug = u.searchParams.get("slug");
|
||||
@@ -579,7 +579,7 @@ describe("cmdSync", () => {
|
||||
interactive = false;
|
||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
||||
if (args.path === "/api/v1/whoami") return { user: { handle: "steipete" } };
|
||||
if (args.path === "/api/cli/telemetry/sync") return { ok: true };
|
||||
if (args.path === "/api/cli/telemetry/install") return { ok: true };
|
||||
if (args.path.startsWith("/api/v1/resolve?")) {
|
||||
const u = new URL(`https://x.test${args.path}`);
|
||||
const slug = u.searchParams.get("slug");
|
||||
@@ -624,7 +624,7 @@ describe("cmdSync", () => {
|
||||
interactive = false;
|
||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
||||
if (args.path === "/api/v1/whoami") return { user: { handle: "steipete" } };
|
||||
if (args.path === "/api/cli/telemetry/sync") return { ok: true };
|
||||
if (args.path === "/api/cli/telemetry/install") return { ok: true };
|
||||
if (args.path.startsWith("/api/v1/resolve?")) {
|
||||
const u = new URL(`https://x.test${args.path}`);
|
||||
const slug = u.searchParams.get("slug");
|
||||
@@ -671,7 +671,7 @@ describe("cmdSync", () => {
|
||||
interactive = false;
|
||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
||||
if (args.path === "/api/v1/whoami") return { user: { handle: "steipete" } };
|
||||
if (args.path === "/api/cli/telemetry/sync") return { ok: true };
|
||||
if (args.path === "/api/cli/telemetry/install") return { ok: true };
|
||||
if (args.path.startsWith("/api/v1/resolve?")) {
|
||||
const u = new URL(`https://x.test${args.path}`);
|
||||
const slug = u.searchParams.get("slug");
|
||||
@@ -691,7 +691,7 @@ describe("cmdSync", () => {
|
||||
const { slug } = options as { slug: string };
|
||||
if (slug === "new-skill") {
|
||||
throw new Error(
|
||||
"This slug is locked to a deleted or banned account. If you believe you are the rightful owner, please contact security@openclaw.ai to reclaim it.",
|
||||
"This slug is locked to a deleted or banned account. If you believe you are the rightful owner, open a GitHub issue to reclaim it: https://github.com/openclaw/clawhub/issues/new.",
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -717,7 +717,7 @@ describe("cmdSync", () => {
|
||||
interactive = false;
|
||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
||||
if (args.path === "/api/v1/whoami") return { user: { handle: "steipete" } };
|
||||
if (args.path === "/api/cli/telemetry/sync") return { ok: true };
|
||||
if (args.path === "/api/cli/telemetry/install") return { ok: true };
|
||||
if (args.path.startsWith("/api/v1/resolve?")) {
|
||||
const u = new URL(`https://x.test${args.path}`);
|
||||
const slug = u.searchParams.get("slug");
|
||||
@@ -761,7 +761,7 @@ describe("cmdSync", () => {
|
||||
});
|
||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
||||
if (args.path === "/api/v1/whoami") return { user: { handle: "steipete" } };
|
||||
if (args.path === "/api/cli/telemetry/sync") return { ok: true };
|
||||
if (args.path === "/api/cli/telemetry/install") return { ok: true };
|
||||
if (args.path.startsWith("/api/v1/resolve?")) {
|
||||
return { match: null, latestVersion: { version: "1.0.0" } };
|
||||
}
|
||||
@@ -792,7 +792,7 @@ describe("cmdSync", () => {
|
||||
|
||||
await cmdSync(makeOpts(), { root: ["/scan"], all: true, dryRun: true }, true);
|
||||
expect(
|
||||
mockApiRequest.mock.calls.some((call) => call[1]?.path === "/api/cli/telemetry/sync"),
|
||||
mockApiRequest.mock.calls.some((call) => call[1]?.path === "/api/cli/telemetry/install"),
|
||||
).toBe(false);
|
||||
delete process.env.CLAWHUB_DISABLE_TELEMETRY;
|
||||
});
|
||||
|
||||
@@ -46,7 +46,7 @@ export async function reportTelemetryIfEnabled(params: {
|
||||
params.registry,
|
||||
{
|
||||
method: "POST",
|
||||
path: LegacyApiRoutes.cliTelemetrySync,
|
||||
path: LegacyApiRoutes.cliTelemetryInstall,
|
||||
token: params.token,
|
||||
body: { roots },
|
||||
},
|
||||
@@ -57,6 +57,45 @@ export async function reportTelemetryIfEnabled(params: {
|
||||
}
|
||||
}
|
||||
|
||||
export async function reportInstalledSkillsTelemetryIfEnabled(params: {
|
||||
token: string | undefined;
|
||||
registry: string;
|
||||
root: string;
|
||||
skills: Record<string, { version?: string | null }>;
|
||||
}) {
|
||||
if (!params.token || isTelemetryDisabled()) return;
|
||||
const skills = Object.entries(params.skills)
|
||||
.map(([slug, entry]) => ({
|
||||
slug,
|
||||
version: entry.version ?? null,
|
||||
}))
|
||||
.filter((skill) => Boolean(skill.slug));
|
||||
|
||||
try {
|
||||
await apiRequest(
|
||||
params.registry,
|
||||
{
|
||||
method: "POST",
|
||||
path: LegacyApiRoutes.cliTelemetryInstall,
|
||||
token: params.token,
|
||||
body: {
|
||||
roots: [
|
||||
{
|
||||
rootId: rootTelemetryId(params.root),
|
||||
label: formatRootLabel(params.root),
|
||||
skills,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
ApiCliTelemetrySyncResponseSchema,
|
||||
);
|
||||
} catch {
|
||||
// Install telemetry is best-effort; local installs must not fail because
|
||||
// metrics reporting is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
function isTelemetryDisabled() {
|
||||
const raw = process.env.CLAWHUB_DISABLE_TELEMETRY ?? process.env.CLAWDHUB_DISABLE_TELEMETRY;
|
||||
if (!raw) return false;
|
||||
|
||||
@@ -83,6 +83,29 @@ describe("bun http client", () => {
|
||||
expect(postArgs).toContain('{"a":1}');
|
||||
});
|
||||
|
||||
it("parses explicitly accepted non-2xx json responses via curl", async () => {
|
||||
const { client, spawnImpl } = createBunClient({
|
||||
spawnImpl: () => ({
|
||||
status: 0,
|
||||
stdout:
|
||||
'{"ok":false,"message":"GitHub-backed skill changed upstream; waiting for scan."}\n409',
|
||||
stderr: "",
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.apiRequest("https://registry.example", {
|
||||
method: "GET",
|
||||
path: "/v1/skills/demo/install",
|
||||
acceptedStatuses: [409],
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
message: "GitHub-backed skill changed upstream; waiting for scan.",
|
||||
});
|
||||
expect(spawnImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("retries 429 responses and keeps 404 non-retryable", async () => {
|
||||
const rateLimited = createBunClient({
|
||||
spawnImpl: () => ({ status: 0, stdout: "rate limited\n429", stderr: "" }),
|
||||
|
||||
@@ -139,6 +139,29 @@ describe("node http client", () => {
|
||||
expect((init.headers as Record<string, string>)["Content-Type"]).toBe("application/json");
|
||||
});
|
||||
|
||||
it("parses explicitly accepted non-2xx json responses", async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 409,
|
||||
json: async () => ({
|
||||
ok: false,
|
||||
message: "GitHub-backed skill changed upstream; waiting for scan.",
|
||||
}),
|
||||
});
|
||||
const client = createNodeClient({ fetchImpl: fetchImpl as unknown as typeof fetch });
|
||||
|
||||
await expect(
|
||||
client.apiRequest("https://example.com", {
|
||||
method: "GET",
|
||||
path: "/api/v1/skills/demo/install",
|
||||
acceptedStatuses: [409],
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
message: "GitHub-backed skill changed upstream; waiting for scan.",
|
||||
});
|
||||
});
|
||||
|
||||
it("includes rate-limit guidance from response headers on 429", async () => {
|
||||
const { setTimeoutImpl, clearTimeoutImpl } = createImmediateTimeouts();
|
||||
const fetchImpl = vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -38,6 +38,7 @@ type RequestArgs =
|
||||
token?: string;
|
||||
body?: unknown;
|
||||
retryCount?: number;
|
||||
acceptedStatuses?: number[];
|
||||
}
|
||||
| {
|
||||
method: "GET" | "POST" | "DELETE";
|
||||
@@ -45,6 +46,7 @@ type RequestArgs =
|
||||
token?: string;
|
||||
body?: unknown;
|
||||
retryCount?: number;
|
||||
acceptedStatuses?: number[];
|
||||
};
|
||||
|
||||
type FormRequestArgs =
|
||||
@@ -180,7 +182,7 @@ export function createHttpClient(options: HttpClientOptions = {}): HttpClient {
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (!response.ok && !isAcceptedStatus(response.status, args.acceptedStatuses)) {
|
||||
throwHttpStatusError(
|
||||
response.status,
|
||||
await readResponseTextSafe(response),
|
||||
@@ -576,6 +578,10 @@ function cleanUserFacingErrorMessage(message: string) {
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
function isAcceptedStatus(status: number, acceptedStatuses: number[] | undefined) {
|
||||
return acceptedStatuses?.includes(status) ?? false;
|
||||
}
|
||||
|
||||
function isTransientConvexContention(text: string) {
|
||||
const lowered = text.toLowerCase();
|
||||
return (
|
||||
@@ -689,7 +695,7 @@ async function fetchJsonViaCurl(
|
||||
throw new Error(result.stderr || "curl failed");
|
||||
}
|
||||
const { body, status, headers: responseHeaders } = parseCurlBodyAndMeta(result.stdout ?? "");
|
||||
if (status < 200 || status >= 300) {
|
||||
if ((status < 200 || status >= 300) && !isAcceptedStatus(status, args.acceptedStatuses)) {
|
||||
throwHttpStatusError(status, body, responseHeaders, deps.now);
|
||||
}
|
||||
return JSON.parse(body || "null") as unknown;
|
||||
|
||||
@@ -6,6 +6,7 @@ export const LegacyApiRoutes = {
|
||||
cliWhoami: "/api/cli/whoami",
|
||||
cliUploadUrl: "/api/cli/upload-url",
|
||||
cliPublish: "/api/cli/publish",
|
||||
cliTelemetryInstall: "/api/cli/telemetry/install",
|
||||
cliTelemetrySync: "/api/cli/telemetry/sync",
|
||||
cliSkillDelete: "/api/cli/skill/delete",
|
||||
cliSkillUndelete: "/api/cli/skill/undelete",
|
||||
|
||||
@@ -120,6 +120,38 @@ export const ApiSkillResolveResponseSchema = type({
|
||||
latestVersion: type({ version: "string" }).or("null"),
|
||||
});
|
||||
|
||||
export const ApiV1SkillInstallResolveResponseSchema = type({
|
||||
ok: "true",
|
||||
slug: "string",
|
||||
installKind: '"archive"',
|
||||
archive: {
|
||||
version: "string",
|
||||
downloadUrl: "string",
|
||||
},
|
||||
})
|
||||
.or({
|
||||
ok: "true",
|
||||
slug: "string",
|
||||
installKind: '"github"',
|
||||
github: {
|
||||
repo: "string",
|
||||
path: "string",
|
||||
commit: "string",
|
||||
contentHash: "string",
|
||||
sourceUrl: "string",
|
||||
},
|
||||
})
|
||||
.or({
|
||||
ok: "false",
|
||||
slug: "string",
|
||||
reason:
|
||||
'"archive_version_missing"|"github_source_missing"|"github_upstream_removed"|"github_upstream_missing"|"github_upstream_unknown"|"github_verification_pending"|"github_scan_failed"',
|
||||
message: "string",
|
||||
status: "number",
|
||||
});
|
||||
export type ApiV1SkillInstallResolveResponse =
|
||||
(typeof ApiV1SkillInstallResolveResponseSchema)[inferred];
|
||||
|
||||
export const CliTelemetrySyncRequestSchema = type({
|
||||
roots: type({
|
||||
rootId: "string",
|
||||
@@ -194,6 +226,36 @@ export const ApiV1PublisherRemoveMemberResponseSchema = type({
|
||||
export type ApiV1PublisherRemoveMemberResponse =
|
||||
(typeof ApiV1PublisherRemoveMemberResponseSchema)[inferred];
|
||||
|
||||
export const ApiV1OfficialPublisherListResponseSchema = type({
|
||||
ok: "true",
|
||||
items: type({
|
||||
officialPublisherId: "string",
|
||||
publisherId: "string",
|
||||
handle: "string|null",
|
||||
displayName: "string|null",
|
||||
kind: '"user"|"org"|null',
|
||||
active: "boolean",
|
||||
reason: "string|null",
|
||||
createdByUserId: "string|null",
|
||||
createdByHandle: "string|null",
|
||||
createdAt: "number",
|
||||
updatedAt: "number",
|
||||
}).array(),
|
||||
});
|
||||
export type ApiV1OfficialPublisherListResponse =
|
||||
(typeof ApiV1OfficialPublisherListResponseSchema)[inferred];
|
||||
|
||||
export const ApiV1OfficialPublisherUpdateResponseSchema = type({
|
||||
ok: "true",
|
||||
publisherId: "string",
|
||||
handle: "string",
|
||||
"added?": "boolean",
|
||||
"removed?": "boolean",
|
||||
"officialPublisherId?": "string",
|
||||
});
|
||||
export type ApiV1OfficialPublisherUpdateResponse =
|
||||
(typeof ApiV1OfficialPublisherUpdateResponseSchema)[inferred];
|
||||
|
||||
export const ApiV1SearchResponseSchema = type({
|
||||
results: type({
|
||||
slug: "string?",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { describe, expect, it } from "vitest";
|
||||
import type { SkillOrigin } from "./skills";
|
||||
import {
|
||||
buildSkillFingerprint,
|
||||
extractGitHubZipPathToDir,
|
||||
extractZipToDir,
|
||||
hashSkillFiles,
|
||||
hashSkillZip,
|
||||
@@ -35,6 +36,40 @@ describe("skills", () => {
|
||||
await expect(stat(join(parent, evilName))).rejects.toBeTruthy();
|
||||
});
|
||||
|
||||
it("extracts only the resolved GitHub skill folder from a repo zip", async () => {
|
||||
const parent = await mkdtemp(join(tmpdir(), "clawhub-github-zip-"));
|
||||
const dir = join(parent, "skill");
|
||||
const zip = zipSync({
|
||||
"skills-main/README.md": strToU8("repo readme"),
|
||||
"skills-main/skills/aiq-deploy/SKILL.md": strToU8("# AIQ Deploy"),
|
||||
"skills-main/skills/aiq-deploy/references/install.md": strToU8("install"),
|
||||
"skills-main/skills/other/SKILL.md": strToU8("# Other"),
|
||||
});
|
||||
|
||||
await extractGitHubZipPathToDir(new Uint8Array(zip), dir, "skills/aiq-deploy");
|
||||
|
||||
expect((await readFile(join(dir, "SKILL.md"), "utf8")).trim()).toBe("# AIQ Deploy");
|
||||
expect((await readFile(join(dir, "references/install.md"), "utf8")).trim()).toBe("install");
|
||||
await expect(stat(join(dir, "README.md"))).rejects.toBeTruthy();
|
||||
await expect(stat(join(dir, "skills/other/SKILL.md"))).rejects.toBeTruthy();
|
||||
});
|
||||
|
||||
it("preserves GitHub skill filenames containing dot-dot text", async () => {
|
||||
const parent = await mkdtemp(join(tmpdir(), "clawhub-github-zip-"));
|
||||
const dir = join(parent, "skill");
|
||||
const zip = zipSync({
|
||||
"skills-main/skills/aiq-deploy/SKILL.md": strToU8("# AIQ Deploy"),
|
||||
"skills-main/skills/aiq-deploy/payload..sh": strToU8("echo safe"),
|
||||
"skills-main/skills/aiq-deploy/../payload.sh": strToU8("echo unsafe"),
|
||||
});
|
||||
|
||||
await extractGitHubZipPathToDir(new Uint8Array(zip), dir, "skills/aiq-deploy");
|
||||
|
||||
expect((await readFile(join(dir, "payload..sh"), "utf8")).trim()).toBe("echo safe");
|
||||
await expect(stat(join(parent, "payload.sh"))).rejects.toBeTruthy();
|
||||
await expect(stat(join(dir, "payload.sh"))).rejects.toBeTruthy();
|
||||
});
|
||||
|
||||
it("writes and reads lockfile", async () => {
|
||||
const workdir = await mkdtemp(join(tmpdir(), "clawhub-work-"));
|
||||
await writeLockfile(workdir, {
|
||||
|
||||
@@ -38,6 +38,37 @@ export async function extractZipToDir(zipBytes: Uint8Array, targetDir: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function extractGitHubZipPathToDir(
|
||||
zipBytes: Uint8Array,
|
||||
targetDir: string,
|
||||
sourcePath: string,
|
||||
) {
|
||||
const entries = unzipSync(zipBytes);
|
||||
const normalizedSourcePath = normalizeGitHubSourcePath(sourcePath);
|
||||
let wroteFile = false;
|
||||
|
||||
await mkdir(targetDir, { recursive: true });
|
||||
for (const [rawPath, data] of Object.entries(entries)) {
|
||||
const safeZipPath = sanitizeRelPath(rawPath);
|
||||
if (!safeZipPath) continue;
|
||||
const repoRelativePath = stripGitHubZipRoot(safeZipPath);
|
||||
if (repoRelativePath === null) continue;
|
||||
const targetRelativePath = getGitHubSourceRelativePath(repoRelativePath, normalizedSourcePath);
|
||||
if (!targetRelativePath) continue;
|
||||
const safeTargetPath = sanitizeRelPath(targetRelativePath);
|
||||
if (!safeTargetPath) continue;
|
||||
|
||||
const outPath = join(targetDir, safeTargetPath);
|
||||
await mkdir(dirname(outPath), { recursive: true });
|
||||
await writeFile(outPath, data);
|
||||
wroteFile = true;
|
||||
}
|
||||
|
||||
if (!wroteFile) {
|
||||
throw new Error(`GitHub zip did not contain ${sourcePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listTextFiles(root: string) {
|
||||
const files: Array<{ relPath: string; bytes: Uint8Array; contentType?: string }> = [];
|
||||
const absRoot = resolve(root);
|
||||
@@ -200,7 +231,9 @@ function isLikelyTextBytes(bytes: Uint8Array) {
|
||||
function sanitizeRelPath(path: string) {
|
||||
const normalized = path.replace(/^\.\/+/, "").replace(/^\/+/, "");
|
||||
if (!normalized || normalized.endsWith("/")) return null;
|
||||
if (normalized.includes("..") || normalized.includes("\\")) return null;
|
||||
if (normalized.includes("\\")) return null;
|
||||
const segments = normalized.split("/");
|
||||
if (segments.some((segment) => !segment || segment === "." || segment === "..")) return null;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@@ -208,6 +241,23 @@ function sanitizeZipPath(path: string) {
|
||||
return sanitizeRelPath(path);
|
||||
}
|
||||
|
||||
function normalizeGitHubSourcePath(path: string) {
|
||||
return path.replace(/^\.\/+/, "").replace(/^\/+|\/+$/g, "");
|
||||
}
|
||||
|
||||
function stripGitHubZipRoot(path: string) {
|
||||
const slash = path.indexOf("/");
|
||||
if (slash < 0) return null;
|
||||
return path.slice(slash + 1);
|
||||
}
|
||||
|
||||
function getGitHubSourceRelativePath(repoRelativePath: string, sourcePath: string) {
|
||||
if (!sourcePath) return repoRelativePath;
|
||||
if (repoRelativePath === sourcePath) return null;
|
||||
if (!repoRelativePath.startsWith(`${sourcePath}/`)) return null;
|
||||
return repoRelativePath.slice(sourcePath.length + 1);
|
||||
}
|
||||
|
||||
async function walk(dir: string, onFile: (path: string) => Promise<void>) {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
|
||||
Vendored
+8
-8
@@ -79,9 +79,9 @@ export declare const PackageAppealFinalActionSchema: import("arktype/internal/va
|
||||
export type PackageAppealFinalAction = (typeof PackageAppealFinalActionSchema)[inferred];
|
||||
export declare const PackageAppealListStatusSchema: import("arktype/internal/variants/string.ts").StringType<"open" | "all" | "accepted" | "rejected", {}>;
|
||||
export type PackageAppealListStatus = (typeof PackageAppealListStatusSchema)[inferred];
|
||||
export declare const PackageOfficialMigrationPhaseSchema: import("arktype/internal/variants/string.ts").StringType<"planned" | "published" | "clawpack-ready" | "legacy-zip-only" | "metadata-ready" | "blocked" | "ready-for-openclaw", {}>;
|
||||
export declare const PackageOfficialMigrationPhaseSchema: import("arktype/internal/variants/string.ts").StringType<"published" | "planned" | "clawpack-ready" | "legacy-zip-only" | "metadata-ready" | "blocked" | "ready-for-openclaw", {}>;
|
||||
export type PackageOfficialMigrationPhase = (typeof PackageOfficialMigrationPhaseSchema)[inferred];
|
||||
export declare const PackageOfficialMigrationListPhaseSchema: import("arktype/internal/variants/string.ts").StringType<"all" | "planned" | "published" | "clawpack-ready" | "legacy-zip-only" | "metadata-ready" | "blocked" | "ready-for-openclaw", {}>;
|
||||
export declare const PackageOfficialMigrationListPhaseSchema: import("arktype/internal/variants/string.ts").StringType<"all" | "published" | "planned" | "clawpack-ready" | "legacy-zip-only" | "metadata-ready" | "blocked" | "ready-for-openclaw", {}>;
|
||||
export type PackageOfficialMigrationListPhase = (typeof PackageOfficialMigrationListPhaseSchema)[inferred];
|
||||
export declare const PackageArtifactSummarySchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
kind: "legacy-zip" | "npm-pack";
|
||||
@@ -816,7 +816,7 @@ export type ApiV1PackageArtifactBackfillResponse = (typeof ApiV1PackageArtifactB
|
||||
export declare const PackageReadinessCheckSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
id: string;
|
||||
label: string;
|
||||
status: "warn" | "pass" | "fail";
|
||||
status: "warn" | "fail" | "pass";
|
||||
message: string;
|
||||
}, {}>;
|
||||
export type PackageReadinessCheck = (typeof PackageReadinessCheckSchema)[inferred];
|
||||
@@ -832,7 +832,7 @@ export declare const ApiV1PackageReadinessResponseSchema: import("arktype/intern
|
||||
checks: {
|
||||
id: string;
|
||||
label: string;
|
||||
status: "warn" | "pass" | "fail";
|
||||
status: "warn" | "fail" | "pass";
|
||||
message: string;
|
||||
}[];
|
||||
blockers: string[];
|
||||
@@ -917,7 +917,7 @@ export declare const PackageOfficialMigrationUpsertRequestSchema: import("arktyp
|
||||
sourceRepo?: string | undefined;
|
||||
sourcePath?: string | undefined;
|
||||
sourceCommit?: string | undefined;
|
||||
phase?: "planned" | "published" | "clawpack-ready" | "legacy-zip-only" | "metadata-ready" | "blocked" | "ready-for-openclaw" | undefined;
|
||||
phase?: "published" | "planned" | "clawpack-ready" | "legacy-zip-only" | "metadata-ready" | "blocked" | "ready-for-openclaw" | undefined;
|
||||
blockers?: string[] | undefined;
|
||||
hostTargetsComplete?: boolean | undefined;
|
||||
scanClean?: boolean | undefined;
|
||||
@@ -930,7 +930,7 @@ export declare const PackageOfficialMigrationItemSchema: import("arktype/interna
|
||||
migrationId: string;
|
||||
bundledPluginId: string;
|
||||
packageName: string;
|
||||
phase: "planned" | "published" | "clawpack-ready" | "legacy-zip-only" | "metadata-ready" | "blocked" | "ready-for-openclaw";
|
||||
phase: "published" | "planned" | "clawpack-ready" | "legacy-zip-only" | "metadata-ready" | "blocked" | "ready-for-openclaw";
|
||||
blockers: string[];
|
||||
hostTargetsComplete: boolean;
|
||||
scanClean: boolean;
|
||||
@@ -951,7 +951,7 @@ export declare const ApiV1PackageOfficialMigrationListResponseSchema: import("ar
|
||||
migrationId: string;
|
||||
bundledPluginId: string;
|
||||
packageName: string;
|
||||
phase: "planned" | "published" | "clawpack-ready" | "legacy-zip-only" | "metadata-ready" | "blocked" | "ready-for-openclaw";
|
||||
phase: "published" | "planned" | "clawpack-ready" | "legacy-zip-only" | "metadata-ready" | "blocked" | "ready-for-openclaw";
|
||||
blockers: string[];
|
||||
hostTargetsComplete: boolean;
|
||||
scanClean: boolean;
|
||||
@@ -976,7 +976,7 @@ export declare const ApiV1PackageOfficialMigrationResponseSchema: import("arktyp
|
||||
migrationId: string;
|
||||
bundledPluginId: string;
|
||||
packageName: string;
|
||||
phase: "planned" | "published" | "clawpack-ready" | "legacy-zip-only" | "metadata-ready" | "blocked" | "ready-for-openclaw";
|
||||
phase: "published" | "planned" | "clawpack-ready" | "legacy-zip-only" | "metadata-ready" | "blocked" | "ready-for-openclaw";
|
||||
blockers: string[];
|
||||
hostTargetsComplete: boolean;
|
||||
scanClean: boolean;
|
||||
|
||||
Vendored
+1
@@ -6,6 +6,7 @@ export declare const LegacyApiRoutes: {
|
||||
readonly cliWhoami: "/api/cli/whoami";
|
||||
readonly cliUploadUrl: "/api/cli/upload-url";
|
||||
readonly cliPublish: "/api/cli/publish";
|
||||
readonly cliTelemetryInstall: "/api/cli/telemetry/install";
|
||||
readonly cliTelemetrySync: "/api/cli/telemetry/sync";
|
||||
readonly cliSkillDelete: "/api/cli/skill/delete";
|
||||
readonly cliSkillUndelete: "/api/cli/skill/undelete";
|
||||
|
||||
Vendored
+1
@@ -6,6 +6,7 @@ export const LegacyApiRoutes = {
|
||||
cliWhoami: "/api/cli/whoami",
|
||||
cliUploadUrl: "/api/cli/upload-url",
|
||||
cliPublish: "/api/cli/publish",
|
||||
cliTelemetryInstall: "/api/cli/telemetry/install",
|
||||
cliTelemetrySync: "/api/cli/telemetry/sync",
|
||||
cliSkillDelete: "/api/cli/skill/delete",
|
||||
cliSkillUndelete: "/api/cli/skill/undelete",
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;IAC9B,gBAAgB,EAAE,yBAAyB;IAC3C,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAC;AAEX,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,gBAAgB,EAAE,4BAA4B;IAC9C,MAAM,EAAE,gBAAgB;IACxB,UAAU,EAAE,uBAAuB;IACnC,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,WAAW,EAAE,sBAAsB;IACnC,aAAa,EAAE,wBAAwB;IACvC,KAAK,EAAE,eAAe;IACtB,SAAS,EAAE,mBAAmB;IAC9B,UAAU,EAAE,oBAAoB;IAChC,KAAK,EAAE,eAAe;IACtB,KAAK,EAAE,eAAe;IACtB,MAAM,EAAE,gBAAgB;IACxB,YAAY,EAAE,uBAAuB;CAC7B,CAAC"}
|
||||
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;IAC9B,mBAAmB,EAAE,4BAA4B;IACjD,gBAAgB,EAAE,yBAAyB;IAC3C,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAC;AAEX,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,gBAAgB,EAAE,4BAA4B;IAC9C,MAAM,EAAE,gBAAgB;IACxB,UAAU,EAAE,uBAAuB;IACnC,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,WAAW,EAAE,sBAAsB;IACnC,aAAa,EAAE,wBAAwB;IACvC,KAAK,EAAE,eAAe;IACtB,SAAS,EAAE,mBAAmB;IAC9B,UAAU,EAAE,oBAAoB;IAChC,KAAK,EAAE,eAAe;IACtB,KAAK,EAAE,eAAe;IACtB,MAAM,EAAE,gBAAgB;IACxB,YAAY,EAAE,uBAAuB;CAC7B,CAAC"}
|
||||
Vendored
+27
@@ -122,6 +122,33 @@ export declare const ApiSkillResolveResponseSchema: import("arktype/internal/var
|
||||
version: string;
|
||||
} | null;
|
||||
}, {}>;
|
||||
export declare const ApiV1SkillInstallResolveResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
ok: true;
|
||||
slug: string;
|
||||
installKind: "archive";
|
||||
archive: {
|
||||
version: string;
|
||||
downloadUrl: string;
|
||||
};
|
||||
} | {
|
||||
ok: true;
|
||||
slug: string;
|
||||
installKind: "github";
|
||||
github: {
|
||||
repo: string;
|
||||
path: string;
|
||||
commit: string;
|
||||
contentHash: string;
|
||||
sourceUrl: string;
|
||||
};
|
||||
} | {
|
||||
ok: false;
|
||||
slug: string;
|
||||
reason: "archive_version_missing" | "github_source_missing" | "github_upstream_removed" | "github_upstream_missing" | "github_upstream_unknown" | "github_verification_pending" | "github_scan_failed";
|
||||
message: string;
|
||||
status: number;
|
||||
}, {}>;
|
||||
export type ApiV1SkillInstallResolveResponse = (typeof ApiV1SkillInstallResolveResponseSchema)[inferred];
|
||||
export declare const CliTelemetrySyncRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
roots: {
|
||||
rootId: string;
|
||||
|
||||
Vendored
+28
@@ -99,6 +99,34 @@ export const ApiSkillResolveResponseSchema = type({
|
||||
match: type({ version: "string" }).or("null"),
|
||||
latestVersion: type({ version: "string" }).or("null"),
|
||||
});
|
||||
export const ApiV1SkillInstallResolveResponseSchema = type({
|
||||
ok: "true",
|
||||
slug: "string",
|
||||
installKind: '"archive"',
|
||||
archive: {
|
||||
version: "string",
|
||||
downloadUrl: "string",
|
||||
},
|
||||
})
|
||||
.or({
|
||||
ok: "true",
|
||||
slug: "string",
|
||||
installKind: '"github"',
|
||||
github: {
|
||||
repo: "string",
|
||||
path: "string",
|
||||
commit: "string",
|
||||
contentHash: "string",
|
||||
sourceUrl: "string",
|
||||
},
|
||||
})
|
||||
.or({
|
||||
ok: "false",
|
||||
slug: "string",
|
||||
reason: '"archive_version_missing"|"github_source_missing"|"github_upstream_removed"|"github_upstream_missing"|"github_upstream_unknown"|"github_verification_pending"|"github_scan_failed"',
|
||||
message: "string",
|
||||
status: "number",
|
||||
});
|
||||
export const CliTelemetrySyncRequestSchema = type({
|
||||
roots: type({
|
||||
rootId: "string",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user