mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-15 09:22:08 +00:00
Compare commits
51
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a350d953c | ||
|
|
3fb3150ee2 | ||
|
|
7dbc0fc3bb | ||
|
|
fc4f8644eb | ||
|
|
dda6d55fbf | ||
|
|
e014759b40 | ||
|
|
2186c41c48 | ||
|
|
c30c182478 | ||
|
|
3080567964 | ||
|
|
f304541561 | ||
|
|
91224ada13 | ||
|
|
d5fbaeef81 | ||
|
|
3706018b72 | ||
|
|
62e616f635 | ||
|
|
9013d324c8 | ||
|
|
c1363ec8d0 | ||
|
|
7e09196f92 | ||
|
|
807043b4b0 | ||
|
|
972fe35935 | ||
|
|
16ee540f5d | ||
|
|
f541882d55 | ||
|
|
95bc156747 | ||
|
|
bf7422022f | ||
|
|
230e5b91f8 | ||
|
|
70dcf21e37 | ||
|
|
932a1fb30c | ||
|
|
44fe60b701 | ||
|
|
1c057ca9b9 | ||
|
|
9f793d1336 | ||
|
|
aa9295bea9 | ||
|
|
370eea4977 | ||
|
|
59e6819020 | ||
|
|
52b633f4e9 | ||
|
|
6b1e6ca1c9 | ||
|
|
6c112eccb7 | ||
|
|
f9e9effcdd | ||
|
|
b5d2d0fefa | ||
|
|
48d0fc91f3 | ||
|
|
fefb2340a8 | ||
|
|
22287558b9 | ||
|
|
f6ce8f9e1e | ||
|
|
b5cdee50a9 | ||
|
|
b5fdee1c13 | ||
|
|
2530beaf51 | ||
|
|
c627b202f7 | ||
|
|
5cbeb54c8e | ||
|
|
eb8136b7fb | ||
|
|
a166c95eb0 | ||
|
|
86dc196e9b | ||
|
|
649c14a43f | ||
|
|
7cbf0434b0 |
@@ -15,7 +15,6 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
env:
|
||||
CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }}
|
||||
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
|
||||
PLAYWRIGHT_AUTH_STORAGE_STATE_JSON: ${{ secrets.PLAYWRIGHT_AUTH_STORAGE_STATE_JSON }}
|
||||
steps:
|
||||
- name: Check deploy secrets
|
||||
@@ -26,10 +25,6 @@ jobs:
|
||||
missing+=("CONVEX_DEPLOY_KEY")
|
||||
fi
|
||||
|
||||
if [[ -z "$VERCEL_TOKEN" ]]; then
|
||||
missing+=("VERCEL_TOKEN")
|
||||
fi
|
||||
|
||||
if (( ${#missing[@]} > 0 )); then
|
||||
echo "::error::Missing required GitHub Actions secrets: ${missing[*]}"
|
||||
exit 1
|
||||
@@ -67,27 +62,47 @@ jobs:
|
||||
- name: Verify Convex contract
|
||||
run: bun run verify:convex-contract -- --prod
|
||||
|
||||
deploy-web:
|
||||
wait-vercel-production:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
needs:
|
||||
- preflight-secrets
|
||||
- deploy-convex
|
||||
env:
|
||||
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
|
||||
VITE_APP_BUILD_SHA: ${{ github.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Wait for Vercel production deployment
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
VERCEL_STATUS_CONTEXT: Vercel – clawhub
|
||||
run: |
|
||||
for attempt in {1..90}; do
|
||||
state="$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/status" \
|
||||
--jq '.statuses[] | select(.context == env.VERCEL_STATUS_CONTEXT) | .state' \
|
||||
2>/dev/null | head -n1)"
|
||||
|
||||
- uses: oven-sh/setup-bun@e3914758a49697077f7bcd190d36582a61667aad
|
||||
with:
|
||||
bun-version: 1.3.10
|
||||
case "$state" in
|
||||
success)
|
||||
echo "Vercel production deployment ready for $GITHUB_SHA"
|
||||
exit 0
|
||||
;;
|
||||
failure|error)
|
||||
echo "::error::Vercel production deployment failed for $GITHUB_SHA"
|
||||
exit 1
|
||||
;;
|
||||
pending)
|
||||
echo "Vercel deployment pending for $GITHUB_SHA; waiting..."
|
||||
;;
|
||||
*)
|
||||
echo "Vercel status for $GITHUB_SHA not published yet; waiting..."
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Pull Vercel config
|
||||
run: bunx vercel pull --yes --environment=production --token "$VERCEL_TOKEN"
|
||||
sleep 10
|
||||
done
|
||||
|
||||
- name: Deploy Vercel app
|
||||
run: bunx vercel deploy --yes --prod --token "$VERCEL_TOKEN"
|
||||
echo "::error::Timed out waiting for Vercel production deployment for $GITHUB_SHA"
|
||||
exit 1
|
||||
|
||||
smoke-production:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -95,7 +110,7 @@ jobs:
|
||||
needs:
|
||||
- preflight-secrets
|
||||
- deploy-convex
|
||||
- deploy-web
|
||||
- wait-vercel-production
|
||||
env:
|
||||
PLAYWRIGHT_BASE_URL: https://clawhub.ai
|
||||
steps:
|
||||
|
||||
@@ -62,7 +62,7 @@ Common CLI flows:
|
||||
- Manage local installs: `clawhub install <slug>`, `clawhub uninstall <slug>`, `clawhub list`, `clawhub update --all`
|
||||
- Inspect without installing: `clawhub inspect <slug>`
|
||||
- Publish/sync: `clawhub publish <path>`, `clawhub sync`
|
||||
- Publish plugins: `clawhub package publish <path> --source-repo <owner/repo> --source-commit <sha>`
|
||||
- Publish plugins: `clawhub package publish <path> [--owner <handle>] --source-repo <owner/repo> --source-commit <sha>`
|
||||
- Canonicalize owned skills: `clawhub skill rename <slug> <new-slug>`, `clawhub skill merge <source> <target>`
|
||||
|
||||
Docs: [`docs/quickstart.md`](docs/quickstart.md), [`docs/cli.md`](docs/cli.md).
|
||||
|
||||
Vendored
+10
@@ -66,7 +66,10 @@ import type * as lib_moderationReasonCodes from "../lib/moderationReasonCodes.js
|
||||
import type * as lib_openaiResponse from "../lib/openaiResponse.js";
|
||||
import type * as lib_packageRegistry from "../lib/packageRegistry.js";
|
||||
import type * as lib_packageSearchDigest from "../lib/packageSearchDigest.js";
|
||||
import type * as lib_packageSecurity from "../lib/packageSecurity.js";
|
||||
import type * as lib_public from "../lib/public.js";
|
||||
import type * as lib_publishLimits from "../lib/publishLimits.js";
|
||||
import type * as lib_publishers from "../lib/publishers.js";
|
||||
import type * as lib_reporting from "../lib/reporting.js";
|
||||
import type * as lib_reservedHandles from "../lib/reservedHandles.js";
|
||||
import type * as lib_reservedSlugs from "../lib/reservedSlugs.js";
|
||||
@@ -83,12 +86,14 @@ import type * as lib_skillZip from "../lib/skillZip.js";
|
||||
import type * as lib_skills from "../lib/skills.js";
|
||||
import type * as lib_soulChangelog from "../lib/soulChangelog.js";
|
||||
import type * as lib_soulPublish from "../lib/soulPublish.js";
|
||||
import type * as lib_staticPublishScan from "../lib/staticPublishScan.js";
|
||||
import type * as lib_tokens from "../lib/tokens.js";
|
||||
import type * as lib_userSearch from "../lib/userSearch.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 packages from "../packages.js";
|
||||
import type * as publishers from "../publishers.js";
|
||||
import type * as rateLimits from "../rateLimits.js";
|
||||
import type * as search from "../search.js";
|
||||
import type * as seed from "../seed.js";
|
||||
@@ -174,7 +179,10 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/openaiResponse": typeof lib_openaiResponse;
|
||||
"lib/packageRegistry": typeof lib_packageRegistry;
|
||||
"lib/packageSearchDigest": typeof lib_packageSearchDigest;
|
||||
"lib/packageSecurity": typeof lib_packageSecurity;
|
||||
"lib/public": typeof lib_public;
|
||||
"lib/publishLimits": typeof lib_publishLimits;
|
||||
"lib/publishers": typeof lib_publishers;
|
||||
"lib/reporting": typeof lib_reporting;
|
||||
"lib/reservedHandles": typeof lib_reservedHandles;
|
||||
"lib/reservedSlugs": typeof lib_reservedSlugs;
|
||||
@@ -191,12 +199,14 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/skills": typeof lib_skills;
|
||||
"lib/soulChangelog": typeof lib_soulChangelog;
|
||||
"lib/soulPublish": typeof lib_soulPublish;
|
||||
"lib/staticPublishScan": typeof lib_staticPublishScan;
|
||||
"lib/tokens": typeof lib_tokens;
|
||||
"lib/userSearch": typeof lib_userSearch;
|
||||
"lib/webhooks": typeof lib_webhooks;
|
||||
llmEval: typeof llmEval;
|
||||
maintenance: typeof maintenance;
|
||||
packages: typeof packages;
|
||||
publishers: typeof publishers;
|
||||
rateLimits: typeof rateLimits;
|
||||
search: typeof search;
|
||||
seed: typeof seed;
|
||||
|
||||
@@ -86,6 +86,9 @@ export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
|
||||
async afterUserCreatedOrUpdated(ctx, args) {
|
||||
const user = await ctx.db.get(args.userId);
|
||||
await handleDeletedUserSignIn(ctx, args, user);
|
||||
await ctx.scheduler.runAfter(0, internal.publishers.ensurePersonalPublisherInternal, {
|
||||
userId: args.userId,
|
||||
});
|
||||
|
||||
// Schedule GitHub profile sync to handle username renames (fixes #303)
|
||||
// This runs as a background action so it doesn't block sign-in
|
||||
|
||||
@@ -58,6 +58,13 @@ crons.interval("vt-cache-backfill", { minutes: 30 }, internal.vt.backfillActiveS
|
||||
batchSize: 100,
|
||||
});
|
||||
|
||||
crons.interval(
|
||||
"package-scan-backfill",
|
||||
{ minutes: 30 },
|
||||
internal.packages.backfillPackageReleaseScansInternal,
|
||||
{ batchSize: 100 },
|
||||
);
|
||||
|
||||
// Daily re-scan of all active skills at 3am UTC
|
||||
crons.daily("vt-daily-rescan", { hourUTC: 3, minuteUTC: 0 }, internal.vt.rescanActiveSkills, {});
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { internal } from "./_generated/api";
|
||||
import {
|
||||
repointPackageLatestRelease,
|
||||
scheduleOwnerPublisherDigestSync,
|
||||
syncPackageSearchDigestForPackageId,
|
||||
syncPackageSearchDigestsForOwnerUserId,
|
||||
} from "./functions";
|
||||
@@ -501,3 +503,35 @@ describe("package digest sync", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("publisher digest scheduling", () => {
|
||||
it("schedules package and skill digest sync in separate background mutations", async () => {
|
||||
const ctx = {
|
||||
scheduler: {
|
||||
runAfter: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
};
|
||||
|
||||
await scheduleOwnerPublisherDigestSync(ctx as never, "publishers:demo" as never);
|
||||
|
||||
expect(ctx.scheduler.runAfter).toHaveBeenCalledTimes(2);
|
||||
expect(ctx.scheduler.runAfter).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
0,
|
||||
internal.functions.syncPackageSearchDigestsForOwnerPublisherIdInternal,
|
||||
{ ownerPublisherId: "publishers:demo" },
|
||||
);
|
||||
expect(ctx.scheduler.runAfter).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
0,
|
||||
internal.functions.syncSkillSearchDigestsForOwnerPublisherIdInternal,
|
||||
{ ownerPublisherId: "publishers:demo" },
|
||||
);
|
||||
});
|
||||
|
||||
it("skips scheduling when the trigger context has no scheduler", async () => {
|
||||
await expect(
|
||||
scheduleOwnerPublisherDigestSync({} as never, "publishers:demo" as never),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
+145
-34
@@ -1,6 +1,8 @@
|
||||
import { customCtx, customMutation } from "convex-helpers/server/customFunctions";
|
||||
import { Triggers } from "convex-helpers/server/triggers";
|
||||
import { v } from "convex/values";
|
||||
import semver from "semver";
|
||||
import { internal } from "./_generated/api";
|
||||
import type { DataModel, Doc, Id } from "./_generated/dataModel";
|
||||
import {
|
||||
mutation as rawMutation,
|
||||
@@ -17,11 +19,20 @@ import {
|
||||
extractPackageDigestFields,
|
||||
upsertPackageSearchDigest,
|
||||
} from "./lib/packageSearchDigest";
|
||||
import { getOwnerPublisher } from "./lib/publishers";
|
||||
import { extractDigestFields, upsertSkillSearchDigest } from "./lib/skillSearchDigest";
|
||||
|
||||
const triggers = new Triggers<DataModel>();
|
||||
|
||||
function isMissingTableError(error: unknown, table: string) {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
new RegExp(`unexpected (query )?table:? ${table}`, "i").test(error.message)
|
||||
);
|
||||
}
|
||||
|
||||
type PackageDigestSyncCtx = Pick<MutationCtx, "db">;
|
||||
type OwnerPublisherDigestScheduleCtx = Pick<Partial<MutationCtx>, "scheduler">;
|
||||
type LatestPackageRelease = Pick<
|
||||
Doc<"packageReleases">,
|
||||
| "_id"
|
||||
@@ -33,7 +44,9 @@ type LatestPackageRelease = Pick<
|
||||
| "capabilities"
|
||||
| "verification"
|
||||
| "distTags"
|
||||
>;
|
||||
> & {
|
||||
scanStatus?: Doc<"packages">["scanStatus"];
|
||||
};
|
||||
|
||||
function toPackageLatestVersionSummary(
|
||||
release: LatestPackageRelease | null,
|
||||
@@ -92,6 +105,7 @@ async function getPreferredFallbackPackageRelease(
|
||||
compatibility: release.compatibility,
|
||||
capabilities: release.capabilities,
|
||||
verification: release.verification,
|
||||
scanStatus: release.verification?.scanStatus,
|
||||
distTags: release.distTags,
|
||||
};
|
||||
if (!best || compareFallbackReleases(family, candidate, best) > 0) best = candidate;
|
||||
@@ -108,19 +122,15 @@ async function syncPackageSearchDigest(
|
||||
if (!pkg) return;
|
||||
const latestRelease = pkg.latestReleaseId ? await ctx.db.get(pkg.latestReleaseId) : null;
|
||||
const fields = extractPackageDigestFields(pkg);
|
||||
const owner = await ctx.db.get(pkg.ownerUserId);
|
||||
const owner = await getOwnerPublisher(ctx, {
|
||||
ownerPublisherId: pkg.ownerPublisherId,
|
||||
ownerUserId: pkg.ownerUserId,
|
||||
});
|
||||
await upsertPackageSearchDigest(ctx, {
|
||||
...fields,
|
||||
latestVersion: latestRelease && !latestRelease.softDeletedAt ? latestRelease.version : undefined,
|
||||
ownerHandle:
|
||||
owner &&
|
||||
typeof owner === "object" &&
|
||||
owner &&
|
||||
!("deletedAt" in owner && owner.deletedAt) &&
|
||||
!("deactivatedAt" in owner && owner.deactivatedAt) &&
|
||||
"handle" in owner
|
||||
? ((owner.handle as string | undefined) ?? "")
|
||||
: "",
|
||||
ownerHandle: owner?.handle ?? "",
|
||||
ownerKind: owner?.kind,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -140,19 +150,127 @@ export async function syncPackageSearchDigestsForOwnerUserId(
|
||||
) {
|
||||
if (!ownerUserId) return;
|
||||
let cursor: string | null = null;
|
||||
while (true) {
|
||||
const page = await ctx.db
|
||||
.query("packages")
|
||||
.withIndex("by_owner", (q) => q.eq("ownerUserId", ownerUserId))
|
||||
.paginate({ cursor, numItems: 100 });
|
||||
for (const pkg of page.page) {
|
||||
await syncPackageSearchDigest(ctx, pkg);
|
||||
try {
|
||||
while (true) {
|
||||
const page = await ctx.db
|
||||
.query("packages")
|
||||
.withIndex("by_owner", (q) => q.eq("ownerUserId", ownerUserId))
|
||||
.paginate({ cursor, numItems: 100 });
|
||||
for (const pkg of page.page) {
|
||||
await syncPackageSearchDigest(ctx, pkg);
|
||||
}
|
||||
if (page.isDone) break;
|
||||
cursor = page.continueCursor;
|
||||
}
|
||||
if (page.isDone) break;
|
||||
cursor = page.continueCursor;
|
||||
} catch (error) {
|
||||
if (isMissingTableError(error, "packages")) return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncPackageSearchDigestsForOwnerPublisherId(
|
||||
ctx: PackageDigestSyncCtx,
|
||||
ownerPublisherId: Id<"publishers"> | null | undefined,
|
||||
) {
|
||||
if (!ownerPublisherId) return;
|
||||
let cursor: string | null = null;
|
||||
try {
|
||||
while (true) {
|
||||
const page = await ctx.db
|
||||
.query("packages")
|
||||
.withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", ownerPublisherId))
|
||||
.paginate({ cursor, numItems: 100 });
|
||||
for (const pkg of page.page) {
|
||||
await syncPackageSearchDigest(ctx, pkg);
|
||||
}
|
||||
if (page.isDone) break;
|
||||
cursor = page.continueCursor;
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMissingTableError(error, "packages")) return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function syncSkillSearchDigestForSkill(
|
||||
ctx: PackageDigestSyncCtx,
|
||||
skill: Doc<"skills"> | null | undefined,
|
||||
) {
|
||||
if (!skill) return;
|
||||
const fields = extractDigestFields(skill);
|
||||
const owner = await getOwnerPublisher(ctx, {
|
||||
ownerPublisherId: skill.ownerPublisherId,
|
||||
ownerUserId: skill.ownerUserId,
|
||||
});
|
||||
await upsertSkillSearchDigest(ctx, {
|
||||
...fields,
|
||||
ownerHandle: owner?.handle ?? "",
|
||||
ownerKind: owner?.kind,
|
||||
ownerName: owner?.linkedUserId ? owner.handle : undefined,
|
||||
ownerDisplayName: owner?.displayName,
|
||||
ownerImage: owner?.image,
|
||||
});
|
||||
}
|
||||
|
||||
export async function syncSkillSearchDigestsForOwnerPublisherId(
|
||||
ctx: PackageDigestSyncCtx,
|
||||
ownerPublisherId: Id<"publishers"> | null | undefined,
|
||||
) {
|
||||
if (!ownerPublisherId) return;
|
||||
let cursor: string | null = null;
|
||||
try {
|
||||
while (true) {
|
||||
const page = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", ownerPublisherId))
|
||||
.paginate({ cursor, numItems: 100 });
|
||||
for (const skill of page.page) {
|
||||
await syncSkillSearchDigestForSkill(ctx, skill);
|
||||
}
|
||||
if (page.isDone) break;
|
||||
cursor = page.continueCursor;
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMissingTableError(error, "skills")) return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function scheduleOwnerPublisherDigestSync(
|
||||
ctx: OwnerPublisherDigestScheduleCtx,
|
||||
ownerPublisherId: Id<"publishers"> | null | undefined,
|
||||
) {
|
||||
if (!ownerPublisherId || !ctx.scheduler) return;
|
||||
await ctx.scheduler.runAfter(
|
||||
0,
|
||||
internal.functions.syncPackageSearchDigestsForOwnerPublisherIdInternal,
|
||||
{ ownerPublisherId },
|
||||
);
|
||||
await ctx.scheduler.runAfter(
|
||||
0,
|
||||
internal.functions.syncSkillSearchDigestsForOwnerPublisherIdInternal,
|
||||
{ ownerPublisherId },
|
||||
);
|
||||
}
|
||||
|
||||
export const syncPackageSearchDigestsForOwnerPublisherIdInternal = rawInternalMutation({
|
||||
args: {
|
||||
ownerPublisherId: v.id("publishers"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await syncPackageSearchDigestsForOwnerPublisherId(ctx, args.ownerPublisherId);
|
||||
},
|
||||
});
|
||||
|
||||
export const syncSkillSearchDigestsForOwnerPublisherIdInternal = rawInternalMutation({
|
||||
args: {
|
||||
ownerPublisherId: v.id("publishers"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await syncSkillSearchDigestsForOwnerPublisherId(ctx, args.ownerPublisherId);
|
||||
},
|
||||
});
|
||||
|
||||
export async function repointPackageLatestRelease(
|
||||
ctx: PackageDigestSyncCtx,
|
||||
packageId: Id<"packages"> | null | undefined,
|
||||
@@ -197,6 +315,7 @@ export async function repointPackageLatestRelease(
|
||||
patch.compatibility = nextLatest?.compatibility;
|
||||
patch.capabilities = nextLatest?.capabilities;
|
||||
patch.verification = nextLatest?.verification;
|
||||
patch.scanStatus = nextLatest?.scanStatus;
|
||||
}
|
||||
await ctx.db.patch(pkg._id, patch);
|
||||
await syncPackageSearchDigest(ctx, { ...pkg, ...patch });
|
||||
@@ -210,20 +329,7 @@ triggers.register("skills", async (ctx, change) => {
|
||||
.unique();
|
||||
if (existing) await ctx.db.delete(existing._id);
|
||||
} else {
|
||||
const fields = extractDigestFields(change.newDoc);
|
||||
const owner = await ctx.db.get(change.newDoc.ownerUserId);
|
||||
const isOwnerVisible = owner && !owner.deletedAt && !owner.deactivatedAt;
|
||||
await upsertSkillSearchDigest(ctx, {
|
||||
...fields,
|
||||
// Use '' as sentinel for "visible user without a handle" so
|
||||
// digestToOwnerInfo can distinguish from undefined (not backfilled).
|
||||
// Deactivated/deleted owners also get '' → digestToOwnerInfo returns
|
||||
// null owner, matching the live path.
|
||||
ownerHandle: isOwnerVisible ? (owner.handle ?? "") : "",
|
||||
ownerName: isOwnerVisible ? owner.name : undefined,
|
||||
ownerDisplayName: isOwnerVisible ? owner.displayName : undefined,
|
||||
ownerImage: isOwnerVisible ? owner.image : undefined,
|
||||
});
|
||||
await syncSkillSearchDigestForSkill(ctx, change.newDoc);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -265,6 +371,11 @@ triggers.register("users", async (ctx, change) => {
|
||||
await syncPackageSearchDigestsForOwnerUserId(ctx, ownerUserId);
|
||||
});
|
||||
|
||||
triggers.register("publishers", async (ctx, change) => {
|
||||
const ownerPublisherId = change.operation === "delete" ? change.id : change.newDoc._id;
|
||||
await scheduleOwnerPublisherDigestSync(ctx, ownerPublisherId);
|
||||
});
|
||||
|
||||
export const mutation = customMutation(rawMutation, customCtx(triggers.wrapDB));
|
||||
export const internalMutation = customMutation(rawInternalMutation, customCtx(triggers.wrapDB));
|
||||
export { query, internalQuery, action, internalAction, httpAction };
|
||||
|
||||
@@ -17,9 +17,11 @@ import {
|
||||
listBundlePluginsV1Http,
|
||||
listCodePluginsV1Http,
|
||||
listPackagesV1Http,
|
||||
listPluginsV1Http,
|
||||
listSkillsV1Http,
|
||||
listSoulsV1Http,
|
||||
packagesGetRouterV1Http,
|
||||
pluginsGetRouterV1Http,
|
||||
publishSkillV1Http,
|
||||
publishPackageV1Http,
|
||||
publishSoulV1Http,
|
||||
@@ -74,6 +76,12 @@ http.route({
|
||||
handler: listPackagesV1Http,
|
||||
});
|
||||
|
||||
http.route({
|
||||
path: ApiRoutes.plugins,
|
||||
method: "GET",
|
||||
handler: listPluginsV1Http,
|
||||
});
|
||||
|
||||
http.route({
|
||||
path: ApiRoutes.codePlugins,
|
||||
method: "GET",
|
||||
@@ -98,6 +106,12 @@ http.route({
|
||||
handler: packagesGetRouterV1Http,
|
||||
});
|
||||
|
||||
http.route({
|
||||
pathPrefix: `${ApiRoutes.plugins}/`,
|
||||
method: "GET",
|
||||
handler: pluginsGetRouterV1Http,
|
||||
});
|
||||
|
||||
http.route({
|
||||
path: ApiRoutes.skills,
|
||||
method: "POST",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { unzipSync } from "fflate";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { internal } from "./_generated/api";
|
||||
import { RATE_LIMITS } from "./lib/httpRateLimit";
|
||||
|
||||
vi.mock("@convex-dev/auth/server", () => ({
|
||||
getAuthUserId: vi.fn(),
|
||||
@@ -41,6 +42,10 @@ function hasSlugArgs(args: unknown): args is { slug: string } {
|
||||
return typeof value.slug === "string";
|
||||
}
|
||||
|
||||
function findRateLimitCallArgs(mock: ReturnType<typeof vi.fn>) {
|
||||
return mock.mock.calls.map(([, args]) => args).find(isRateLimitArgs);
|
||||
}
|
||||
|
||||
function makeCtx(partial: Record<string, unknown>) {
|
||||
const partialRunQuery =
|
||||
typeof partial.runQuery === "function"
|
||||
@@ -214,6 +219,43 @@ describe("httpApiV1 handlers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("users/publisher ensures an org publisher handle for admin", async () => {
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
return {
|
||||
ok: true,
|
||||
publisherId: "publishers:openclaw",
|
||||
handle: "openclaw",
|
||||
created: true,
|
||||
migrated: false,
|
||||
trusted: true,
|
||||
};
|
||||
});
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:admin",
|
||||
user: { _id: "users:admin", role: "admin" },
|
||||
} as never);
|
||||
|
||||
const response = await __handlers.usersPostRouterV1Handler(
|
||||
makeCtx({ runQuery: vi.fn(), runAction: vi.fn(), runMutation }),
|
||||
new Request("https://example.com/api/v1/users/publisher", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ handle: "OpenClaw", displayName: "OpenClaw", trusted: true }),
|
||||
}),
|
||||
);
|
||||
if (response.status !== 200) throw new Error(await response.text());
|
||||
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
actorUserId: "users:admin",
|
||||
handle: "openclaw",
|
||||
displayName: "OpenClaw",
|
||||
trusted: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("search forwards limit and highlightedOnly", async () => {
|
||||
const runAction = vi.fn().mockResolvedValue([
|
||||
{
|
||||
@@ -2257,13 +2299,10 @@ describe("httpApiV1 handlers", () => {
|
||||
capabilityTag: "tools",
|
||||
}),
|
||||
);
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
key: expect.stringMatching(/^ip:/),
|
||||
limit: 120,
|
||||
}),
|
||||
);
|
||||
expect(findRateLimitCallArgs(runMutation)).toMatchObject({
|
||||
key: expect.stringMatching(/^ip:/),
|
||||
limit: RATE_LIMITS.read.ip,
|
||||
});
|
||||
expect(response.headers.get("RateLimit-Limit")).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -2345,6 +2384,27 @@ describe("httpApiV1 handlers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("packages list falls back to anonymous when cookie auth resolution fails", async () => {
|
||||
vi.mocked(getAuthUserId).mockRejectedValue(new Error("stale session"));
|
||||
const runQuery = vi.fn().mockResolvedValue({ page: [], isDone: true, continueCursor: "" });
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
const response = await __handlers.listPackagesV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request("https://example.com/api/v1/packages?isOfficial=true&limit=7"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(runQuery).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
isOfficial: true,
|
||||
viewerUserId: undefined,
|
||||
paginationOpts: { cursor: null, numItems: 7 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("packages detail falls back to public skills", async () => {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("name" in args) return null;
|
||||
@@ -2543,6 +2603,112 @@ describe("httpApiV1 handlers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("packages version detail returns security scan fields for plugins", async () => {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("name" in args && !("version" in args)) {
|
||||
return {
|
||||
package: {
|
||||
_id: "packages:demo-plugin",
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
tags: { latest: "packageReleases:1" },
|
||||
latestReleaseId: "packageReleases:1",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
latestRelease: null,
|
||||
owner: { _id: "publishers:demo", handle: "demo" },
|
||||
};
|
||||
}
|
||||
if ("name" in args && "version" in args) {
|
||||
return {
|
||||
package: {
|
||||
_id: "packages:demo-plugin",
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
},
|
||||
version: {
|
||||
_id: "packageReleases:1",
|
||||
packageId: "packages:demo-plugin",
|
||||
version: "1.0.0",
|
||||
createdAt: 1,
|
||||
changelog: "Initial release",
|
||||
distTags: ["latest"],
|
||||
files: [
|
||||
{
|
||||
path: "README.md",
|
||||
size: 10,
|
||||
sha256: "file-sha",
|
||||
storageId: "storage:1",
|
||||
contentType: "text/markdown",
|
||||
},
|
||||
],
|
||||
verification: {
|
||||
tier: "source-linked",
|
||||
scope: "artifact-only",
|
||||
scanStatus: "clean",
|
||||
},
|
||||
sha256hash: "a".repeat(64),
|
||||
vtAnalysis: {
|
||||
status: "clean",
|
||||
verdict: "benign",
|
||||
checkedAt: 1,
|
||||
},
|
||||
llmAnalysis: {
|
||||
status: "clean",
|
||||
verdict: "clean",
|
||||
summary: "Looks safe.",
|
||||
checkedAt: 1,
|
||||
},
|
||||
staticScan: {
|
||||
status: "clean",
|
||||
reasonCodes: [],
|
||||
findings: [],
|
||||
summary: "No issues",
|
||||
engineVersion: "1",
|
||||
checkedAt: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
const response = await __handlers.packagesGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request("https://example.com/api/v1/packages/demo-plugin/versions/1.0.0"),
|
||||
);
|
||||
|
||||
if (response.status !== 200) throw new Error(await response.text());
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
package: {
|
||||
name: "demo-plugin",
|
||||
family: "code-plugin",
|
||||
},
|
||||
version: {
|
||||
version: "1.0.0",
|
||||
sha256hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
vtAnalysis: {
|
||||
status: "clean",
|
||||
verdict: "benign",
|
||||
},
|
||||
llmAnalysis: {
|
||||
status: "clean",
|
||||
verdict: "clean",
|
||||
},
|
||||
staticScan: {
|
||||
status: "clean",
|
||||
summary: "No issues",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("treats /packages/search without q as a package detail route", async () => {
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
@@ -2641,13 +2807,10 @@ describe("httpApiV1 handlers", () => {
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("RateLimit-Limit")).toBeTruthy();
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
key: expect.stringMatching(/^ip:/),
|
||||
limit: 20,
|
||||
}),
|
||||
);
|
||||
expect(findRateLimitCallArgs(runMutation)).toMatchObject({
|
||||
key: expect.stringMatching(/^ip:/),
|
||||
limit: RATE_LIMITS.download.ip,
|
||||
});
|
||||
});
|
||||
|
||||
it("package file uses read rate limiting", async () => {
|
||||
@@ -2704,13 +2867,66 @@ describe("httpApiV1 handlers", () => {
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("RateLimit-Limit")).toBeTruthy();
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
key: expect.stringMatching(/^ip:/),
|
||||
limit: 120,
|
||||
expect(findRateLimitCallArgs(runMutation)).toMatchObject({
|
||||
key: expect.stringMatching(/^ip:/),
|
||||
limit: RATE_LIMITS.read.ip,
|
||||
});
|
||||
});
|
||||
|
||||
it("package file resolves lowercase readme variants from the canonical request path", 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: null,
|
||||
};
|
||||
}
|
||||
if ("releaseId" in args) {
|
||||
return {
|
||||
_id: "packageReleases:1",
|
||||
version: "1.0.0",
|
||||
createdAt: 1,
|
||||
changelog: "init",
|
||||
files: [
|
||||
{
|
||||
path: "readme.md",
|
||||
size: 5,
|
||||
sha256: "a".repeat(64),
|
||||
storageId: "storage:1",
|
||||
contentType: "text/markdown",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const response = await __handlers.packagesGetRouterV1Handler(
|
||||
makeCtx({
|
||||
runQuery,
|
||||
runMutation,
|
||||
storage: {
|
||||
get: vi.fn().mockResolvedValue(new Blob(["hello"], { type: "text/markdown" })),
|
||||
},
|
||||
}),
|
||||
new Request("https://example.com/api/v1/packages/demo-plugin/file?path=README.md"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe("hello");
|
||||
});
|
||||
|
||||
it("package download uses a package/ root without registry metadata", async () => {
|
||||
@@ -2847,6 +3063,167 @@ describe("httpApiV1 handlers", () => {
|
||||
expect(await response.text()).toBe("Missing stored file: dist/index.js");
|
||||
});
|
||||
|
||||
it("allows package downloads while VT scan is pending", 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: null,
|
||||
};
|
||||
}
|
||||
if ("releaseId" in args) {
|
||||
return {
|
||||
_id: "packageReleases:1",
|
||||
version: "1.0.0",
|
||||
createdAt: 1,
|
||||
changelog: "init",
|
||||
sha256hash: "a".repeat(64),
|
||||
files: [
|
||||
{
|
||||
path: "package.json",
|
||||
size: 2,
|
||||
sha256: "a".repeat(64),
|
||||
storageId: "storage:1",
|
||||
contentType: "application/json",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const storageGet = vi.fn(async () => new Blob(['{"name":"demo-plugin"}']));
|
||||
|
||||
const response = await __handlers.packagesGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation, storage: { get: storageGet } }),
|
||||
new Request("https://example.com/api/v1/packages/demo-plugin/download"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toContain("application/zip");
|
||||
expect(storageGet).toHaveBeenCalledWith("storage:1");
|
||||
});
|
||||
|
||||
it("allows package downloads when verification is clean even without cached vtAnalysis", 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: null,
|
||||
};
|
||||
}
|
||||
if ("releaseId" in args) {
|
||||
return {
|
||||
_id: "packageReleases:1",
|
||||
version: "1.0.0",
|
||||
createdAt: 1,
|
||||
changelog: "init",
|
||||
sha256hash: "a".repeat(64),
|
||||
verification: { scanStatus: "clean" },
|
||||
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"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("blocks package file access when release is malicious", 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: null,
|
||||
};
|
||||
}
|
||||
if ("releaseId" in args) {
|
||||
return {
|
||||
_id: "packageReleases:1",
|
||||
version: "1.0.0",
|
||||
createdAt: 1,
|
||||
changelog: "init",
|
||||
verification: { scanStatus: "malicious" },
|
||||
files: [
|
||||
{
|
||||
path: "README.md",
|
||||
size: 2,
|
||||
sha256: "a".repeat(64),
|
||||
storageId: "storage:1",
|
||||
contentType: "text/markdown",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const response = await __handlers.packagesGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation, storage: { get: vi.fn() } }),
|
||||
new Request("https://example.com/api/v1/packages/demo-plugin/file?path=README.md"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.text()).toContain("flagged as malicious");
|
||||
});
|
||||
|
||||
it("blocks file and download access to soft-deleted package releases", async () => {
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
@@ -2924,6 +3301,7 @@ describe("httpApiV1 handlers", () => {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: "demo-plugin",
|
||||
ownerHandle: "openclaw",
|
||||
family: "bundle-plugin",
|
||||
version: "1.0.0",
|
||||
changelog: "init",
|
||||
@@ -2942,11 +3320,15 @@ describe("httpApiV1 handlers", () => {
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("RateLimit-Limit")).toBeTruthy();
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect(findRateLimitCallArgs(runMutation)).toMatchObject({
|
||||
key: "user:users:1",
|
||||
limit: RATE_LIMITS.write.key,
|
||||
});
|
||||
expect(runAction).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
key: "user:users:1",
|
||||
limit: 120,
|
||||
actorUserId: "users:1",
|
||||
payload: expect.objectContaining({ ownerHandle: "openclaw" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,7 +3,9 @@ import {
|
||||
listBundlePluginsV1Handler,
|
||||
listCodePluginsV1Handler,
|
||||
listPackagesV1Handler,
|
||||
listPluginsV1Handler,
|
||||
packagesGetRouterV1Handler,
|
||||
pluginsGetRouterV1Handler,
|
||||
publishPackageV1Handler,
|
||||
} from "./httpApiV1/packagesV1";
|
||||
import {
|
||||
@@ -28,7 +30,9 @@ import { usersListV1Handler, usersPostRouterV1Handler } from "./httpApiV1/usersV
|
||||
import { whoamiV1Handler } from "./httpApiV1/whoamiV1";
|
||||
|
||||
export const listPackagesV1Http = httpAction(listPackagesV1Handler);
|
||||
export const listPluginsV1Http = httpAction(listPluginsV1Handler);
|
||||
export const packagesGetRouterV1Http = httpAction(packagesGetRouterV1Handler);
|
||||
export const pluginsGetRouterV1Http = httpAction(pluginsGetRouterV1Handler);
|
||||
export const publishPackageV1Http = httpAction(publishPackageV1Handler);
|
||||
export const listCodePluginsV1Http = httpAction(listCodePluginsV1Handler);
|
||||
export const listBundlePluginsV1Http = httpAction(listBundlePluginsV1Handler);
|
||||
@@ -57,7 +61,9 @@ export const usersListV1Http = httpAction(usersListV1Handler);
|
||||
|
||||
export const __handlers = {
|
||||
listPackagesV1Handler,
|
||||
listPluginsV1Handler,
|
||||
packagesGetRouterV1Handler,
|
||||
pluginsGetRouterV1Handler,
|
||||
publishPackageV1Handler,
|
||||
listCodePluginsV1Handler,
|
||||
listBundlePluginsV1Handler,
|
||||
|
||||
+167
-86
@@ -5,6 +5,8 @@ import type { Doc, Id } from "../_generated/dataModel";
|
||||
import type { ActionCtx } from "../_generated/server";
|
||||
import { getOptionalApiTokenUserId } from "../lib/apiTokenAuth";
|
||||
import { corsHeaders, mergeHeaders } from "../lib/httpHeaders";
|
||||
import { getPackageDownloadSecurityBlock } from "../lib/packageSecurity";
|
||||
import { getPublishFileSizeError, MAX_PUBLISH_FILE_BYTES } from "../lib/publishLimits";
|
||||
import { applyRateLimit } from "../lib/httpRateLimit";
|
||||
import { buildDeterministicPackageZip } from "../lib/skillZip";
|
||||
import { isMacJunkPath, isTextFile } from "../lib/skills";
|
||||
@@ -58,6 +60,17 @@ async function runActionRef<T>(ctx: ActionCtx, ref: unknown, args: unknown): Pro
|
||||
return (await ctx.runAction(ref as never, args as never)) as T;
|
||||
}
|
||||
|
||||
async function getOptionalViewerUserIdForRequest(ctx: ActionCtx, request: Request) {
|
||||
const apiTokenUserId = await getOptionalApiTokenUserId(ctx, request);
|
||||
if (apiTokenUserId) return apiTokenUserId;
|
||||
try {
|
||||
return (await getAuthUserId(ctx)) ?? null;
|
||||
} catch {
|
||||
// Public package reads should degrade to anonymous when cookie-backed auth is stale.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type PackageListQueryArgs = {
|
||||
family?: "skill" | "code-plugin" | "bundle-plugin";
|
||||
channel?: "official" | "community" | "private";
|
||||
@@ -113,6 +126,10 @@ type ReleaseLike = {
|
||||
compatibility?: Doc<"packageReleases">["compatibility"];
|
||||
capabilities?: Doc<"packageReleases">["capabilities"];
|
||||
verification?: Doc<"packageReleases">["verification"];
|
||||
sha256hash?: string;
|
||||
vtAnalysis?: Doc<"packageReleases">["vtAnalysis"];
|
||||
llmAnalysis?: Doc<"packageReleases">["llmAnalysis"];
|
||||
staticScan?: Doc<"packageReleases">["staticScan"];
|
||||
integritySha256?: string;
|
||||
softDeletedAt?: number;
|
||||
};
|
||||
@@ -122,6 +139,10 @@ function toVisibleRelease(release: ReleaseLike | null) {
|
||||
return release;
|
||||
}
|
||||
|
||||
function getReleaseSecurityBlock(release: ReleaseLike) {
|
||||
return getPackageDownloadSecurityBlock(release);
|
||||
}
|
||||
|
||||
async function resolvePackageTags(
|
||||
ctx: ActionCtx,
|
||||
tags: Record<string, Id<"packageReleases">>,
|
||||
@@ -340,6 +361,7 @@ function parsePackagePublishBody(body: unknown) {
|
||||
const parsed = parseArk(PackagePublishRequestSchema, body, "Package publish payload") as {
|
||||
name: string;
|
||||
displayName?: string;
|
||||
ownerHandle?: string;
|
||||
family: "skill" | "code-plugin" | "bundle-plugin";
|
||||
version: string;
|
||||
changelog: string;
|
||||
@@ -359,6 +381,7 @@ function parsePackagePublishBody(body: unknown) {
|
||||
return {
|
||||
name: parsed.name,
|
||||
displayName: parsed.displayName ?? undefined,
|
||||
ownerHandle: parsed.ownerHandle?.trim().replace(/^@+/, "") || undefined,
|
||||
family: parsed.family,
|
||||
version: parsed.version,
|
||||
changelog: parsed.changelog,
|
||||
@@ -388,6 +411,9 @@ async function parseMultipartPackagePublish(ctx: ActionCtx, request: Request) {
|
||||
for (const entry of form.getAll("files")) {
|
||||
if (typeof entry === "string") continue;
|
||||
if (isMacJunkPath(entry.name)) continue;
|
||||
if (entry.size > MAX_PUBLISH_FILE_BYTES) {
|
||||
throw new Error(getPublishFileSizeError(entry.name));
|
||||
}
|
||||
const buffer = new Uint8Array(await entry.arrayBuffer());
|
||||
const digest = await crypto.subtle.digest("SHA-256", buffer);
|
||||
const sha256 = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
@@ -403,12 +429,17 @@ async function parseMultipartPackagePublish(ctx: ActionCtx, request: Request) {
|
||||
return parsePackagePublishBody({ ...payload, files });
|
||||
}
|
||||
|
||||
async function listPackages(ctx: ActionCtx, request: Request, family?: PackageListQueryArgs["family"]) {
|
||||
async function listPackages(
|
||||
ctx: ActionCtx,
|
||||
request: Request,
|
||||
family?: PackageListQueryArgs["family"],
|
||||
options?: { includeSkills?: boolean },
|
||||
) {
|
||||
const rate = await applyRateLimit(ctx, request, "read");
|
||||
if (!rate.ok) return rate.response;
|
||||
|
||||
const url = new URL(request.url);
|
||||
const viewerUserId = (await getOptionalApiTokenUserId(ctx, request)) ?? (await getAuthUserId(ctx));
|
||||
const viewerUserId = await getOptionalViewerUserIdForRequest(ctx, request);
|
||||
const limit = Math.max(1, Math.min(toOptionalNumber(url.searchParams.get("limit")) ?? 25, 100));
|
||||
const cursor = url.searchParams.get("cursor");
|
||||
const familyRaw = url.searchParams.get("family");
|
||||
@@ -421,6 +452,7 @@ async function listPackages(ctx: ActionCtx, request: Request, family?: PackageLi
|
||||
(familyRaw === "skill" || familyRaw === "code-plugin" || familyRaw === "bundle-plugin"
|
||||
? familyRaw
|
||||
: undefined);
|
||||
const includeSkills = options?.includeSkills ?? effectiveFamily === undefined;
|
||||
const channel =
|
||||
channelRaw === "official" || channelRaw === "community" || channelRaw === "private"
|
||||
? channelRaw
|
||||
@@ -449,7 +481,7 @@ async function listPackages(ctx: ActionCtx, request: Request, family?: PackageLi
|
||||
);
|
||||
}
|
||||
|
||||
if (!effectiveFamily) {
|
||||
if (!effectiveFamily && includeSkills) {
|
||||
const packageSource = initCatalogSource(decodeUnifiedCatalogCursor(cursor).packages);
|
||||
const skillSource = initCatalogSource(decodeUnifiedCatalogCursor(cursor).skills);
|
||||
const pageSize = limit;
|
||||
@@ -546,7 +578,11 @@ async function listPackages(ctx: ActionCtx, request: Request, family?: PackageLi
|
||||
}
|
||||
|
||||
export async function listPackagesV1Handler(ctx: ActionCtx, request: Request) {
|
||||
return await listPackages(ctx, request);
|
||||
return await listPackages(ctx, request, undefined, { includeSkills: true });
|
||||
}
|
||||
|
||||
export async function listPluginsV1Handler(ctx: ActionCtx, request: Request) {
|
||||
return await listPackages(ctx, request, undefined, { includeSkills: false });
|
||||
}
|
||||
|
||||
export async function listCodePluginsV1Handler(ctx: ActionCtx, request: Request) {
|
||||
@@ -570,7 +606,7 @@ export async function publishPackageV1Handler(ctx: ActionCtx, request: Request)
|
||||
? await parseMultipartPackagePublish(ctx, request)
|
||||
: parsePackagePublishBody(await request.json());
|
||||
const result = await runActionRef(ctx, internalRefs.packages.publishPackageForUserInternal, {
|
||||
userId: auth.userId,
|
||||
actorUserId: auth.userId,
|
||||
payload,
|
||||
});
|
||||
return json(result, 200, rate.headers);
|
||||
@@ -644,6 +680,23 @@ function resolveSkillFilePath(version: SkillVersionLike, requestedPath: string)
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePackageFilePath(release: ReleaseLike, requestedPath: string) {
|
||||
const normalized = requestedPath.trim();
|
||||
const lower = normalized.toLowerCase();
|
||||
if (isReadmeVariantPath(normalized)) {
|
||||
return (
|
||||
release.files.find((file) => isReadmeVariantPath(file.path)) ??
|
||||
release.files.find((file) => file.path.toLowerCase() === lower) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
return (
|
||||
release.files.find((file) => file.path === normalized) ??
|
||||
release.files.find((file) => file.path.toLowerCase() === lower) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
async function getSkillDetailForRequest(ctx: ActionCtx, slug: string) {
|
||||
return (await runQueryRef(ctx, apiRefs.skills.getBySlug, { slug })) as
|
||||
| {
|
||||
@@ -683,99 +736,110 @@ async function getSkillVersionForRequest(
|
||||
})) as SkillVersionLike | null;
|
||||
}
|
||||
|
||||
export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const segments = getPathSegments(request, "/api/v1/packages/");
|
||||
if (segments.length === 0) return text("Not found", 404);
|
||||
|
||||
const rateKind = segments[1] === "download" ? "download" : "read";
|
||||
const rate = await applyRateLimit(ctx, request, rateKind);
|
||||
async function searchPackages(
|
||||
ctx: ActionCtx,
|
||||
request: Request,
|
||||
options?: { includeSkills?: boolean },
|
||||
) {
|
||||
const rate = await applyRateLimit(ctx, request, "read");
|
||||
if (!rate.ok) return rate.response;
|
||||
|
||||
if (segments[0] === "search" && new URL(request.url).searchParams.has("q")) {
|
||||
const url = new URL(request.url);
|
||||
const viewerUserId = (await getOptionalApiTokenUserId(ctx, request)) ?? (await getAuthUserId(ctx));
|
||||
const queryText = url.searchParams.get("q")?.trim() ?? "";
|
||||
const limit = Math.max(1, Math.min(toOptionalNumber(url.searchParams.get("limit")) ?? 20, 100));
|
||||
const familyRaw = url.searchParams.get("family");
|
||||
const channelRaw = url.searchParams.get("channel");
|
||||
const isOfficialRaw = url.searchParams.get("isOfficial");
|
||||
const executesCodeRaw = url.searchParams.get("executesCode");
|
||||
const capabilityTag = url.searchParams.get("capabilityTag")?.trim() || undefined;
|
||||
const family =
|
||||
familyRaw === "skill" || familyRaw === "code-plugin" || familyRaw === "bundle-plugin"
|
||||
? familyRaw
|
||||
: undefined;
|
||||
const channel =
|
||||
channelRaw === "official" || channelRaw === "community" || channelRaw === "private"
|
||||
? channelRaw
|
||||
: undefined;
|
||||
const isOfficial =
|
||||
isOfficialRaw === "true" ? true : isOfficialRaw === "false" ? false : undefined;
|
||||
const executesCode =
|
||||
executesCodeRaw === "true" ? true : executesCodeRaw === "false" ? false : undefined;
|
||||
const url = new URL(request.url);
|
||||
const viewerUserId = await getOptionalViewerUserIdForRequest(ctx, request);
|
||||
const queryText = url.searchParams.get("q")?.trim() ?? "";
|
||||
const limit = Math.max(1, Math.min(toOptionalNumber(url.searchParams.get("limit")) ?? 20, 100));
|
||||
const familyRaw = url.searchParams.get("family");
|
||||
const channelRaw = url.searchParams.get("channel");
|
||||
const isOfficialRaw = url.searchParams.get("isOfficial");
|
||||
const executesCodeRaw = url.searchParams.get("executesCode");
|
||||
const capabilityTag = url.searchParams.get("capabilityTag")?.trim() || undefined;
|
||||
const family =
|
||||
familyRaw === "skill" || familyRaw === "code-plugin" || familyRaw === "bundle-plugin"
|
||||
? familyRaw
|
||||
: undefined;
|
||||
const includeSkills = options?.includeSkills ?? family === undefined;
|
||||
const channel =
|
||||
channelRaw === "official" || channelRaw === "community" || channelRaw === "private"
|
||||
? channelRaw
|
||||
: undefined;
|
||||
const isOfficial =
|
||||
isOfficialRaw === "true" ? true : isOfficialRaw === "false" ? false : undefined;
|
||||
const executesCode =
|
||||
executesCodeRaw === "true" ? true : executesCodeRaw === "false" ? false : undefined;
|
||||
|
||||
let results: CatalogSearchEntry[];
|
||||
if (family === "skill") {
|
||||
results = await runQueryRef<CatalogSearchEntry[]>(ctx, apiRefs.skills.searchPackageCatalogPublic, {
|
||||
let results: CatalogSearchEntry[];
|
||||
if (family === "skill") {
|
||||
results = await runQueryRef<CatalogSearchEntry[]>(ctx, apiRefs.skills.searchPackageCatalogPublic, {
|
||||
query: queryText,
|
||||
limit,
|
||||
channel,
|
||||
isOfficial,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
});
|
||||
} else if (family || !includeSkills) {
|
||||
results = await runQueryRef<CatalogSearchEntry[]>(ctx, internalRefs.packages.searchForViewerInternal, {
|
||||
query: queryText,
|
||||
limit,
|
||||
family,
|
||||
channel,
|
||||
isOfficial,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
});
|
||||
} else {
|
||||
const [packageResults, skillResults] = await Promise.all([
|
||||
runQueryRef<CatalogSearchEntry[]>(ctx, internalRefs.packages.searchForViewerInternal, {
|
||||
query: queryText,
|
||||
limit,
|
||||
channel,
|
||||
isOfficial,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
});
|
||||
} else if (family) {
|
||||
results = await runQueryRef<CatalogSearchEntry[]>(ctx, internalRefs.packages.searchForViewerInternal, {
|
||||
query: queryText,
|
||||
limit,
|
||||
family,
|
||||
channel,
|
||||
isOfficial,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
});
|
||||
} else {
|
||||
const [packageResults, skillResults] = await Promise.all([
|
||||
runQueryRef<CatalogSearchEntry[]>(ctx, internalRefs.packages.searchForViewerInternal, {
|
||||
query: queryText,
|
||||
limit,
|
||||
channel,
|
||||
isOfficial,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
}),
|
||||
runQueryRef<CatalogSearchEntry[]>(ctx, apiRefs.skills.searchPackageCatalogPublic, {
|
||||
query: queryText,
|
||||
limit,
|
||||
channel,
|
||||
isOfficial,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
}),
|
||||
]);
|
||||
const seen = new Set<string>();
|
||||
results = [...packageResults, ...skillResults]
|
||||
.filter((entry) => {
|
||||
const key = `${entry.package.family}:${entry.package.name}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.score - a.score ||
|
||||
Number(b.package.isOfficial) - Number(a.package.isOfficial) ||
|
||||
compareCatalogItems(a.package, b.package),
|
||||
)
|
||||
.slice(0, limit);
|
||||
}
|
||||
return json({ results }, 200, rate.headers);
|
||||
}),
|
||||
runQueryRef<CatalogSearchEntry[]>(ctx, apiRefs.skills.searchPackageCatalogPublic, {
|
||||
query: queryText,
|
||||
limit,
|
||||
channel,
|
||||
isOfficial,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
}),
|
||||
]);
|
||||
const seen = new Set<string>();
|
||||
results = [...packageResults, ...skillResults]
|
||||
.filter((entry) => {
|
||||
const key = `${entry.package.family}:${entry.package.name}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.score - a.score ||
|
||||
Number(b.package.isOfficial) - Number(a.package.isOfficial) ||
|
||||
compareCatalogItems(a.package, b.package),
|
||||
)
|
||||
.slice(0, limit);
|
||||
}
|
||||
return json({ results }, 200, rate.headers);
|
||||
}
|
||||
|
||||
export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const segments = getPathSegments(request, "/api/v1/packages/");
|
||||
if (segments.length === 0) return text("Not found", 404);
|
||||
if (segments[0] === "search" && new URL(request.url).searchParams.has("q")) {
|
||||
return await searchPackages(ctx, request, { includeSkills: true });
|
||||
}
|
||||
|
||||
const rateKind = segments[1] === "download" ? "download" : "read";
|
||||
const rate = await applyRateLimit(ctx, request, rateKind);
|
||||
if (!rate.ok) return rate.response;
|
||||
|
||||
const packageName = segments[0] ?? "";
|
||||
const viewerUserId = (await getOptionalApiTokenUserId(ctx, request)) ?? (await getAuthUserId(ctx));
|
||||
const viewerUserId = await getOptionalViewerUserIdForRequest(ctx, request);
|
||||
const detail = (await runQueryRef(
|
||||
ctx,
|
||||
internalRefs.packages.getByNameForViewerInternal,
|
||||
@@ -928,6 +992,10 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
|
||||
compatibility: result.version.compatibility ?? null,
|
||||
capabilities: result.version.capabilities ?? null,
|
||||
verification: result.version.verification ?? null,
|
||||
sha256hash: result.version.sha256hash ?? null,
|
||||
vtAnalysis: result.version.vtAnalysis ?? null,
|
||||
llmAnalysis: result.version.llmAnalysis ?? null,
|
||||
staticScan: result.version.staticScan ?? null,
|
||||
},
|
||||
}, 200, rate.headers);
|
||||
}
|
||||
@@ -958,7 +1026,9 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
|
||||
}
|
||||
const release = await getReleaseForRequest(ctx, publicPackage!, request);
|
||||
if (!release) return text("Version not found", 404, rate.headers);
|
||||
const file = release.files.find((entry) => entry.path === path);
|
||||
const securityBlock = getReleaseSecurityBlock(release);
|
||||
if (securityBlock) return text(securityBlock.message, securityBlock.status, rate.headers);
|
||||
const file = resolvePackageFilePath(release, path);
|
||||
if (!file) return text("File not found", 404, rate.headers);
|
||||
if (!isTextFile(file.path, file.contentType)) {
|
||||
return text("Binary files are not served inline", 415, rate.headers);
|
||||
@@ -993,6 +1063,8 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
|
||||
}
|
||||
const release = await getReleaseForRequest(ctx, publicPackage!, request);
|
||||
if (!release) return text("Version not found", 404, rate.headers);
|
||||
const securityBlock = getReleaseSecurityBlock(release);
|
||||
if (securityBlock) return text(securityBlock.message, securityBlock.status, rate.headers);
|
||||
const entries: Array<{ path: string; bytes: Uint8Array }> = [];
|
||||
for (const file of release.files) {
|
||||
const blob = await ctx.storage.get(file.storageId);
|
||||
@@ -1019,6 +1091,15 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
|
||||
return text("Not found", 404, rate.headers);
|
||||
}
|
||||
|
||||
export async function pluginsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const segments = getPathSegments(request, "/api/v1/plugins/");
|
||||
if (segments.length === 0) return text("Not found", 404);
|
||||
if (segments[0] === "search" && new URL(request.url).searchParams.has("q")) {
|
||||
return await searchPackages(ctx, request, { includeSkills: false });
|
||||
}
|
||||
return text("Not found", 404);
|
||||
}
|
||||
|
||||
type PublicPackageDocLike = {
|
||||
_id: Id<"packages">;
|
||||
name: string;
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ActionCtx } from "../_generated/server";
|
||||
import { assertAdmin } from "../lib/access";
|
||||
import { requireApiTokenUser } from "../lib/apiTokenAuth";
|
||||
import { corsHeaders, mergeHeaders } from "../lib/httpHeaders";
|
||||
import { getPublishFileSizeError, MAX_PUBLISH_FILE_BYTES } from "../lib/publishLimits";
|
||||
import { isMacJunkPath } from "../lib/skills";
|
||||
|
||||
export const MAX_RAW_FILE_BYTES = 200 * 1024;
|
||||
@@ -263,6 +264,9 @@ export async function parseMultipartPublish(
|
||||
const path = file.name;
|
||||
if (isMacJunkPath(path)) continue;
|
||||
const size = file.size;
|
||||
if (size > MAX_PUBLISH_FILE_BYTES) {
|
||||
throw new Error(getPublishFileSizeError(path));
|
||||
}
|
||||
const contentType = file.type || undefined;
|
||||
const buffer = new Uint8Array(await file.arrayBuffer());
|
||||
const sha256 = await sha256Hex(buffer);
|
||||
|
||||
@@ -22,7 +22,13 @@ export async function usersPostRouterV1Handler(ctx: ActionCtx, request: Request)
|
||||
return text("Not found", 404, rate.headers);
|
||||
}
|
||||
const action = segments[0];
|
||||
if (action !== "ban" && action !== "role" && action !== "restore" && action !== "reclaim") {
|
||||
if (
|
||||
action !== "ban" &&
|
||||
action !== "role" &&
|
||||
action !== "restore" &&
|
||||
action !== "reclaim" &&
|
||||
action !== "publisher"
|
||||
) {
|
||||
return text("Not found", 404, rate.headers);
|
||||
}
|
||||
|
||||
@@ -48,6 +54,12 @@ export async function usersPostRouterV1Handler(ctx: ActionCtx, request: Request)
|
||||
return handleAdminReclaim(ctx, request, payload, actorUserId, rate.headers);
|
||||
}
|
||||
|
||||
if (action === "publisher") {
|
||||
const admin = requireAdminOrResponse(actorUser, rate.headers);
|
||||
if (!admin.ok) return admin.response;
|
||||
return handleAdminEnsurePublisher(ctx, payload, actorUserId, rate.headers);
|
||||
}
|
||||
|
||||
const handleRaw = typeof payload.handle === "string" ? payload.handle.trim() : "";
|
||||
const userIdRaw = typeof payload.userId === "string" ? payload.userId.trim() : "";
|
||||
const reasonRaw = typeof payload.reason === "string" ? payload.reason.trim() : "";
|
||||
@@ -215,6 +227,39 @@ async function handleAdminReclaim(
|
||||
return json({ ok: true, results, succeeded, failed }, 200, headers);
|
||||
}
|
||||
|
||||
async function handleAdminEnsurePublisher(
|
||||
ctx: ActionCtx,
|
||||
payload: Record<string, unknown>,
|
||||
actorUserId: Id<"users">,
|
||||
headers: HeadersInit,
|
||||
) {
|
||||
const handle = typeof payload.handle === "string" ? payload.handle.trim().toLowerCase() : "";
|
||||
if (!handle) return text("Missing handle", 400, headers);
|
||||
|
||||
const displayName = typeof payload.displayName === "string" ? payload.displayName.trim() : undefined;
|
||||
const trusted =
|
||||
typeof payload.trusted === "boolean" ? payload.trusted : true;
|
||||
|
||||
try {
|
||||
const result = await ctx.runMutation(internal.publishers.ensureOrgPublisherHandleInternal, {
|
||||
actorUserId,
|
||||
handle,
|
||||
displayName,
|
||||
trusted,
|
||||
});
|
||||
return json(result, 200, headers);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Publisher ensure failed";
|
||||
if (message.toLowerCase().includes("forbidden")) {
|
||||
return text("Forbidden", 403, headers);
|
||||
}
|
||||
if (message.toLowerCase().includes("not found")) {
|
||||
return text(message, 404, headers);
|
||||
}
|
||||
return text(message, 400, headers);
|
||||
}
|
||||
}
|
||||
|
||||
export async function usersListV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const rate = await applyRateLimit(ctx, request, "read");
|
||||
if (!rate.ok) return rate.response;
|
||||
|
||||
@@ -5,9 +5,9 @@ import { corsHeaders, mergeHeaders } from "./httpHeaders";
|
||||
|
||||
const RATE_LIMIT_WINDOW_MS = 60_000;
|
||||
export const RATE_LIMITS = {
|
||||
read: { ip: 120, key: 600 },
|
||||
write: { ip: 30, key: 120 },
|
||||
download: { ip: 20, key: 120 },
|
||||
read: { ip: 180, key: 900 },
|
||||
write: { ip: 45, key: 180 },
|
||||
download: { ip: 30, key: 180 },
|
||||
} as const;
|
||||
|
||||
type RateLimitResult = {
|
||||
|
||||
@@ -46,9 +46,11 @@ describe("packageRegistry", () => {
|
||||
|
||||
expect(result.runtimeId).toBe("demo.plugin");
|
||||
expect(result.compatibility?.pluginApiRange).toBe("^1.2.0");
|
||||
expect(result.compatibility?.minGatewayVersion).toBe("2026.3.0");
|
||||
expect(result.capabilities.executesCode).toBe(true);
|
||||
expect(result.capabilities.toolNames).toContain("demoTool");
|
||||
expect(result.verification.tier).toBe("source-linked");
|
||||
expect(result.verification.scanStatus).toBe("not-run");
|
||||
});
|
||||
|
||||
it("requires source metadata for code plugins", () => {
|
||||
@@ -69,6 +71,63 @@ describe("packageRegistry", () => {
|
||||
).toThrow("source repo and commit");
|
||||
});
|
||||
|
||||
it("maps legacy minHostVersion to minGatewayVersion instead of pluginApiRange", () => {
|
||||
expect(() =>
|
||||
extractCodePluginArtifacts({
|
||||
packageName: "@openclaw/matrix",
|
||||
packageJson: {
|
||||
name: "@openclaw/matrix",
|
||||
version: "2026.3.13",
|
||||
openclaw: {
|
||||
extensions: ["./index.ts"],
|
||||
install: {
|
||||
npmSpec: "@openclaw/matrix",
|
||||
localPath: "extensions/matrix",
|
||||
defaultChoice: "npm",
|
||||
minHostVersion: "2026.3.13",
|
||||
},
|
||||
},
|
||||
},
|
||||
pluginManifest: {
|
||||
id: "matrix",
|
||||
channels: ["matrix"],
|
||||
configSchema: { type: "object" },
|
||||
},
|
||||
source: {
|
||||
kind: "github",
|
||||
url: "https://github.com/openclaw/openclaw",
|
||||
repo: "openclaw/openclaw",
|
||||
ref: "refs/tags/v2026.3.13",
|
||||
commit: "abc123",
|
||||
path: "extensions/matrix",
|
||||
importedAt: Date.now(),
|
||||
},
|
||||
}),
|
||||
).toThrow("package.json openclaw.compat.pluginApi is required");
|
||||
});
|
||||
|
||||
it("extracts legacy minHostVersion as minGatewayVersion while preserving build metadata", () => {
|
||||
const result = extractBundlePluginArtifacts({
|
||||
packageName: "@openclaw/matrix-bundle",
|
||||
packageJson: {
|
||||
name: "@openclaw/matrix-bundle",
|
||||
version: "2026.3.13",
|
||||
openclaw: {
|
||||
install: {
|
||||
minHostVersion: "2026.3.13",
|
||||
},
|
||||
},
|
||||
},
|
||||
bundleManifest: {
|
||||
hostTargets: ["openclaw"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.compatibility?.pluginApiRange).toBeUndefined();
|
||||
expect(result.compatibility?.minGatewayVersion).toBe("2026.3.13");
|
||||
expect(result.compatibility?.builtWithOpenClawVersion).toBe("2026.3.13");
|
||||
});
|
||||
|
||||
it("requires host targets for bundle plugins", () => {
|
||||
expect(() =>
|
||||
extractBundlePluginArtifacts({
|
||||
|
||||
@@ -176,14 +176,25 @@ function extractOpenClawBlock(packageJson: JsonRecord | undefined) {
|
||||
}
|
||||
|
||||
function extractCompatibility(packageJson: JsonRecord | undefined): PackageCompatibility | undefined {
|
||||
const { compat, build } = extractOpenClawBlock(packageJson);
|
||||
const { openclaw, compat, build } = extractOpenClawBlock(packageJson);
|
||||
const install = isRecord(openclaw?.install) ? openclaw.install : undefined;
|
||||
const version =
|
||||
typeof packageJson?.version === "string" ? packageJson.version.trim() : undefined;
|
||||
const minHostVersion =
|
||||
typeof install?.minHostVersion === "string" ? install.minHostVersion.trim() : undefined;
|
||||
const compatibility: PackageCompatibility = {};
|
||||
if (typeof compat?.pluginApi === "string") compatibility.pluginApiRange = compat.pluginApi.trim();
|
||||
if (typeof compat?.pluginApi === "string") {
|
||||
compatibility.pluginApiRange = compat.pluginApi.trim();
|
||||
}
|
||||
if (typeof compat?.minGatewayVersion === "string") {
|
||||
compatibility.minGatewayVersion = compat.minGatewayVersion.trim();
|
||||
} else if (minHostVersion) {
|
||||
compatibility.minGatewayVersion = minHostVersion;
|
||||
}
|
||||
if (typeof build?.openclawVersion === "string") {
|
||||
compatibility.builtWithOpenClawVersion = build.openclawVersion.trim();
|
||||
} else if (version) {
|
||||
compatibility.builtWithOpenClawVersion = version;
|
||||
}
|
||||
if (typeof build?.pluginSdkVersion === "string") {
|
||||
compatibility.pluginSdkVersion = build.pluginSdkVersion.trim();
|
||||
|
||||
@@ -13,10 +13,12 @@ const SHARED_KEYS = [
|
||||
"channel",
|
||||
"isOfficial",
|
||||
"ownerUserId",
|
||||
"ownerPublisherId",
|
||||
"summary",
|
||||
"capabilityTags",
|
||||
"executesCode",
|
||||
"runtimeId",
|
||||
"scanStatus",
|
||||
"softDeletedAt",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
@@ -31,13 +33,16 @@ const CAPABILITY_SHARED_KEYS = [
|
||||
"channel",
|
||||
"isOfficial",
|
||||
"ownerUserId",
|
||||
"ownerPublisherId",
|
||||
"ownerHandle",
|
||||
"ownerKind",
|
||||
"summary",
|
||||
"latestVersion",
|
||||
"runtimeId",
|
||||
"capabilityTags",
|
||||
"executesCode",
|
||||
"verificationTier",
|
||||
"scanStatus",
|
||||
"softDeletedAt",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
@@ -47,6 +52,7 @@ export type PackageSearchDigestFields = Pick<Doc<"packages">, (typeof SHARED_KEY
|
||||
packageId: Id<"packages">;
|
||||
latestVersion?: string;
|
||||
ownerHandle?: string;
|
||||
ownerKind?: "user" | "org";
|
||||
verificationTier?: Doc<"packageSearchDigest">["verificationTier"];
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getPackageDownloadSecurityBlock,
|
||||
isPackageBlockedFromPublic,
|
||||
resolvePackageReleaseScanStatus,
|
||||
} from "./packageSecurity";
|
||||
|
||||
describe("packageSecurity", () => {
|
||||
it("treats pending package scans as public", () => {
|
||||
expect(isPackageBlockedFromPublic("pending")).toBe(false);
|
||||
});
|
||||
|
||||
it("allows package downloads while VT is pending", () => {
|
||||
expect(
|
||||
getPackageDownloadSecurityBlock({
|
||||
sha256hash: "a".repeat(64),
|
||||
} as never),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("still resolves sha256-only releases to pending", () => {
|
||||
expect(
|
||||
resolvePackageReleaseScanStatus({
|
||||
sha256hash: "a".repeat(64),
|
||||
} as never),
|
||||
).toBe("pending");
|
||||
});
|
||||
|
||||
it("still blocks malicious package releases", () => {
|
||||
expect(isPackageBlockedFromPublic("malicious")).toBe(true);
|
||||
expect(
|
||||
getPackageDownloadSecurityBlock({
|
||||
vtAnalysis: { status: "malicious" },
|
||||
} as never),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
status: 403,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Doc } from "../_generated/dataModel";
|
||||
|
||||
export type PackageScanStatus = Doc<"packages">["scanStatus"];
|
||||
|
||||
type PackageReleaseSecurityLike = Pick<
|
||||
Doc<"packageReleases">,
|
||||
"sha256hash" | "vtAnalysis" | "verification" | "staticScan"
|
||||
>;
|
||||
|
||||
export function normalizePackageScanStatus(status: string | null | undefined): PackageScanStatus {
|
||||
switch (status?.trim().toLowerCase()) {
|
||||
case "clean":
|
||||
case "suspicious":
|
||||
case "malicious":
|
||||
case "pending":
|
||||
case "not-run":
|
||||
return status.trim().toLowerCase() as PackageScanStatus;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePackageReleaseScanStatus(
|
||||
release: PackageReleaseSecurityLike,
|
||||
): Exclude<PackageScanStatus, undefined> {
|
||||
const staticStatus = normalizePackageScanStatus(release.staticScan?.status);
|
||||
if (staticStatus === "malicious") return "malicious";
|
||||
|
||||
const vtStatus = normalizePackageScanStatus(release.vtAnalysis?.status);
|
||||
if (vtStatus === "malicious") return "malicious";
|
||||
|
||||
const verificationStatus = normalizePackageScanStatus(release.verification?.scanStatus);
|
||||
if (verificationStatus === "malicious") return "malicious";
|
||||
|
||||
if (vtStatus) return vtStatus;
|
||||
if (verificationStatus && verificationStatus !== "not-run") return verificationStatus;
|
||||
if (release.sha256hash) return "pending";
|
||||
|
||||
return verificationStatus ?? "not-run";
|
||||
}
|
||||
|
||||
export function isPackageBlockedFromPublic(scanStatus: PackageScanStatus) {
|
||||
return scanStatus === "malicious";
|
||||
}
|
||||
|
||||
export function getPackageDownloadSecurityBlock(release: PackageReleaseSecurityLike) {
|
||||
const scanStatus = resolvePackageReleaseScanStatus(release);
|
||||
|
||||
if (scanStatus === "malicious") {
|
||||
return {
|
||||
status: 403,
|
||||
message: "Blocked: this package release has been flagged as malicious and cannot be downloaded.",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -6,6 +6,11 @@ export type PublicUser = Pick<
|
||||
"_id" | "_creationTime" | "handle" | "name" | "displayName" | "image" | "bio"
|
||||
>;
|
||||
|
||||
export type PublicPublisher = Pick<
|
||||
Doc<"publishers">,
|
||||
"_id" | "_creationTime" | "kind" | "handle" | "displayName" | "image" | "bio" | "linkedUserId"
|
||||
>;
|
||||
|
||||
export type PublicSkill = Pick<
|
||||
Doc<"skills">,
|
||||
| "_id"
|
||||
@@ -14,6 +19,7 @@ export type PublicSkill = Pick<
|
||||
| "displayName"
|
||||
| "summary"
|
||||
| "ownerUserId"
|
||||
| "ownerPublisherId"
|
||||
| "canonicalSkillId"
|
||||
| "forkOf"
|
||||
| "latestVersionId"
|
||||
@@ -38,6 +44,7 @@ export type HydratableSkill = Pick<
|
||||
| "displayName"
|
||||
| "summary"
|
||||
| "ownerUserId"
|
||||
| "ownerPublisherId"
|
||||
| "canonicalSkillId"
|
||||
| "forkOf"
|
||||
| "latestVersionId"
|
||||
@@ -65,6 +72,7 @@ export type PublicSoul = Pick<
|
||||
| "displayName"
|
||||
| "summary"
|
||||
| "ownerUserId"
|
||||
| "ownerPublisherId"
|
||||
| "latestVersionId"
|
||||
| "tags"
|
||||
| "stats"
|
||||
@@ -85,6 +93,22 @@ export function toPublicUser(user: Doc<"users"> | null | undefined): PublicUser
|
||||
};
|
||||
}
|
||||
|
||||
export function toPublicPublisher(
|
||||
publisher: Doc<"publishers"> | null | undefined,
|
||||
): PublicPublisher | null {
|
||||
if (!publisher || publisher.deletedAt || publisher.deactivatedAt) return null;
|
||||
return {
|
||||
_id: publisher._id,
|
||||
_creationTime: publisher._creationTime,
|
||||
kind: publisher.kind,
|
||||
handle: publisher.handle,
|
||||
displayName: publisher.displayName,
|
||||
image: publisher.image,
|
||||
bio: publisher.bio,
|
||||
linkedUserId: publisher.linkedUserId,
|
||||
};
|
||||
}
|
||||
|
||||
export function toPublicSkill(skill: HydratableSkill | null | undefined): PublicSkill | null {
|
||||
if (!skill) return null;
|
||||
if (!isPublicSkillDoc(skill)) return null;
|
||||
@@ -112,6 +136,7 @@ export function toPublicSkill(skill: HydratableSkill | null | undefined): Public
|
||||
displayName: skill.displayName,
|
||||
summary: skill.summary,
|
||||
ownerUserId: skill.ownerUserId,
|
||||
ownerPublisherId: skill.ownerPublisherId,
|
||||
canonicalSkillId: skill.canonicalSkillId,
|
||||
forkOf: skill.forkOf,
|
||||
latestVersionId: skill.latestVersionId,
|
||||
@@ -132,6 +157,7 @@ export function toPublicSoul(soul: Doc<"souls"> | null | undefined): PublicSoul
|
||||
displayName: soul.displayName,
|
||||
summary: soul.summary,
|
||||
ownerUserId: soul.ownerUserId,
|
||||
ownerPublisherId: soul.ownerPublisherId,
|
||||
latestVersionId: soul.latestVersionId,
|
||||
tags: soul.tags,
|
||||
stats: soul.stats,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
findOversizedPublishFile,
|
||||
getPublishFileSizeError,
|
||||
getPublishTotalSizeError,
|
||||
MAX_PUBLISH_FILE_BYTES,
|
||||
} from "./publishLimits";
|
||||
|
||||
describe("publishLimits", () => {
|
||||
it("finds files over the max publish file size", () => {
|
||||
expect(
|
||||
findOversizedPublishFile([
|
||||
{ path: "small.txt", size: 128 },
|
||||
{ path: "big.txt", size: MAX_PUBLISH_FILE_BYTES + 1 },
|
||||
]),
|
||||
).toEqual({
|
||||
path: "big.txt",
|
||||
size: MAX_PUBLISH_FILE_BYTES + 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("formats user-facing size errors", () => {
|
||||
expect(getPublishFileSizeError("dist/plugin.wasm")).toBe(
|
||||
'File "dist/plugin.wasm" exceeds 10MB limit',
|
||||
);
|
||||
expect(getPublishTotalSizeError("package")).toBe("Package exceeds 50MB limit");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
export const MAX_PUBLISH_TOTAL_BYTES = 50 * 1024 * 1024;
|
||||
export const MAX_PUBLISH_FILE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
type SizedPathLike = {
|
||||
path: string;
|
||||
size: number;
|
||||
};
|
||||
|
||||
export function findOversizedPublishFile<TFile extends SizedPathLike>(files: TFile[]) {
|
||||
return files.find((file) => file.size > MAX_PUBLISH_FILE_BYTES) ?? null;
|
||||
}
|
||||
|
||||
export function getPublishFileSizeError(path: string) {
|
||||
return `File "${path}" exceeds 10MB limit`;
|
||||
}
|
||||
|
||||
export function getPublishTotalSizeError(target: "skill bundle" | "package") {
|
||||
return `${target[0]?.toUpperCase() ?? ""}${target.slice(1)} exceeds 50MB limit`;
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import { ConvexError } from "convex/values";
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
import type { MutationCtx, QueryCtx } from "../_generated/server";
|
||||
|
||||
export type PublisherRole = "owner" | "admin" | "publisher";
|
||||
|
||||
type DbCtx = Pick<QueryCtx | MutationCtx, "db">;
|
||||
|
||||
function isMissingPublisherTableError(error: unknown) {
|
||||
if (!(error instanceof Error)) return false;
|
||||
return (
|
||||
/unexpected (query |insert )?table:? (publishers|publishermembers)/i.test(error.message) ||
|
||||
/innerdb\.(insert|patch) is not a function/i.test(error.message)
|
||||
);
|
||||
}
|
||||
|
||||
function derivePersonalPublisherHandle(user: Doc<"users">) {
|
||||
const emailLocalPart = user.email?.split("@")[0];
|
||||
const userIdSuffix = String(user._id).split(":").pop();
|
||||
return (
|
||||
normalizePublisherHandle(user.handle ?? user.name ?? emailLocalPart ?? userIdSuffix) ??
|
||||
"user"
|
||||
);
|
||||
}
|
||||
|
||||
function synthesizePersonalPublisher(user: Doc<"users">): Doc<"publishers"> {
|
||||
const handle = derivePersonalPublisherHandle(user);
|
||||
const now = user.updatedAt ?? user.createdAt ?? user._creationTime;
|
||||
return {
|
||||
_id: (user.personalPublisherId ?? (`publishers:${handle}` as Id<"publishers">)) as Id<"publishers">,
|
||||
_creationTime: user._creationTime,
|
||||
kind: "user",
|
||||
handle,
|
||||
displayName: user.displayName?.trim() || user.name?.trim() || handle,
|
||||
bio: user.bio?.trim() || undefined,
|
||||
image: user.image,
|
||||
linkedUserId: user._id,
|
||||
trustedPublisher: user.trustedPublisher,
|
||||
createdAt: user.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPersonalPublisherForUserOrFallback(
|
||||
ctx: DbCtx,
|
||||
user: Doc<"users">,
|
||||
) {
|
||||
if (user.personalPublisherId) {
|
||||
const publisher = await ctx.db.get(user.personalPublisherId);
|
||||
if (isPublisherActive(publisher)) return publisher;
|
||||
}
|
||||
try {
|
||||
const publisher = await getPersonalPublisherForUser(ctx, user._id);
|
||||
if (isPublisherActive(publisher)) return publisher;
|
||||
} catch (error) {
|
||||
if (!isMissingPublisherTableError(error)) throw error;
|
||||
}
|
||||
return synthesizePersonalPublisher(user);
|
||||
}
|
||||
|
||||
export function normalizePublisherHandle(handle: string | undefined | null) {
|
||||
const normalized = handle?.trim().replace(/^@+/, "").toLowerCase();
|
||||
return normalized ? normalized : undefined;
|
||||
}
|
||||
|
||||
export function isPublisherActive(
|
||||
publisher: Pick<Doc<"publishers">, "deletedAt" | "deactivatedAt"> | null | undefined,
|
||||
) {
|
||||
return Boolean(publisher && !publisher.deletedAt && !publisher.deactivatedAt);
|
||||
}
|
||||
|
||||
export function isPublisherRoleAllowed(role: PublisherRole, allowed: PublisherRole[]) {
|
||||
const ranks: Record<PublisherRole, number> = {
|
||||
publisher: 1,
|
||||
admin: 2,
|
||||
owner: 3,
|
||||
};
|
||||
return allowed.some((candidate) => ranks[role] >= ranks[candidate]);
|
||||
}
|
||||
|
||||
export async function getPublisherByHandle(
|
||||
ctx: DbCtx,
|
||||
handle: string | undefined | null,
|
||||
) {
|
||||
const normalized = normalizePublisherHandle(handle);
|
||||
if (!normalized) return null;
|
||||
try {
|
||||
return await ctx.db
|
||||
.query("publishers")
|
||||
.withIndex("by_handle", (q) => q.eq("handle", normalized))
|
||||
.unique();
|
||||
} catch (error) {
|
||||
if (isMissingPublisherTableError(error)) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPersonalPublisherForUser(
|
||||
ctx: DbCtx,
|
||||
userId: Id<"users">,
|
||||
) {
|
||||
try {
|
||||
return await ctx.db
|
||||
.query("publishers")
|
||||
.withIndex("by_linked_user", (q) => q.eq("linkedUserId", userId))
|
||||
.unique();
|
||||
} catch (error) {
|
||||
if (isMissingPublisherTableError(error)) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensurePersonalPublisherForUser(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
user: Doc<"users">,
|
||||
) {
|
||||
const handle = derivePersonalPublisherHandle(user);
|
||||
let existing: Doc<"publishers"> | null = null;
|
||||
try {
|
||||
existing =
|
||||
user.personalPublisherId
|
||||
? await ctx.db.get(user.personalPublisherId)
|
||||
: await getPersonalPublisherForUser(ctx, user._id);
|
||||
} catch (error) {
|
||||
if (!isMissingPublisherTableError(error)) throw error;
|
||||
return synthesizePersonalPublisher(user);
|
||||
}
|
||||
if (existing && isPublisherActive(existing)) {
|
||||
const existingPublisher = existing;
|
||||
const now = Date.now();
|
||||
const conflict = await getPublisherByHandle(ctx, handle);
|
||||
if (conflict && conflict._id !== existingPublisher._id) {
|
||||
throw new ConvexError(`Publisher handle "@${handle}" is already claimed`);
|
||||
}
|
||||
try {
|
||||
await ctx.db.patch(existingPublisher._id, {
|
||||
handle,
|
||||
displayName: user.displayName?.trim() || user.name?.trim() || handle,
|
||||
bio: user.bio?.trim() || undefined,
|
||||
image: user.image,
|
||||
linkedUserId: user._id,
|
||||
trustedPublisher: user.trustedPublisher,
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
updatedAt: now,
|
||||
});
|
||||
if (user.personalPublisherId !== existingPublisher._id) {
|
||||
await ctx.db.patch(user._id, {
|
||||
personalPublisherId: existingPublisher._id,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
const existingMember = await ctx.db
|
||||
.query("publisherMembers")
|
||||
.withIndex("by_publisher_user", (q) =>
|
||||
q.eq("publisherId", existingPublisher._id).eq("userId", user._id),
|
||||
)
|
||||
.unique();
|
||||
if (!existingMember) {
|
||||
await ctx.db.insert("publisherMembers", {
|
||||
publisherId: existingPublisher._id,
|
||||
userId: user._id,
|
||||
role: "owner",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
return await ctx.db.get(existingPublisher._id);
|
||||
} catch (error) {
|
||||
if (isMissingPublisherTableError(error)) return synthesizePersonalPublisher(user);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const conflict = await getPublisherByHandle(ctx, handle);
|
||||
if (conflict && conflict.linkedUserId !== user._id) {
|
||||
throw new ConvexError(`Publisher handle "@${handle}" is already claimed`);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
try {
|
||||
const publisherId =
|
||||
conflict?._id ??
|
||||
(await ctx.db.insert("publishers", {
|
||||
kind: "user",
|
||||
handle,
|
||||
displayName: user.displayName?.trim() || user.name?.trim() || handle,
|
||||
bio: user.bio?.trim() || undefined,
|
||||
image: user.image,
|
||||
linkedUserId: user._id,
|
||||
trustedPublisher: user.trustedPublisher,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}));
|
||||
|
||||
if (conflict) {
|
||||
await ctx.db.patch(conflict._id, {
|
||||
displayName: user.displayName?.trim() || user.name?.trim() || handle,
|
||||
bio: user.bio?.trim() || undefined,
|
||||
image: user.image,
|
||||
linkedUserId: user._id,
|
||||
trustedPublisher: user.trustedPublisher,
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
const existingMember = await ctx.db
|
||||
.query("publisherMembers")
|
||||
.withIndex("by_publisher_user", (q) => q.eq("publisherId", publisherId).eq("userId", user._id))
|
||||
.unique();
|
||||
if (!existingMember) {
|
||||
await ctx.db.insert("publisherMembers", {
|
||||
publisherId,
|
||||
userId: user._id,
|
||||
role: "owner",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.patch(user._id, {
|
||||
personalPublisherId: publisherId,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
return await ctx.db.get(publisherId);
|
||||
} catch (error) {
|
||||
if (isMissingPublisherTableError(error)) return synthesizePersonalPublisher(user);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPublisherMembership(
|
||||
ctx: DbCtx,
|
||||
publisherId: Id<"publishers">,
|
||||
userId: Id<"users">,
|
||||
) {
|
||||
try {
|
||||
return await ctx.db
|
||||
.query("publisherMembers")
|
||||
.withIndex("by_publisher_user", (q) => q.eq("publisherId", publisherId).eq("userId", userId))
|
||||
.unique();
|
||||
} catch (error) {
|
||||
if (isMissingPublisherTableError(error)) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function requirePublisherRole(
|
||||
ctx: DbCtx,
|
||||
params: {
|
||||
publisherId: Id<"publishers">;
|
||||
userId: Id<"users">;
|
||||
allowed: PublisherRole[];
|
||||
},
|
||||
) {
|
||||
const publisher = await ctx.db.get(params.publisherId);
|
||||
if (!isPublisherActive(publisher)) throw new ConvexError("Publisher not found");
|
||||
const membership = await getPublisherMembership(ctx, params.publisherId, params.userId);
|
||||
if (!membership || !isPublisherRoleAllowed(membership.role, params.allowed)) {
|
||||
throw new ConvexError("Forbidden");
|
||||
}
|
||||
return { publisher, membership };
|
||||
}
|
||||
|
||||
export async function resolvePublisherForActor(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
params: {
|
||||
actor: Doc<"users">;
|
||||
ownerHandle?: string | null;
|
||||
allowed: PublisherRole[];
|
||||
},
|
||||
) {
|
||||
const personalPublisher = await ensurePersonalPublisherForUser(ctx, params.actor);
|
||||
const requestedHandle = normalizePublisherHandle(params.ownerHandle);
|
||||
if (!requestedHandle) {
|
||||
return personalPublisher;
|
||||
}
|
||||
if (requestedHandle === personalPublisher?.handle) return personalPublisher;
|
||||
|
||||
const publisher = await getPublisherByHandle(ctx, requestedHandle);
|
||||
if (!publisher || !isPublisherActive(publisher)) {
|
||||
throw new ConvexError(`Publisher "@${requestedHandle}" not found`);
|
||||
}
|
||||
const membership = await getPublisherMembership(ctx, publisher._id, params.actor._id);
|
||||
if (!membership || !isPublisherRoleAllowed(membership.role, params.allowed)) {
|
||||
throw new ConvexError(`You do not have publish access for "@${requestedHandle}"`);
|
||||
}
|
||||
return publisher;
|
||||
}
|
||||
|
||||
export async function getOwnerPublisher(
|
||||
ctx: DbCtx,
|
||||
params: {
|
||||
ownerPublisherId?: Id<"publishers"> | null;
|
||||
ownerUserId?: Id<"users"> | null;
|
||||
},
|
||||
) {
|
||||
if (params.ownerPublisherId) {
|
||||
const publisher = await ctx.db.get(params.ownerPublisherId);
|
||||
if (isPublisherActive(publisher)) return publisher;
|
||||
}
|
||||
if (!params.ownerUserId) return null;
|
||||
const user = await ctx.db.get(params.ownerUserId);
|
||||
if (!user || user.deletedAt || user.deactivatedAt) return null;
|
||||
return await getPersonalPublisherForUserOrFallback(ctx, user);
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import { getSkillBadgeMap, isSkillHighlighted } from "./badges";
|
||||
import { generateChangelogForPublish } from "./changelog";
|
||||
import { generateEmbedding } from "./embeddings";
|
||||
import { requireGitHubAccountAge } from "./githubAccount";
|
||||
import { runStaticModerationScan } from "./moderationEngine";
|
||||
import type { PublicUser } from "./public";
|
||||
import {
|
||||
computeQualitySignals,
|
||||
@@ -28,9 +27,15 @@ import {
|
||||
sanitizePath,
|
||||
} from "./skills";
|
||||
import { generateSkillSummary } from "./skillSummary";
|
||||
import { runStaticPublishScan } from "./staticPublishScan";
|
||||
import type { WebhookSkillPayload } from "./webhooks";
|
||||
import {
|
||||
findOversizedPublishFile,
|
||||
getPublishFileSizeError,
|
||||
getPublishTotalSizeError,
|
||||
MAX_PUBLISH_TOTAL_BYTES,
|
||||
} from "./publishLimits";
|
||||
|
||||
const MAX_TOTAL_BYTES = 50 * 1024 * 1024;
|
||||
const MAX_FILES_FOR_EMBEDDING = 40;
|
||||
const QUALITY_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
const QUALITY_ACTIVITY_LIMIT = 60;
|
||||
@@ -73,6 +78,7 @@ export type PublishOptions = {
|
||||
bypassQualityGate?: boolean;
|
||||
skipBackup?: boolean;
|
||||
skipWebhook?: boolean;
|
||||
ownerPublisherId?: Id<"publishers">;
|
||||
};
|
||||
|
||||
export async function publishVersionForUser(
|
||||
@@ -119,9 +125,14 @@ export async function publishVersionForUser(
|
||||
throw new ConvexError("Only text-based files are allowed");
|
||||
}
|
||||
|
||||
const oversizedFile = findOversizedPublishFile(publishFiles);
|
||||
if (oversizedFile) {
|
||||
throw new ConvexError(getPublishFileSizeError(oversizedFile.path));
|
||||
}
|
||||
|
||||
const totalBytes = publishFiles.reduce((sum, file) => sum + file.size, 0);
|
||||
if (totalBytes > MAX_TOTAL_BYTES) {
|
||||
throw new ConvexError("Skill bundle exceeds 50MB limit");
|
||||
if (totalBytes > MAX_PUBLISH_TOTAL_BYTES) {
|
||||
throw new ConvexError(getPublishTotalSizeError("skill bundle"));
|
||||
}
|
||||
|
||||
const readmeFile = publishFiles.find(
|
||||
@@ -221,14 +232,13 @@ export async function publishVersionForUser(
|
||||
.filter((file) => !file.path.toLowerCase().endsWith(".md"))
|
||||
.slice(0, MAX_FILES_FOR_EMBEDDING);
|
||||
|
||||
const staticScan = runStaticModerationScan({
|
||||
const staticScan = await runStaticPublishScan(ctx, {
|
||||
slug,
|
||||
displayName,
|
||||
summary,
|
||||
frontmatter,
|
||||
metadata,
|
||||
files: publishFiles.map((file) => ({ path: file.path, size: file.size })),
|
||||
fileContents,
|
||||
files: publishFiles,
|
||||
});
|
||||
|
||||
const embeddingText = buildEmbeddingText({
|
||||
@@ -263,6 +273,7 @@ export async function publishVersionForUser(
|
||||
|
||||
const publishResult = (await ctx.runMutation(internal.skills.insertVersion, {
|
||||
userId,
|
||||
ownerPublisherId: options.ownerPublisherId,
|
||||
slug,
|
||||
displayName,
|
||||
version,
|
||||
|
||||
@@ -157,13 +157,14 @@ describe("digestToOwnerInfo", () => {
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.ownerHandle).toBe("jdoe");
|
||||
expect(result!.owner).toEqual({
|
||||
_id: "users:owner",
|
||||
_id: "publishers:missing",
|
||||
_creationTime: 0,
|
||||
kind: "user",
|
||||
handle: "jdoe",
|
||||
name: "John",
|
||||
displayName: "John Doe",
|
||||
image: "https://example.com/avatar.png",
|
||||
bio: undefined,
|
||||
linkedUserId: "users:owner",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -190,13 +191,14 @@ describe("digestToOwnerInfo", () => {
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.ownerHandle).toBe("users:owner");
|
||||
expect(result!.owner).toEqual({
|
||||
_id: "users:owner",
|
||||
_id: "publishers:missing",
|
||||
_creationTime: 0,
|
||||
handle: undefined,
|
||||
name: "No Handle User",
|
||||
kind: "user",
|
||||
handle: "users:owner",
|
||||
displayName: "No Handle",
|
||||
image: "https://example.com/avatar.png",
|
||||
bio: undefined,
|
||||
linkedUserId: "users:owner",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
import type { MutationCtx } from "../_generated/server";
|
||||
import type { HydratableSkill, PublicUser } from "./public";
|
||||
import type { HydratableSkill, PublicPublisher } from "./public";
|
||||
|
||||
function pick<T extends Record<string, unknown>, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
|
||||
return Object.fromEntries(keys.map((k) => [k, obj[k]])) as Pick<T, K>;
|
||||
@@ -16,6 +16,7 @@ const SHARED_KEYS = [
|
||||
"displayName",
|
||||
"summary",
|
||||
"ownerUserId",
|
||||
"ownerPublisherId",
|
||||
"canonicalSkillId",
|
||||
"forkOf",
|
||||
"latestVersionId",
|
||||
@@ -40,6 +41,7 @@ export type SkillSearchDigestFields = Pick<Doc<"skills">, (typeof SHARED_KEYS)[n
|
||||
skillId: Id<"skills">;
|
||||
isSuspicious?: boolean;
|
||||
ownerHandle?: string;
|
||||
ownerKind?: "user" | "org";
|
||||
ownerName?: string;
|
||||
ownerDisplayName?: string;
|
||||
ownerImage?: string;
|
||||
@@ -106,14 +108,22 @@ function hasDigestChanged(
|
||||
export function digestToOwnerInfo(
|
||||
digest: Pick<
|
||||
Doc<"skillSearchDigest">,
|
||||
"ownerHandle" | "ownerName" | "ownerDisplayName" | "ownerImage" | "ownerUserId"
|
||||
| "ownerHandle"
|
||||
| "ownerKind"
|
||||
| "ownerName"
|
||||
| "ownerDisplayName"
|
||||
| "ownerImage"
|
||||
| "ownerUserId"
|
||||
| "ownerPublisherId"
|
||||
>,
|
||||
): { ownerHandle: string | null; owner: PublicUser | null } | null {
|
||||
): { ownerHandle: string | null; owner: PublicPublisher | null } | null {
|
||||
if (digest.ownerHandle === undefined) return null;
|
||||
// Empty string means backfilled but owner has no handle.
|
||||
// Use userId as fallback handle, matching the live getOwnerInfo path.
|
||||
const handle = digest.ownerHandle || undefined;
|
||||
const fallbackHandle = handle ?? String(digest.ownerUserId);
|
||||
const fallbackHandle =
|
||||
handle ?? String(digest.ownerPublisherId ?? digest.ownerUserId);
|
||||
const resolvedHandle = handle ?? fallbackHandle;
|
||||
// Determine if we have real profile data (deactivated/deleted owners have
|
||||
// all profile fields undefined, while handle-less visible owners still have
|
||||
// name/displayName/image populated).
|
||||
@@ -126,13 +136,14 @@ export function digestToOwnerInfo(
|
||||
owner:
|
||||
handle || hasProfileData
|
||||
? {
|
||||
_id: digest.ownerUserId,
|
||||
_id: digest.ownerPublisherId ?? ("publishers:missing" as Id<"publishers">),
|
||||
_creationTime: 0,
|
||||
handle,
|
||||
name: digest.ownerName,
|
||||
displayName: digest.ownerDisplayName,
|
||||
handle: resolvedHandle,
|
||||
displayName: digest.ownerDisplayName ?? digest.ownerName ?? resolvedHandle,
|
||||
image: digest.ownerImage,
|
||||
bio: undefined,
|
||||
kind: digest.ownerKind ?? "user",
|
||||
linkedUserId: digest.ownerKind === "org" ? undefined : digest.ownerUserId,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ActionCtx } from "../_generated/server";
|
||||
import { runStaticModerationScan, type StaticScanResult } from "./moderationEngine";
|
||||
import { readStorageText } from "./packageRegistry";
|
||||
import { isTextFile } from "./skills";
|
||||
|
||||
type PublishFile = {
|
||||
path: string;
|
||||
size: number;
|
||||
storageId: string;
|
||||
contentType?: string;
|
||||
};
|
||||
|
||||
type StaticPublishScanInput = {
|
||||
slug: string;
|
||||
displayName: string;
|
||||
summary?: string;
|
||||
frontmatter?: Record<string, unknown>;
|
||||
metadata?: unknown;
|
||||
files: PublishFile[];
|
||||
};
|
||||
|
||||
export async function runStaticPublishScan(
|
||||
ctx: Pick<ActionCtx, "storage">,
|
||||
input: StaticPublishScanInput,
|
||||
): Promise<StaticScanResult> {
|
||||
const fileContents: Array<{ path: string; content: string }> = [];
|
||||
for (const file of input.files) {
|
||||
if (!isTextFile(file.path, file.contentType ?? undefined)) continue;
|
||||
const content = await readStorageText(ctx, file.storageId);
|
||||
fileContents.push({ path: file.path, content });
|
||||
}
|
||||
|
||||
return runStaticModerationScan({
|
||||
slug: input.slug,
|
||||
displayName: input.displayName,
|
||||
summary: input.summary,
|
||||
frontmatter: input.frontmatter ?? {},
|
||||
metadata: input.metadata,
|
||||
files: input.files.map((file) => ({ path: file.path, size: file.size })),
|
||||
fileContents,
|
||||
});
|
||||
}
|
||||
@@ -20,6 +20,30 @@ import {
|
||||
SECURITY_EVALUATOR_SYSTEM_PROMPT,
|
||||
} from "./lib/securityPrompt";
|
||||
|
||||
const internalRefs = internal as unknown as {
|
||||
packages: {
|
||||
getReleaseByIdInternal: unknown;
|
||||
getPackageByIdInternal: unknown;
|
||||
updateReleaseLlmAnalysisInternal: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
async function runQueryRef<T>(
|
||||
ctx: { runQuery: (ref: never, args: never) => Promise<unknown> },
|
||||
ref: unknown,
|
||||
args: unknown,
|
||||
): Promise<T> {
|
||||
return (await ctx.runQuery(ref as never, args as never)) as T;
|
||||
}
|
||||
|
||||
async function runMutationRef<T>(
|
||||
ctx: { runMutation: (ref: never, args: never) => Promise<unknown> },
|
||||
ref: unknown,
|
||||
args: unknown,
|
||||
): Promise<T> {
|
||||
return (await ctx.runMutation(ref as never, args as never)) as T;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -249,6 +273,176 @@ export const evaluateWithLlm = internalAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const evaluatePackageReleaseWithLlm = internalAction({
|
||||
args: {
|
||||
releaseId: v.id("packageReleases"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
if (!apiKey) {
|
||||
console.log("[llmEval] OPENAI_API_KEY not configured, skipping package evaluation");
|
||||
return;
|
||||
}
|
||||
|
||||
const model = getLlmEvalModel();
|
||||
const storeError = async (message: string) => {
|
||||
console.error(`[llmEval:package] ${message}`);
|
||||
await runMutationRef(ctx, internalRefs.packages.updateReleaseLlmAnalysisInternal, {
|
||||
releaseId: args.releaseId,
|
||||
llmAnalysis: {
|
||||
status: "error",
|
||||
summary: message,
|
||||
model,
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const release = (await runQueryRef(ctx, internalRefs.packages.getReleaseByIdInternal, {
|
||||
releaseId: args.releaseId,
|
||||
})) as Doc<"packageReleases"> | null;
|
||||
if (!release || release.softDeletedAt) {
|
||||
await storeError(`Release ${args.releaseId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const pkg = (await runQueryRef(ctx, internalRefs.packages.getPackageByIdInternal, {
|
||||
packageId: release.packageId,
|
||||
})) as Doc<"packages"> | null;
|
||||
if (!pkg) {
|
||||
await storeError(`Package ${release.packageId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
let readmeContent = "";
|
||||
const fileContents: Array<{ path: string; content: string }> = [];
|
||||
for (const f of release.files) {
|
||||
try {
|
||||
const blob = await ctx.storage.get(f.storageId as Id<"_storage">);
|
||||
if (!blob) continue;
|
||||
const content = await blob.text();
|
||||
fileContents.push({ path: f.path, content });
|
||||
const lower = f.path.toLowerCase();
|
||||
if (!readmeContent && (lower === "readme.md" || lower === "readme.mdx" || lower === "readme.markdown")) {
|
||||
readmeContent = content;
|
||||
}
|
||||
} catch {
|
||||
// Best-effort read.
|
||||
}
|
||||
}
|
||||
|
||||
if (!readmeContent) {
|
||||
const packageJsonText = fileContents.find((entry) => entry.path.toLowerCase() === "package.json")?.content;
|
||||
readmeContent = packageJsonText ?? `# ${pkg.displayName}\n\n${release.summary ?? pkg.summary ?? pkg.name}`;
|
||||
}
|
||||
|
||||
const allContent = [readmeContent, ...fileContents.map((f) => f.content)].join("\n");
|
||||
const injectionSignals = detectInjectionPatterns(allContent);
|
||||
|
||||
const evalCtx: SkillEvalContext = {
|
||||
slug: pkg.name,
|
||||
displayName: pkg.displayName,
|
||||
ownerUserId: String(pkg.ownerUserId),
|
||||
version: release.version,
|
||||
createdAt: release.createdAt,
|
||||
summary: release.summary ?? pkg.summary ?? undefined,
|
||||
source: pkg.sourceRepo ?? undefined,
|
||||
homepage: undefined,
|
||||
parsed: {
|
||||
frontmatter: {},
|
||||
metadata: {
|
||||
compatibility: release.compatibility,
|
||||
capabilities: release.capabilities,
|
||||
verification: release.verification,
|
||||
staticScan: release.staticScan,
|
||||
},
|
||||
},
|
||||
files: release.files.map((f) => ({ path: f.path, size: f.size })),
|
||||
skillMdContent: readmeContent,
|
||||
fileContents,
|
||||
injectionSignals,
|
||||
};
|
||||
|
||||
const userMessage = assembleEvalUserMessage(evalCtx);
|
||||
const MAX_RETRIES = 3;
|
||||
let raw: string | null = null;
|
||||
try {
|
||||
const body = JSON.stringify({
|
||||
model,
|
||||
instructions: SECURITY_EVALUATOR_SYSTEM_PROMPT,
|
||||
input: userMessage,
|
||||
max_output_tokens: LLM_EVAL_MAX_OUTPUT_TOKENS,
|
||||
text: {
|
||||
format: {
|
||||
type: "json_object",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let response: Response | null = null;
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
response = await fetch("https://api.openai.com/v1/responses", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (response.status === 429 || response.status >= 500) {
|
||||
if (attempt < MAX_RETRIES) {
|
||||
const delay = 2 ** attempt * 2000 + Math.random() * 1000;
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (!response || !response.ok) {
|
||||
const errorText = response ? await response.text() : "No response";
|
||||
await storeError(`OpenAI API error (${response?.status}): ${errorText.slice(0, 200)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as unknown;
|
||||
raw = extractResponseText(payload);
|
||||
} catch (error) {
|
||||
await storeError(
|
||||
`OpenAI API call failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!raw) {
|
||||
await storeError("Empty response from OpenAI");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = parseLlmEvalResponse(raw);
|
||||
if (!result) {
|
||||
await storeError("Failed to parse LLM evaluation response");
|
||||
return;
|
||||
}
|
||||
|
||||
await runMutationRef(ctx, internalRefs.packages.updateReleaseLlmAnalysisInternal, {
|
||||
releaseId: args.releaseId,
|
||||
llmAnalysis: {
|
||||
status: verdictToStatus(result.verdict),
|
||||
verdict: result.verdict,
|
||||
confidence: result.confidence,
|
||||
summary: result.summary,
|
||||
dimensions: result.dimensions,
|
||||
guidance: result.guidance,
|
||||
findings: result.findings || undefined,
|
||||
model,
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Convenience: evaluate a single skill by slug (for testing / manual runs)
|
||||
// Usage: npx convex run llmEval:evaluateBySlug '{"slug": "transcribeexx"}'
|
||||
|
||||
+1166
-14
File diff suppressed because it is too large
Load Diff
+880
-80
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,511 @@
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
addMember,
|
||||
listMine,
|
||||
migrateLegacyPublisherHandleToOrgInternal,
|
||||
removeMember,
|
||||
} from "./publishers";
|
||||
|
||||
vi.mock("@convex-dev/auth/server", () => ({
|
||||
getAuthUserId: vi.fn(),
|
||||
}));
|
||||
|
||||
type WrappedHandler<TArgs, TResult = unknown> = {
|
||||
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
|
||||
};
|
||||
|
||||
const addMemberHandler = (
|
||||
addMember as unknown as WrappedHandler<
|
||||
{ publisherId: string; userHandle: string; role: "owner" | "admin" | "publisher" }
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const removeMemberHandler = (
|
||||
removeMember as unknown as WrappedHandler<{ publisherId: string; userId: string }>
|
||||
)._handler;
|
||||
|
||||
const migrateLegacyPublisherHandleToOrgInternalHandler = (
|
||||
migrateLegacyPublisherHandleToOrgInternal as unknown as WrappedHandler<
|
||||
{
|
||||
actorUserId: string;
|
||||
handle: string;
|
||||
fallbackUserHandle?: string;
|
||||
displayName?: string;
|
||||
},
|
||||
{
|
||||
ok: true;
|
||||
handle: string;
|
||||
orgPublisherId: string;
|
||||
legacyUserId: string;
|
||||
fallbackUserHandle: string;
|
||||
personalPublisherId: string | null;
|
||||
convertedExistingPublisher: boolean;
|
||||
packagesMigrated: number;
|
||||
}
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const listMineHandler = (
|
||||
listMine as unknown as WrappedHandler<Record<string, never>, Array<unknown>>
|
||||
)._handler;
|
||||
|
||||
describe("publishers membership controls", () => {
|
||||
it("prevents admins from promoting members to owner", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:admin" as never);
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "users:admin") return { _id: id };
|
||||
if (id === "publishers:org") {
|
||||
return {
|
||||
_id: id,
|
||||
kind: "org",
|
||||
handle: "acme",
|
||||
displayName: "Acme",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn().mockResolvedValue({
|
||||
_id: "publisherMembers:admin",
|
||||
publisherId: "publishers:org",
|
||||
userId: "users:admin",
|
||||
role: "admin",
|
||||
}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
insert: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
normalizeId: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
addMemberHandler(
|
||||
ctx as never,
|
||||
{ publisherId: "publishers:org", userHandle: "peter", role: "owner" } as never,
|
||||
),
|
||||
).rejects.toThrow("Only org owners can promote members to owner");
|
||||
});
|
||||
|
||||
it("prevents removing the last remaining owner", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "users:owner") return { _id: id };
|
||||
if (id === "publishers:org") {
|
||||
return {
|
||||
_id: id,
|
||||
kind: "org",
|
||||
handle: "acme",
|
||||
displayName: "Acme",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: vi.fn((indexName: string) => {
|
||||
if (indexName === "by_publisher_user") {
|
||||
return {
|
||||
unique: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: "publisherMembers:owner-actor",
|
||||
publisherId: "publishers:org",
|
||||
userId: "users:owner",
|
||||
role: "owner",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
_id: "publisherMembers:owner-target",
|
||||
publisherId: "publishers:org",
|
||||
userId: "users:owner",
|
||||
role: "owner",
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (indexName === "by_publisher") {
|
||||
return {
|
||||
collect: vi.fn().mockResolvedValue([
|
||||
{
|
||||
_id: "publisherMembers:owner-target",
|
||||
publisherId: "publishers:org",
|
||||
userId: "users:owner",
|
||||
role: "owner",
|
||||
},
|
||||
]),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected index ${indexName}`);
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
delete: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
normalizeId: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
removeMemberHandler(
|
||||
ctx as never,
|
||||
{ publisherId: "publishers:org", userId: "users:owner" } as never,
|
||||
),
|
||||
).rejects.toThrow("Publisher must have at least one owner");
|
||||
});
|
||||
});
|
||||
|
||||
describe("publisher bootstrap", () => {
|
||||
it("lists a synthesized personal publisher when membership rows are missing", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:alice" as never);
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "users:alice") {
|
||||
return {
|
||||
_id: id,
|
||||
_creationTime: 1,
|
||||
handle: "alice",
|
||||
displayName: "Alice",
|
||||
trustedPublisher: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: vi.fn((indexName: string) => {
|
||||
if (indexName !== "by_user") throw new Error(`unexpected index ${indexName}`);
|
||||
return { collect: vi.fn().mockResolvedValue([]) };
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: vi.fn((indexName: string) => {
|
||||
if (indexName !== "by_linked_user") {
|
||||
throw new Error(`unexpected index ${indexName}`);
|
||||
}
|
||||
return { unique: vi.fn().mockResolvedValue(null) };
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(listMineHandler(ctx as never, {} as never)).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
role: "owner",
|
||||
publisher: expect.objectContaining({
|
||||
handle: "alice",
|
||||
kind: "user",
|
||||
linkedUserId: "users:alice",
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy publisher migration", () => {
|
||||
it("converts a legacy personal publisher into an org and rehomes package ownership", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
||||
|
||||
const users = new Map<string, Record<string, unknown>>([
|
||||
["users:admin", { _id: "users:admin", role: "admin" }],
|
||||
[
|
||||
"users:openclaw",
|
||||
{
|
||||
_id: "users:openclaw",
|
||||
_creationTime: 1,
|
||||
handle: "openclaw",
|
||||
displayName: "OpenClaw",
|
||||
trustedPublisher: true,
|
||||
personalPublisherId: "publishers:openclaw",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
],
|
||||
]);
|
||||
const publishers = new Map<string, Record<string, unknown>>([
|
||||
[
|
||||
"publishers:openclaw",
|
||||
{
|
||||
_id: "publishers:openclaw",
|
||||
_creationTime: 1,
|
||||
kind: "user",
|
||||
handle: "openclaw",
|
||||
displayName: "OpenClaw",
|
||||
linkedUserId: "users:openclaw",
|
||||
trustedPublisher: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
],
|
||||
[
|
||||
"publishers:openclaw-user",
|
||||
{
|
||||
_id: "publishers:openclaw-user",
|
||||
_creationTime: 1,
|
||||
kind: "user",
|
||||
handle: "openclaw-user",
|
||||
displayName: "OpenClaw User",
|
||||
linkedUserId: "users:openclaw",
|
||||
trustedPublisher: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
],
|
||||
]);
|
||||
const packages = [
|
||||
{
|
||||
_id: "packages:demo",
|
||||
ownerUserId: "users:openclaw",
|
||||
ownerPublisherId: undefined,
|
||||
updatedAt: 1,
|
||||
},
|
||||
];
|
||||
const publisherMembers = [
|
||||
{
|
||||
_id: "publisherMembers:openclaw-owner",
|
||||
publisherId: "publishers:openclaw",
|
||||
userId: "users:openclaw",
|
||||
role: "owner",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const patch = vi.fn(async (id: string, value: Record<string, unknown>) => {
|
||||
if (users.has(id)) {
|
||||
users.set(id, { ...users.get(id), ...value });
|
||||
return;
|
||||
}
|
||||
if (publishers.has(id)) {
|
||||
publishers.set(id, { ...publishers.get(id), ...value });
|
||||
return;
|
||||
}
|
||||
const pkg = packages.find((entry) => entry._id === id);
|
||||
if (pkg) {
|
||||
Object.assign(pkg, value);
|
||||
return;
|
||||
}
|
||||
const member = publisherMembers.find((entry) => entry._id === id);
|
||||
if (member) {
|
||||
Object.assign(member, value);
|
||||
return;
|
||||
}
|
||||
throw new Error(`unexpected patch ${id}`);
|
||||
});
|
||||
|
||||
const insert = vi.fn(async (table: string, value: Record<string, unknown>) => {
|
||||
if (table === "publishers") {
|
||||
const id = "publishers:openclaw-user";
|
||||
publishers.set(id, { _id: id, _creationTime: 1, ...value });
|
||||
return id;
|
||||
}
|
||||
if (table === "publisherMembers") {
|
||||
const id = `publisherMembers:${publisherMembers.length + 1}`;
|
||||
publisherMembers.push({
|
||||
_id: id,
|
||||
publisherId: String(value.publisherId),
|
||||
userId: String(value.userId),
|
||||
role: String(value.role),
|
||||
createdAt: Number(value.createdAt),
|
||||
updatedAt: Number(value.updatedAt),
|
||||
});
|
||||
return id;
|
||||
}
|
||||
if (table === "auditLogs") return "auditLogs:1";
|
||||
throw new Error(`unexpected insert ${table}`);
|
||||
});
|
||||
|
||||
const query = vi.fn((table: string) => {
|
||||
if (table === "users") {
|
||||
return {
|
||||
withIndex: vi.fn((_indexName: string, builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown) => {
|
||||
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((_indexName: string, builder?: (q: { eq: (field: string, value: string) => 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);
|
||||
return {
|
||||
unique: vi.fn(async () => {
|
||||
if (handle) {
|
||||
return [...publishers.values()].find((publisher) => publisher.handle === handle) ?? null;
|
||||
}
|
||||
if (linkedUserId) {
|
||||
return (
|
||||
[...publishers.values()].find((publisher) => publisher.linkedUserId === linkedUserId) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: vi.fn((_indexName: string, builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown) => {
|
||||
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 === "packages") {
|
||||
return {
|
||||
withIndex: vi.fn((_indexName: string, builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown) => {
|
||||
let ownerUserId = "";
|
||||
let ownerPublisherId = "";
|
||||
const q = {
|
||||
eq: (field: string, value: string) => {
|
||||
if (field === "ownerUserId") ownerUserId = value;
|
||||
if (field === "ownerPublisherId") ownerPublisherId = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
builder?.(q);
|
||||
return {
|
||||
collect: vi.fn(async () => {
|
||||
if (ownerUserId) {
|
||||
return packages.filter((pkg) => pkg.ownerUserId === ownerUserId);
|
||||
}
|
||||
if (ownerPublisherId) {
|
||||
return packages.filter((pkg) => pkg.ownerPublisherId === ownerPublisherId);
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "skills") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
collect: vi.fn(async () => []),
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
});
|
||||
|
||||
const result = await migrateLegacyPublisherHandleToOrgInternalHandler(
|
||||
{
|
||||
db: {
|
||||
get: vi.fn(async (id: string) =>
|
||||
users.get(id) ?? publishers.get(id) ?? null,
|
||||
),
|
||||
query,
|
||||
patch,
|
||||
insert,
|
||||
delete: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
normalizeId: vi.fn(),
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
actorUserId: "users:admin",
|
||||
handle: "openclaw",
|
||||
fallbackUserHandle: "openclaw-user",
|
||||
displayName: "OpenClaw",
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
handle: "openclaw",
|
||||
orgPublisherId: "publishers:openclaw",
|
||||
legacyUserId: "users:openclaw",
|
||||
fallbackUserHandle: "openclaw-user",
|
||||
personalPublisherId: "publishers:openclaw-user",
|
||||
convertedExistingPublisher: true,
|
||||
packagesMigrated: 1,
|
||||
});
|
||||
expect(users.get("users:openclaw")).toEqual(
|
||||
expect.objectContaining({
|
||||
handle: "openclaw-user",
|
||||
personalPublisherId: "publishers:openclaw-user",
|
||||
}),
|
||||
);
|
||||
expect(publishers.get("publishers:openclaw")).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: "org",
|
||||
handle: "openclaw",
|
||||
linkedUserId: undefined,
|
||||
}),
|
||||
);
|
||||
expect(publishers.get("publishers:openclaw-user")).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: "user",
|
||||
handle: "openclaw-user",
|
||||
linkedUserId: "users:openclaw",
|
||||
}),
|
||||
);
|
||||
expect(packages[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
ownerPublisherId: "publishers:openclaw",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,694 @@
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { MutationCtx } from "./_generated/server";
|
||||
import { internalMutation, internalQuery, mutation, query } from "./functions";
|
||||
import { assertAdmin, requireUser } from "./lib/access";
|
||||
import {
|
||||
ensurePersonalPublisherForUser,
|
||||
getPublisherByHandle,
|
||||
getPublisherMembership,
|
||||
getPersonalPublisherForUserOrFallback,
|
||||
getPersonalPublisherForUser,
|
||||
isPublisherRoleAllowed,
|
||||
normalizePublisherHandle,
|
||||
} from "./lib/publishers";
|
||||
import { toPublicPublisher } from "./lib/public";
|
||||
|
||||
const PUBLISHER_HANDLE_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
|
||||
|
||||
function validateHandle(rawHandle: string) {
|
||||
const handle = normalizePublisherHandle(rawHandle);
|
||||
if (!handle) throw new ConvexError("Handle is required");
|
||||
if (!PUBLISHER_HANDLE_PATTERN.test(handle)) {
|
||||
throw new ConvexError("Handle must be lowercase, url-safe, and 2-40 characters");
|
||||
}
|
||||
return handle;
|
||||
}
|
||||
|
||||
async function getUserByHandle(ctx: Pick<MutationCtx, "db">, handle: string) {
|
||||
return await ctx.db
|
||||
.query("users")
|
||||
.withIndex("handle", (q) => q.eq("handle", handle))
|
||||
.unique();
|
||||
}
|
||||
|
||||
function appendHandleSuffix(base: string, suffix: number) {
|
||||
const suffixText = suffix <= 1 ? "" : `-${suffix}`;
|
||||
const maxBaseLength = Math.max(2, 40 - suffixText.length);
|
||||
const trimmedBase = base.slice(0, maxBaseLength);
|
||||
return `${trimmedBase}${suffixText}`;
|
||||
}
|
||||
|
||||
async function resolveAvailableUserHandle(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
baseHandle: string,
|
||||
excludeUserId?: Id<"users">,
|
||||
) {
|
||||
for (let suffix = 1; suffix <= 50; suffix += 1) {
|
||||
const candidate = appendHandleSuffix(baseHandle, suffix);
|
||||
if (!PUBLISHER_HANDLE_PATTERN.test(candidate)) continue;
|
||||
const existingUser = await getUserByHandle(ctx, candidate);
|
||||
if (existingUser && existingUser._id !== excludeUserId) continue;
|
||||
const existingPublisher = await getPublisherByHandle(ctx, candidate);
|
||||
if (
|
||||
existingPublisher &&
|
||||
!(existingPublisher.kind === "user" && existingPublisher.linkedUserId === excludeUserId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
throw new ConvexError(`Unable to find an available fallback handle for "@${baseHandle}"`);
|
||||
}
|
||||
|
||||
async function migrateLegacyPublisherHandleToOrgWithActor(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
args: {
|
||||
actorUserId: Id<"users">;
|
||||
handle: string;
|
||||
fallbackUserHandle?: string;
|
||||
displayName?: string;
|
||||
},
|
||||
) {
|
||||
const actor = await ctx.db.get(args.actorUserId);
|
||||
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
||||
assertAdmin(actor);
|
||||
|
||||
const orgHandle = validateHandle(args.handle);
|
||||
const fallbackBase = validateHandle(args.fallbackUserHandle ?? `${orgHandle}-user`);
|
||||
const now = Date.now();
|
||||
|
||||
const handlePublisher = await getPublisherByHandle(ctx, orgHandle);
|
||||
const legacyUser =
|
||||
(handlePublisher?.linkedUserId ? await ctx.db.get(handlePublisher.linkedUserId) : null) ??
|
||||
(await getUserByHandle(ctx, orgHandle));
|
||||
if (!legacyUser || legacyUser.deletedAt || legacyUser.deactivatedAt) {
|
||||
throw new ConvexError(`Legacy user "@${orgHandle}" not found`);
|
||||
}
|
||||
|
||||
const personalPublisher =
|
||||
legacyUser.personalPublisherId
|
||||
? await ctx.db.get(legacyUser.personalPublisherId)
|
||||
: await getPersonalPublisherForUser(ctx, legacyUser._id);
|
||||
const convertiblePublisher =
|
||||
handlePublisher?.kind === "user" && handlePublisher.linkedUserId === legacyUser._id
|
||||
? handlePublisher
|
||||
: personalPublisher?.kind === "user" &&
|
||||
personalPublisher.linkedUserId === legacyUser._id &&
|
||||
personalPublisher.handle === orgHandle
|
||||
? personalPublisher
|
||||
: null;
|
||||
|
||||
const fallbackHandle = await resolveAvailableUserHandle(ctx, fallbackBase, legacyUser._id);
|
||||
let nextLegacyUser: Doc<"users"> = legacyUser;
|
||||
const needsDetachedPersonalPublisher = Boolean(
|
||||
convertiblePublisher && legacyUser.personalPublisherId === convertiblePublisher._id,
|
||||
);
|
||||
if (legacyUser.handle === orgHandle || needsDetachedPersonalPublisher) {
|
||||
const userPatch: Partial<Doc<"users">> = {
|
||||
updatedAt: now,
|
||||
};
|
||||
if (legacyUser.handle === orgHandle) {
|
||||
userPatch.handle = fallbackHandle;
|
||||
}
|
||||
if (needsDetachedPersonalPublisher) {
|
||||
userPatch.personalPublisherId = undefined;
|
||||
}
|
||||
await ctx.db.patch(legacyUser._id, userPatch);
|
||||
nextLegacyUser = {
|
||||
...legacyUser,
|
||||
...userPatch,
|
||||
};
|
||||
}
|
||||
|
||||
let orgPublisherId: Id<"publishers">;
|
||||
let convertedExistingPublisher = false;
|
||||
if (handlePublisher?.kind === "org") {
|
||||
orgPublisherId = handlePublisher._id;
|
||||
if (args.displayName?.trim() && handlePublisher.displayName !== args.displayName.trim()) {
|
||||
await ctx.db.patch(handlePublisher._id, {
|
||||
displayName: args.displayName.trim(),
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
} else if (convertiblePublisher) {
|
||||
orgPublisherId = convertiblePublisher._id;
|
||||
convertedExistingPublisher = true;
|
||||
await ctx.db.patch(convertiblePublisher._id, {
|
||||
kind: "org",
|
||||
handle: orgHandle,
|
||||
displayName: args.displayName?.trim() || convertiblePublisher.displayName,
|
||||
linkedUserId: undefined,
|
||||
trustedPublisher: convertiblePublisher.trustedPublisher ?? legacyUser.trustedPublisher,
|
||||
updatedAt: now,
|
||||
});
|
||||
} else {
|
||||
orgPublisherId = await ctx.db.insert("publishers", {
|
||||
kind: "org",
|
||||
handle: orgHandle,
|
||||
displayName: args.displayName?.trim() || legacyUser.displayName?.trim() || orgHandle,
|
||||
bio: undefined,
|
||||
image: undefined,
|
||||
linkedUserId: undefined,
|
||||
trustedPublisher: legacyUser.trustedPublisher,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
const membership = await getPublisherMembership(ctx, orgPublisherId, legacyUser._id);
|
||||
if (membership) {
|
||||
if (membership.role !== "owner") {
|
||||
await ctx.db.patch(membership._id, { role: "owner", updatedAt: now });
|
||||
}
|
||||
} else {
|
||||
await ctx.db.insert("publisherMembers", {
|
||||
publisherId: orgPublisherId,
|
||||
userId: legacyUser._id,
|
||||
role: "owner",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
const ensuredPersonalPublisher = await ensurePersonalPublisherForUser(ctx, nextLegacyUser);
|
||||
|
||||
const packages = await ctx.db
|
||||
.query("packages")
|
||||
.withIndex("by_owner", (q) => q.eq("ownerUserId", legacyUser._id))
|
||||
.collect();
|
||||
let packagesMigrated = 0;
|
||||
for (const pkg of packages) {
|
||||
if (pkg.ownerPublisherId === orgPublisherId) continue;
|
||||
await ctx.db.patch(pkg._id, {
|
||||
ownerPublisherId: orgPublisherId,
|
||||
updatedAt: now,
|
||||
});
|
||||
packagesMigrated += 1;
|
||||
}
|
||||
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: args.actorUserId,
|
||||
action: "publisher.legacy_handle.migrate",
|
||||
targetType: "publisher",
|
||||
targetId: orgPublisherId,
|
||||
metadata: {
|
||||
handle: orgHandle,
|
||||
legacyUserId: legacyUser._id,
|
||||
fallbackUserHandle: nextLegacyUser.handle ?? fallbackHandle,
|
||||
convertedExistingPublisher,
|
||||
packagesMigrated,
|
||||
personalPublisherId: ensuredPersonalPublisher?._id ?? null,
|
||||
},
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
handle: orgHandle,
|
||||
orgPublisherId,
|
||||
legacyUserId: legacyUser._id,
|
||||
fallbackUserHandle: nextLegacyUser.handle ?? fallbackHandle,
|
||||
personalPublisherId: ensuredPersonalPublisher?._id ?? null,
|
||||
convertedExistingPublisher,
|
||||
packagesMigrated,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureOrgPublisherHandleWithActor(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
args: {
|
||||
actorUserId: Id<"users">;
|
||||
handle: string;
|
||||
fallbackUserHandle?: string;
|
||||
displayName?: string;
|
||||
trusted?: boolean;
|
||||
},
|
||||
) {
|
||||
const actor = await ctx.db.get(args.actorUserId);
|
||||
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
||||
assertAdmin(actor);
|
||||
|
||||
const handle = validateHandle(args.handle);
|
||||
const now = Date.now();
|
||||
const existingPublisher = await getPublisherByHandle(ctx, handle);
|
||||
const existingUser = await getUserByHandle(ctx, handle);
|
||||
|
||||
if (existingPublisher?.kind === "org") {
|
||||
await ctx.db.patch(existingPublisher._id, {
|
||||
displayName: args.displayName?.trim() || existingPublisher.displayName,
|
||||
trustedPublisher: args.trusted ?? existingPublisher.trustedPublisher,
|
||||
updatedAt: now,
|
||||
});
|
||||
const membership = await getPublisherMembership(ctx, existingPublisher._id, args.actorUserId);
|
||||
if (!membership) {
|
||||
await ctx.db.insert("publisherMembers", {
|
||||
publisherId: existingPublisher._id,
|
||||
userId: args.actorUserId,
|
||||
role: "owner",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
return {
|
||||
ok: true as const,
|
||||
publisherId: existingPublisher._id,
|
||||
handle,
|
||||
created: false,
|
||||
migrated: false,
|
||||
trusted: args.trusted ?? existingPublisher.trustedPublisher ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
if (existingPublisher || existingUser) {
|
||||
const result = await migrateLegacyPublisherHandleToOrgWithActor(ctx, {
|
||||
actorUserId: args.actorUserId,
|
||||
handle,
|
||||
fallbackUserHandle: args.fallbackUserHandle,
|
||||
displayName: args.displayName,
|
||||
});
|
||||
if (typeof args.trusted === "boolean") {
|
||||
await ctx.db.patch(result.orgPublisherId, {
|
||||
trustedPublisher: args.trusted,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
return {
|
||||
ok: true as const,
|
||||
publisherId: result.orgPublisherId,
|
||||
handle,
|
||||
created: false,
|
||||
migrated: true,
|
||||
trusted: args.trusted ?? existingPublisher?.trustedPublisher ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
const publisherId = await ctx.db.insert("publishers", {
|
||||
kind: "org",
|
||||
handle,
|
||||
displayName: args.displayName?.trim() || handle,
|
||||
bio: undefined,
|
||||
image: undefined,
|
||||
linkedUserId: undefined,
|
||||
trustedPublisher: args.trusted || undefined,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await ctx.db.insert("publisherMembers", {
|
||||
publisherId,
|
||||
userId: args.actorUserId,
|
||||
role: "owner",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: args.actorUserId,
|
||||
action: "publisher.org.ensure",
|
||||
targetType: "publisher",
|
||||
targetId: publisherId,
|
||||
metadata: {
|
||||
handle,
|
||||
trusted: args.trusted === true,
|
||||
},
|
||||
createdAt: now,
|
||||
});
|
||||
return {
|
||||
ok: true as const,
|
||||
publisherId,
|
||||
handle,
|
||||
created: true,
|
||||
migrated: false,
|
||||
trusted: args.trusted ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export const getByIdInternal = internalQuery({
|
||||
args: { publisherId: v.id("publishers") },
|
||||
handler: async (ctx, args) => await ctx.db.get(args.publisherId),
|
||||
});
|
||||
|
||||
export const getByHandleInternal = internalQuery({
|
||||
args: { handle: v.string() },
|
||||
handler: async (ctx, args) => await getPublisherByHandle(ctx, args.handle),
|
||||
});
|
||||
|
||||
export const getMemberRoleInternal = internalQuery({
|
||||
args: {
|
||||
publisherId: v.id("publishers"),
|
||||
userId: v.id("users"),
|
||||
},
|
||||
handler: async (ctx, args) =>
|
||||
(await getPublisherMembership(ctx, args.publisherId, args.userId))?.role ?? null,
|
||||
});
|
||||
|
||||
export const ensurePersonalPublisherInternal = internalMutation({
|
||||
args: { userId: v.id("users") },
|
||||
handler: async (ctx, args) => {
|
||||
const user = await ctx.db.get(args.userId);
|
||||
if (!user || user.deletedAt || user.deactivatedAt) return null;
|
||||
return await ensurePersonalPublisherForUser(ctx, user);
|
||||
},
|
||||
});
|
||||
|
||||
export const resolvePublishTargetForUserInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
ownerHandle: v.optional(v.string()),
|
||||
minimumRole: v.optional(v.union(v.literal("owner"), v.literal("admin"), v.literal("publisher"))),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const actor = await ctx.db.get(args.actorUserId);
|
||||
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
||||
const minimumRole = args.minimumRole ?? "publisher";
|
||||
const requestedHandle = normalizePublisherHandle(args.ownerHandle);
|
||||
const personal = await ensurePersonalPublisherForUser(ctx, actor);
|
||||
if (!personal) throw new ConvexError("Personal publisher not found");
|
||||
if (!requestedHandle) {
|
||||
return {
|
||||
publisherId: personal._id,
|
||||
handle: personal.handle,
|
||||
kind: personal.kind,
|
||||
linkedUserId: personal.linkedUserId,
|
||||
};
|
||||
}
|
||||
|
||||
if (personal && requestedHandle === personal.handle) {
|
||||
return {
|
||||
publisherId: personal._id,
|
||||
handle: personal.handle,
|
||||
kind: personal.kind,
|
||||
linkedUserId: personal.linkedUserId,
|
||||
};
|
||||
}
|
||||
|
||||
const publisher = await getPublisherByHandle(ctx, requestedHandle);
|
||||
if (!publisher || publisher.deletedAt || publisher.deactivatedAt) {
|
||||
throw new ConvexError(`Publisher "@${requestedHandle}" not found`);
|
||||
}
|
||||
const membership = await getPublisherMembership(ctx, publisher._id, actor._id);
|
||||
if (!membership || !isPublisherRoleAllowed(membership.role, [minimumRole])) {
|
||||
throw new ConvexError(`Forbidden for "@${requestedHandle}"`);
|
||||
}
|
||||
return {
|
||||
publisherId: publisher._id,
|
||||
handle: publisher.handle,
|
||||
kind: publisher.kind,
|
||||
linkedUserId: publisher.linkedUserId,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const listMine = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) return [];
|
||||
const user = await ctx.db.get(userId);
|
||||
if (!user || user.deletedAt || user.deactivatedAt) return [];
|
||||
const memberships = await ctx.db
|
||||
.query("publisherMembers")
|
||||
.withIndex("by_user", (q) => q.eq("userId", userId))
|
||||
.collect();
|
||||
const publishers = await Promise.all(
|
||||
memberships.map(async (membership) => {
|
||||
const publisher = await ctx.db.get(membership.publisherId);
|
||||
const publicPublisher = toPublicPublisher(publisher);
|
||||
if (!publicPublisher) return null;
|
||||
return {
|
||||
publisher: publicPublisher,
|
||||
role: membership.role,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const visiblePublishers = publishers.filter(
|
||||
(
|
||||
item,
|
||||
): item is {
|
||||
publisher: NonNullable<ReturnType<typeof toPublicPublisher>>;
|
||||
role: Doc<"publisherMembers">["role"];
|
||||
} => Boolean(item),
|
||||
);
|
||||
const personalPublisher = toPublicPublisher(
|
||||
await getPersonalPublisherForUserOrFallback(ctx, user),
|
||||
);
|
||||
if (
|
||||
personalPublisher &&
|
||||
!visiblePublishers.some((entry) => entry.publisher._id === personalPublisher._id)
|
||||
) {
|
||||
visiblePublishers.unshift({
|
||||
publisher: personalPublisher,
|
||||
role: "owner",
|
||||
});
|
||||
}
|
||||
return visiblePublishers;
|
||||
},
|
||||
});
|
||||
|
||||
export const getByHandle = query({
|
||||
args: { handle: v.string() },
|
||||
handler: async (ctx, args) => toPublicPublisher(await getPublisherByHandle(ctx, args.handle)),
|
||||
});
|
||||
|
||||
export const listMembers = query({
|
||||
args: { publisherHandle: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const publisher = await getPublisherByHandle(ctx, args.publisherHandle);
|
||||
if (!publisher || publisher.deletedAt || publisher.deactivatedAt) return null;
|
||||
const memberships = await ctx.db
|
||||
.query("publisherMembers")
|
||||
.withIndex("by_publisher", (q) => q.eq("publisherId", publisher._id))
|
||||
.collect();
|
||||
const items = await Promise.all(
|
||||
memberships.map(async (membership) => {
|
||||
const user = await ctx.db.get(membership.userId);
|
||||
if (!user || user.deletedAt || user.deactivatedAt) return null;
|
||||
return {
|
||||
role: membership.role,
|
||||
user: {
|
||||
_id: user._id,
|
||||
handle: user.handle ?? null,
|
||||
displayName: user.displayName ?? user.name ?? null,
|
||||
image: user.image ?? null,
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
return {
|
||||
publisher: toPublicPublisher(publisher),
|
||||
members: items.filter(Boolean),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const createOrg = mutation({
|
||||
args: {
|
||||
handle: v.string(),
|
||||
displayName: v.string(),
|
||||
bio: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { user, userId } = await requireUser(ctx);
|
||||
await ensurePersonalPublisherForUser(ctx, user);
|
||||
|
||||
const handle = validateHandle(args.handle);
|
||||
const existingPublisher = await getPublisherByHandle(ctx, handle);
|
||||
if (existingPublisher) throw new ConvexError(`Publisher "@${handle}" already exists`);
|
||||
|
||||
const existingUser = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("handle", (q) => q.eq("handle", handle))
|
||||
.unique();
|
||||
if (existingUser && existingUser._id !== userId) {
|
||||
throw new ConvexError(`Handle "@${handle}" is already claimed`);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const publisherId = await ctx.db.insert("publishers", {
|
||||
kind: "org",
|
||||
handle,
|
||||
displayName: args.displayName.trim() || handle,
|
||||
bio: args.bio?.trim() || undefined,
|
||||
image: undefined,
|
||||
linkedUserId: undefined,
|
||||
trustedPublisher: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await ctx.db.insert("publisherMembers", {
|
||||
publisherId,
|
||||
userId,
|
||||
role: "owner",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: userId,
|
||||
action: "publisher.create",
|
||||
targetType: "publisher",
|
||||
targetId: publisherId,
|
||||
metadata: { kind: "org", handle },
|
||||
createdAt: now,
|
||||
});
|
||||
return {
|
||||
publisher: toPublicPublisher(await ctx.db.get(publisherId)),
|
||||
role: "owner" as const,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const migrateLegacyPublisherHandleToOrg = mutation({
|
||||
args: {
|
||||
handle: v.string(),
|
||||
fallbackUserHandle: v.optional(v.string()),
|
||||
displayName: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { userId } = await requireUser(ctx);
|
||||
return await migrateLegacyPublisherHandleToOrgWithActor(ctx, {
|
||||
actorUserId: userId,
|
||||
...args,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const ensureOrgPublisherHandleInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
handle: v.string(),
|
||||
fallbackUserHandle: v.optional(v.string()),
|
||||
displayName: v.optional(v.string()),
|
||||
trusted: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => await ensureOrgPublisherHandleWithActor(ctx, args),
|
||||
});
|
||||
|
||||
export const addMember = mutation({
|
||||
args: {
|
||||
publisherId: v.id("publishers"),
|
||||
userHandle: v.string(),
|
||||
role: v.union(v.literal("owner"), v.literal("admin"), v.literal("publisher")),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { userId } = await requireUser(ctx);
|
||||
const publisher = await ctx.db.get(args.publisherId);
|
||||
if (!publisher || publisher.deletedAt || publisher.deactivatedAt) {
|
||||
throw new ConvexError("Publisher not found");
|
||||
}
|
||||
const membership = await getPublisherMembership(ctx, publisher._id, userId);
|
||||
if (!membership || !isPublisherRoleAllowed(membership.role, ["admin"])) {
|
||||
throw new ConvexError("Forbidden");
|
||||
}
|
||||
if (args.role === "owner" && membership.role !== "owner") {
|
||||
throw new ConvexError("Only org owners can promote members to owner");
|
||||
}
|
||||
const handle = normalizePublisherHandle(args.userHandle);
|
||||
if (!handle) throw new ConvexError("User handle is required");
|
||||
const targetUser = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("handle", (q) => q.eq("handle", handle))
|
||||
.unique();
|
||||
if (!targetUser || targetUser.deletedAt || targetUser.deactivatedAt) {
|
||||
throw new ConvexError(`User "@${handle}" not found`);
|
||||
}
|
||||
await ensurePersonalPublisherForUser(ctx, targetUser);
|
||||
const existing = await getPublisherMembership(ctx, publisher._id, targetUser._id);
|
||||
const now = Date.now();
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, { role: args.role, updatedAt: now });
|
||||
} else {
|
||||
await ctx.db.insert("publisherMembers", {
|
||||
publisherId: publisher._id,
|
||||
userId: targetUser._id,
|
||||
role: args.role,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: userId,
|
||||
action: "publisher.member.upsert",
|
||||
targetType: "publisher",
|
||||
targetId: publisher._id,
|
||||
metadata: {
|
||||
memberUserId: targetUser._id,
|
||||
memberHandle: targetUser.handle ?? handle,
|
||||
role: args.role,
|
||||
},
|
||||
createdAt: now,
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const removeMember = mutation({
|
||||
args: {
|
||||
publisherId: v.id("publishers"),
|
||||
userId: v.id("users"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { userId } = await requireUser(ctx);
|
||||
const publisher = await ctx.db.get(args.publisherId);
|
||||
if (!publisher || publisher.deletedAt || publisher.deactivatedAt) {
|
||||
throw new ConvexError("Publisher not found");
|
||||
}
|
||||
const actorMembership = await getPublisherMembership(ctx, publisher._id, userId);
|
||||
if (!actorMembership || !isPublisherRoleAllowed(actorMembership.role, ["admin"])) {
|
||||
throw new ConvexError("Forbidden");
|
||||
}
|
||||
const targetMembership = await getPublisherMembership(ctx, publisher._id, args.userId);
|
||||
if (!targetMembership) return { ok: true };
|
||||
if (targetMembership.role === "owner" && actorMembership.role !== "owner") {
|
||||
throw new ConvexError("Only org owners can remove other owners");
|
||||
}
|
||||
if (targetMembership.role === "owner") {
|
||||
const members = await ctx.db
|
||||
.query("publisherMembers")
|
||||
.withIndex("by_publisher", (q) => q.eq("publisherId", publisher._id))
|
||||
.collect();
|
||||
const remainingOwners = members.filter(
|
||||
(member) => member.role === "owner" && member.userId !== args.userId,
|
||||
);
|
||||
if (remainingOwners.length === 0) {
|
||||
throw new ConvexError("Publisher must have at least one owner");
|
||||
}
|
||||
}
|
||||
await ctx.db.delete(targetMembership._id);
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: userId,
|
||||
action: "publisher.member.remove",
|
||||
targetType: "publisher",
|
||||
targetId: publisher._id,
|
||||
metadata: { memberUserId: args.userId },
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const setTrustedPublisherInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
publisherId: v.id("publishers"),
|
||||
trustedPublisher: v.boolean(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const actor = await ctx.db.get(args.actorUserId);
|
||||
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
||||
assertAdmin(actor);
|
||||
await ctx.db.patch(args.publisherId, {
|
||||
trustedPublisher: args.trustedPublisher,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const migrateLegacyPublisherHandleToOrgInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
handle: v.string(),
|
||||
fallbackUserHandle: v.optional(v.string()),
|
||||
displayName: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => await migrateLegacyPublisherHandleToOrgWithActor(ctx, args),
|
||||
});
|
||||
+115
-2
@@ -28,6 +28,7 @@ const users = defineTable({
|
||||
githubFetchedAt: v.optional(v.number()),
|
||||
githubProfileSyncedAt: v.optional(v.number()),
|
||||
trustedPublisher: v.optional(v.boolean()),
|
||||
personalPublisherId: v.optional(v.id("publishers")),
|
||||
requiresModerationAt: v.optional(v.number()),
|
||||
requiresModerationReason: v.optional(v.string()),
|
||||
deactivatedAt: v.optional(v.number()),
|
||||
@@ -41,6 +42,34 @@ const users = defineTable({
|
||||
.index("phone", ["phone"])
|
||||
.index("handle", ["handle"]);
|
||||
|
||||
const publishers = defineTable({
|
||||
kind: v.union(v.literal("user"), v.literal("org")),
|
||||
handle: v.string(),
|
||||
displayName: v.string(),
|
||||
bio: v.optional(v.string()),
|
||||
image: v.optional(v.string()),
|
||||
linkedUserId: v.optional(v.id("users")),
|
||||
trustedPublisher: v.optional(v.boolean()),
|
||||
deactivatedAt: v.optional(v.number()),
|
||||
deletedAt: v.optional(v.number()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_handle", ["handle"])
|
||||
.index("by_linked_user", ["linkedUserId"])
|
||||
.index("by_kind_handle", ["kind", "handle"]);
|
||||
|
||||
const publisherMembers = defineTable({
|
||||
publisherId: v.id("publishers"),
|
||||
userId: v.id("users"),
|
||||
role: v.union(v.literal("owner"), v.literal("admin"), v.literal("publisher")),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_publisher", ["publisherId"])
|
||||
.index("by_user", ["userId"])
|
||||
.index("by_publisher_user", ["publisherId", "userId"]);
|
||||
|
||||
// Shared validator fragments used by both `skills` and `skillSearchDigest`.
|
||||
const forkOfValidator = v.optional(
|
||||
v.object({
|
||||
@@ -159,6 +188,16 @@ const packageVerificationValidator = v.optional(
|
||||
}),
|
||||
);
|
||||
|
||||
const packageScanStatusValidator = v.optional(
|
||||
v.union(
|
||||
v.literal("clean"),
|
||||
v.literal("suspicious"),
|
||||
v.literal("malicious"),
|
||||
v.literal("pending"),
|
||||
v.literal("not-run"),
|
||||
),
|
||||
);
|
||||
|
||||
const packageFilesValidator = v.array(
|
||||
v.object({
|
||||
path: v.string(),
|
||||
@@ -175,6 +214,7 @@ const skills = defineTable({
|
||||
summary: v.optional(v.string()),
|
||||
resourceId: v.optional(v.string()),
|
||||
ownerUserId: v.id("users"),
|
||||
ownerPublisherId: v.optional(v.id("publishers")),
|
||||
canonicalSkillId: v.optional(v.id("skills")),
|
||||
forkOf: forkOfValidator,
|
||||
latestVersionId: v.optional(v.id("skillVersions")),
|
||||
@@ -255,6 +295,7 @@ const skills = defineTable({
|
||||
})
|
||||
.index("by_slug", ["slug"])
|
||||
.index("by_owner", ["ownerUserId"])
|
||||
.index("by_owner_publisher", ["ownerPublisherId"])
|
||||
.index("by_updated", ["updatedAt"])
|
||||
.index("by_stats_downloads", ["statsDownloads", "updatedAt"])
|
||||
.index("by_stats_stars", ["statsStars", "updatedAt"])
|
||||
@@ -295,18 +336,21 @@ const skillSlugAliases = defineTable({
|
||||
slug: v.string(),
|
||||
skillId: v.id("skills"),
|
||||
ownerUserId: v.id("users"),
|
||||
ownerPublisherId: v.optional(v.id("publishers")),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_slug", ["slug"])
|
||||
.index("by_skill", ["skillId"])
|
||||
.index("by_owner", ["ownerUserId"]);
|
||||
.index("by_owner", ["ownerUserId"])
|
||||
.index("by_owner_publisher", ["ownerPublisherId"]);
|
||||
|
||||
const souls = defineTable({
|
||||
slug: v.string(),
|
||||
displayName: v.string(),
|
||||
summary: v.optional(v.string()),
|
||||
ownerUserId: v.id("users"),
|
||||
ownerPublisherId: v.optional(v.id("publishers")),
|
||||
latestVersionId: v.optional(v.id("soulVersions")),
|
||||
tags: v.record(v.string(), v.id("soulVersions")),
|
||||
softDeletedAt: v.optional(v.number()),
|
||||
@@ -321,6 +365,7 @@ const souls = defineTable({
|
||||
})
|
||||
.index("by_slug", ["slug"])
|
||||
.index("by_owner", ["ownerUserId"])
|
||||
.index("by_owner_publisher", ["ownerPublisherId"])
|
||||
.index("by_updated", ["updatedAt"]);
|
||||
|
||||
const skillVersions = defineTable({
|
||||
@@ -471,6 +516,7 @@ const skillEmbeddings = defineTable({
|
||||
skillId: v.id("skills"),
|
||||
versionId: v.id("skillVersions"),
|
||||
ownerId: v.id("users"),
|
||||
ownerPublisherId: v.optional(v.id("publishers")),
|
||||
embedding: v.array(v.number()),
|
||||
isLatest: v.boolean(),
|
||||
isApproved: v.boolean(),
|
||||
@@ -501,7 +547,9 @@ const skillSearchDigest = defineTable({
|
||||
displayName: v.string(),
|
||||
summary: v.optional(v.string()),
|
||||
ownerUserId: v.id("users"),
|
||||
ownerPublisherId: v.optional(v.id("publishers")),
|
||||
ownerHandle: v.optional(v.string()),
|
||||
ownerKind: v.optional(v.union(v.literal("user"), v.literal("org"))),
|
||||
ownerName: v.optional(v.string()),
|
||||
ownerDisplayName: v.optional(v.string()),
|
||||
ownerImage: v.optional(v.string()),
|
||||
@@ -566,6 +614,7 @@ const packages = defineTable({
|
||||
displayName: v.string(),
|
||||
summary: v.optional(v.string()),
|
||||
ownerUserId: v.id("users"),
|
||||
ownerPublisherId: v.optional(v.id("publishers")),
|
||||
family: packageFamilyValidator,
|
||||
channel: packageChannelValidator,
|
||||
isOfficial: v.boolean(),
|
||||
@@ -588,6 +637,7 @@ const packages = defineTable({
|
||||
compatibility: packageCompatibilityValidator,
|
||||
capabilities: packageCapabilitiesValidator,
|
||||
verification: packageVerificationValidator,
|
||||
scanStatus: packageScanStatusValidator,
|
||||
stats: packageStatsValidator,
|
||||
softDeletedAt: v.optional(v.number()),
|
||||
createdAt: v.number(),
|
||||
@@ -595,6 +645,7 @@ const packages = defineTable({
|
||||
})
|
||||
.index("by_name", ["normalizedName"])
|
||||
.index("by_owner", ["ownerUserId"])
|
||||
.index("by_owner_publisher", ["ownerPublisherId"])
|
||||
.index("by_family_updated", ["family", "updatedAt"])
|
||||
.index("by_family_channel_updated", ["family", "channel", "updatedAt"])
|
||||
.index("by_family_official_updated", ["family", "isOfficial", "updatedAt"])
|
||||
@@ -615,6 +666,57 @@ const packageReleases = defineTable({
|
||||
compatibility: packageCompatibilityValidator,
|
||||
capabilities: packageCapabilitiesValidator,
|
||||
verification: packageVerificationValidator,
|
||||
sha256hash: v.optional(v.string()),
|
||||
vtAnalysis: v.optional(
|
||||
v.object({
|
||||
status: v.string(),
|
||||
verdict: v.optional(v.string()),
|
||||
analysis: v.optional(v.string()),
|
||||
source: v.optional(v.string()),
|
||||
checkedAt: v.number(),
|
||||
}),
|
||||
),
|
||||
llmAnalysis: v.optional(
|
||||
v.object({
|
||||
status: v.string(),
|
||||
verdict: v.optional(v.string()),
|
||||
confidence: v.optional(v.string()),
|
||||
summary: v.optional(v.string()),
|
||||
dimensions: v.optional(
|
||||
v.array(
|
||||
v.object({
|
||||
name: v.string(),
|
||||
label: v.string(),
|
||||
rating: v.string(),
|
||||
detail: v.string(),
|
||||
}),
|
||||
),
|
||||
),
|
||||
guidance: v.optional(v.string()),
|
||||
findings: v.optional(v.string()),
|
||||
model: v.optional(v.string()),
|
||||
checkedAt: v.number(),
|
||||
}),
|
||||
),
|
||||
staticScan: v.optional(
|
||||
v.object({
|
||||
status: v.union(v.literal("clean"), v.literal("suspicious"), v.literal("malicious")),
|
||||
reasonCodes: v.array(v.string()),
|
||||
findings: v.array(
|
||||
v.object({
|
||||
code: v.string(),
|
||||
severity: v.union(v.literal("info"), v.literal("warn"), v.literal("critical")),
|
||||
file: v.string(),
|
||||
line: v.number(),
|
||||
message: v.string(),
|
||||
evidence: v.string(),
|
||||
}),
|
||||
),
|
||||
summary: v.string(),
|
||||
engineVersion: v.string(),
|
||||
checkedAt: v.number(),
|
||||
}),
|
||||
),
|
||||
source: v.optional(v.any()),
|
||||
createdBy: v.id("users"),
|
||||
createdAt: v.number(),
|
||||
@@ -622,7 +724,8 @@ const packageReleases = defineTable({
|
||||
})
|
||||
.index("by_package", ["packageId"])
|
||||
.index("by_package_active_created", ["packageId", "softDeletedAt", "createdAt"])
|
||||
.index("by_package_version", ["packageId", "version"]);
|
||||
.index("by_package_version", ["packageId", "version"])
|
||||
.index("by_sha256hash", ["sha256hash"]);
|
||||
|
||||
const packageSearchDigest = defineTable({
|
||||
packageId: v.id("packages"),
|
||||
@@ -633,13 +736,16 @@ const packageSearchDigest = defineTable({
|
||||
channel: packageChannelValidator,
|
||||
isOfficial: v.boolean(),
|
||||
ownerUserId: v.id("users"),
|
||||
ownerPublisherId: v.optional(v.id("publishers")),
|
||||
ownerHandle: v.optional(v.string()),
|
||||
ownerKind: v.optional(v.union(v.literal("user"), v.literal("org"))),
|
||||
summary: v.optional(v.string()),
|
||||
latestVersion: v.optional(v.string()),
|
||||
runtimeId: v.optional(v.string()),
|
||||
capabilityTags: v.optional(v.array(v.string())),
|
||||
executesCode: v.optional(v.boolean()),
|
||||
verificationTier: v.optional(packageVerificationTierValidator),
|
||||
scanStatus: packageScanStatusValidator,
|
||||
softDeletedAt: v.optional(v.number()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
@@ -702,6 +808,8 @@ const packageSearchDigest = defineTable({
|
||||
"executesCode",
|
||||
"updatedAt",
|
||||
])
|
||||
.index("by_active_normalized_name", ["softDeletedAt", "normalizedName", "updatedAt"])
|
||||
.index("by_active_runtime_id", ["softDeletedAt", "runtimeId", "updatedAt"])
|
||||
.index("by_active_name", ["softDeletedAt", "displayName"]);
|
||||
|
||||
const packageCapabilitySearchDigest = defineTable({
|
||||
@@ -713,7 +821,9 @@ const packageCapabilitySearchDigest = defineTable({
|
||||
channel: packageChannelValidator,
|
||||
isOfficial: v.boolean(),
|
||||
ownerUserId: v.id("users"),
|
||||
ownerPublisherId: v.optional(v.id("publishers")),
|
||||
ownerHandle: v.optional(v.string()),
|
||||
ownerKind: v.optional(v.union(v.literal("user"), v.literal("org"))),
|
||||
summary: v.optional(v.string()),
|
||||
latestVersion: v.optional(v.string()),
|
||||
runtimeId: v.optional(v.string()),
|
||||
@@ -721,6 +831,7 @@ const packageCapabilitySearchDigest = defineTable({
|
||||
capabilityTag: v.string(),
|
||||
executesCode: v.optional(v.boolean()),
|
||||
verificationTier: v.optional(packageVerificationTierValidator),
|
||||
scanStatus: packageScanStatusValidator,
|
||||
softDeletedAt: v.optional(v.number()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
@@ -1138,6 +1249,8 @@ const skillOwnershipTransfers = defineTable({
|
||||
export default defineSchema({
|
||||
...authTables,
|
||||
users,
|
||||
publishers,
|
||||
publisherMembers,
|
||||
skills,
|
||||
skillSlugAliases,
|
||||
packages,
|
||||
|
||||
+236
-3
@@ -46,8 +46,8 @@ describe("search helpers", () => {
|
||||
owner: null,
|
||||
},
|
||||
];
|
||||
// With incremental hydration, empty vector results skip the hydrate call entirely.
|
||||
const runQuery = vi.fn().mockResolvedValueOnce(fallback); // lexicalFallbackSkills (only call)
|
||||
// Slug-like queries now do an indexed exact-slug lookup before lexical fallback.
|
||||
const runQuery = vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(fallback);
|
||||
|
||||
const result = await searchSkillsHandler(
|
||||
{
|
||||
@@ -183,6 +183,7 @@ describe("search helpers", () => {
|
||||
|
||||
const runQuery = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null) // getExactSkillSlugMatch
|
||||
.mockResolvedValueOnce(vectorEntries) // hydrateResults
|
||||
.mockResolvedValueOnce(fallbackEntries); // lexicalFallbackSkills
|
||||
|
||||
@@ -204,6 +205,235 @@ describe("search helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("always includes an exact slug match even when vector exact matches already fill the limit", async () => {
|
||||
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
|
||||
|
||||
const vectorEntries = Array.from({ length: 10 }, (_, index) => ({
|
||||
embeddingId: `skillEmbeddings:${index}`,
|
||||
skill: makePublicSkill({
|
||||
id: `skills:${index}`,
|
||||
slug: `downloader-${index}`,
|
||||
displayName: `Downloader ${index}`,
|
||||
downloads: 100 - index,
|
||||
}),
|
||||
version: null,
|
||||
ownerHandle: "owner",
|
||||
owner: null,
|
||||
}));
|
||||
|
||||
const exactSlugEntry = {
|
||||
skill: makePublicSkill({
|
||||
id: "skills:exact",
|
||||
slug: "skill-downloader",
|
||||
displayName: "Skill Downloader",
|
||||
downloads: 1,
|
||||
}),
|
||||
version: null,
|
||||
ownerHandle: "yyang100",
|
||||
owner: null,
|
||||
};
|
||||
|
||||
const runQuery = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(exactSlugEntry)
|
||||
.mockResolvedValueOnce(vectorEntries);
|
||||
|
||||
const result = await searchSkillsHandler(
|
||||
{
|
||||
vectorSearch: vi.fn().mockResolvedValue(
|
||||
vectorEntries.map((entry, index) => ({ _id: entry.embeddingId, _score: 0.9 - index * 0.01 })),
|
||||
),
|
||||
runQuery,
|
||||
},
|
||||
{ query: "skill-downloader", limit: 10 },
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(10);
|
||||
expect(result[0].skill.slug).toBe("skill-downloader");
|
||||
expect(runQuery).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("omits exact slug injection when nonSuspiciousOnly excludes it", async () => {
|
||||
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
|
||||
|
||||
const vectorEntries = [
|
||||
{
|
||||
embeddingId: "skillEmbeddings:1",
|
||||
skill: makePublicSkill({
|
||||
id: "skills:1",
|
||||
slug: "downloader-1",
|
||||
displayName: "Downloader 1",
|
||||
downloads: 50,
|
||||
}),
|
||||
version: null,
|
||||
ownerHandle: "owner",
|
||||
owner: null,
|
||||
},
|
||||
];
|
||||
|
||||
const runQuery = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(vectorEntries)
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await searchSkillsHandler(
|
||||
{
|
||||
vectorSearch: vi.fn().mockResolvedValue([{ _id: "skillEmbeddings:1", _score: 0.9 }]),
|
||||
runQuery,
|
||||
},
|
||||
{ query: "skill-downloader", limit: 10, nonSuspiciousOnly: true },
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].skill.slug).toBe("downloader-1");
|
||||
});
|
||||
|
||||
it("omits exact slug injection when highlightedOnly excludes it", async () => {
|
||||
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
|
||||
|
||||
const exactSlugEntry = {
|
||||
skill: makePublicSkill({
|
||||
id: "skills:exact",
|
||||
slug: "skill-downloader",
|
||||
displayName: "Skill Downloader",
|
||||
downloads: 1,
|
||||
}),
|
||||
version: null,
|
||||
ownerHandle: "yyang100",
|
||||
owner: null,
|
||||
};
|
||||
|
||||
const vectorEntries = [
|
||||
{
|
||||
embeddingId: "skillEmbeddings:1",
|
||||
skill: {
|
||||
...makePublicSkill({
|
||||
id: "skills:1",
|
||||
slug: "downloader-1",
|
||||
displayName: "Downloader 1",
|
||||
downloads: 50,
|
||||
}),
|
||||
badges: { highlighted: { byUserId: "users:mod", at: 1 } },
|
||||
},
|
||||
version: null,
|
||||
ownerHandle: "owner",
|
||||
owner: null,
|
||||
},
|
||||
];
|
||||
|
||||
const runQuery = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(exactSlugEntry)
|
||||
.mockResolvedValueOnce(vectorEntries)
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await searchSkillsHandler(
|
||||
{
|
||||
vectorSearch: vi.fn().mockResolvedValue([{ _id: "skillEmbeddings:1", _score: 0.9 }]),
|
||||
runQuery,
|
||||
},
|
||||
{ query: "skill-downloader", limit: 10, highlightedOnly: true },
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].skill.slug).toBe("downloader-1");
|
||||
});
|
||||
|
||||
it("deduplicates exact slug injection against vector exact matches", async () => {
|
||||
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
|
||||
|
||||
const sharedSkill = makePublicSkill({
|
||||
id: "skills:exact",
|
||||
slug: "skill-downloader",
|
||||
displayName: "Skill Downloader",
|
||||
downloads: 100,
|
||||
});
|
||||
const exactSlugEntry = {
|
||||
skill: sharedSkill,
|
||||
version: null,
|
||||
ownerHandle: "yyang100",
|
||||
owner: null,
|
||||
};
|
||||
const vectorEntries = [
|
||||
{
|
||||
embeddingId: "skillEmbeddings:exact",
|
||||
skill: sharedSkill,
|
||||
version: null,
|
||||
ownerHandle: "yyang100",
|
||||
owner: null,
|
||||
},
|
||||
{
|
||||
embeddingId: "skillEmbeddings:other",
|
||||
skill: makePublicSkill({
|
||||
id: "skills:other",
|
||||
slug: "downloader-2",
|
||||
displayName: "Downloader 2",
|
||||
downloads: 50,
|
||||
}),
|
||||
version: null,
|
||||
ownerHandle: "owner",
|
||||
owner: null,
|
||||
},
|
||||
];
|
||||
|
||||
const runQuery = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(exactSlugEntry)
|
||||
.mockResolvedValueOnce(vectorEntries)
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await searchSkillsHandler(
|
||||
{
|
||||
vectorSearch: vi.fn().mockResolvedValue([
|
||||
{ _id: "skillEmbeddings:exact", _score: 0.95 },
|
||||
{ _id: "skillEmbeddings:other", _score: 0.8 },
|
||||
]),
|
||||
runQuery,
|
||||
},
|
||||
{ query: "skill-downloader", limit: 10 },
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.filter((entry) => entry.skill._id === "skills:exact")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("skips duplicate slug lookup inside lexical fallback when search action already did it", async () => {
|
||||
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
|
||||
|
||||
const fallbackEntries = [
|
||||
{
|
||||
skill: makePublicSkill({
|
||||
id: "skills:orf",
|
||||
slug: "orf",
|
||||
displayName: "ORF",
|
||||
}),
|
||||
version: null,
|
||||
ownerHandle: "steipete",
|
||||
owner: null,
|
||||
},
|
||||
];
|
||||
|
||||
const runQuery = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockImplementationOnce(async (_ref: unknown, args: { skipExactSlugLookup?: boolean }) => {
|
||||
expect(args.skipExactSlugLookup).toBe(true);
|
||||
return fallbackEntries;
|
||||
});
|
||||
|
||||
const result = await searchSkillsHandler(
|
||||
{
|
||||
vectorSearch: vi.fn().mockResolvedValue([]),
|
||||
runQuery,
|
||||
},
|
||||
{ query: "orf", limit: 10 },
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].skill.slug).toBe("orf");
|
||||
});
|
||||
|
||||
it("filters suspicious vector results in hydrateResults when requested", async () => {
|
||||
const result = await hydrateResultsHandler(
|
||||
{
|
||||
@@ -525,7 +755,10 @@ describe("search helpers", () => {
|
||||
|
||||
const hydrateCalls: string[][] = [];
|
||||
const runQuery = vi.fn(
|
||||
async (_ref: unknown, args: { embeddingIds?: string[]; query?: string }) => {
|
||||
async (_ref: unknown, args: { embeddingIds?: string[]; query?: string; slug?: string }) => {
|
||||
if (args.slug) {
|
||||
return null; // getExactSkillSlugMatch
|
||||
}
|
||||
if (args.embeddingIds) {
|
||||
hydrateCalls.push(args.embeddingIds);
|
||||
return args.embeddingIds.map((embeddingId: string) => ({
|
||||
|
||||
+74
-18
@@ -5,27 +5,32 @@ import type { QueryCtx } from "./_generated/server";
|
||||
import { action, internalQuery } from "./functions";
|
||||
import { isSkillHighlighted } from "./lib/badges";
|
||||
import { generateEmbedding } from "./lib/embeddings";
|
||||
import type { HydratableSkill } from "./lib/public";
|
||||
import { toPublicSkill, toPublicSoul, toPublicUser } from "./lib/public";
|
||||
import type { HydratableSkill, PublicPublisher } from "./lib/public";
|
||||
import { toPublicPublisher, toPublicSkill, toPublicSoul } from "./lib/public";
|
||||
import { getOwnerPublisher } from "./lib/publishers";
|
||||
import { matchesExactTokens, tokenize } from "./lib/searchText";
|
||||
import { isSkillSuspicious } from "./lib/skillSafety";
|
||||
import { digestToHydratableSkill, digestToOwnerInfo } from "./lib/skillSearchDigest";
|
||||
|
||||
type OwnerInfo = { ownerHandle: string | null; owner: ReturnType<typeof toPublicUser> | null };
|
||||
type OwnerInfo = { ownerHandle: string | null; owner: PublicPublisher | null };
|
||||
|
||||
function makeOwnerInfoGetter(ctx: Pick<QueryCtx, "db">) {
|
||||
const ownerCache = new Map<Id<"users">, Promise<OwnerInfo>>();
|
||||
return (ownerUserId: Id<"users">) => {
|
||||
const cached = ownerCache.get(ownerUserId);
|
||||
const ownerCache = new Map<string, Promise<OwnerInfo>>();
|
||||
return (ownerUserId: Id<"users">, ownerPublisherId?: Id<"publishers"> | null) => {
|
||||
const cacheKey = String(ownerPublisherId ?? ownerUserId);
|
||||
const cached = ownerCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
const ownerPromise = ctx.db.get(ownerUserId).then((ownerDoc) => {
|
||||
const owner = toPublicUser(ownerDoc);
|
||||
const ownerPromise = getOwnerPublisher(ctx, {
|
||||
ownerPublisherId,
|
||||
ownerUserId,
|
||||
}).then((ownerDoc) => {
|
||||
const owner = toPublicPublisher(ownerDoc);
|
||||
return {
|
||||
ownerHandle: owner?.handle ?? owner?.name ?? null,
|
||||
ownerHandle: owner?.handle ?? null,
|
||||
owner,
|
||||
};
|
||||
});
|
||||
ownerCache.set(ownerUserId, ownerPromise);
|
||||
ownerCache.set(cacheKey, ownerPromise);
|
||||
return ownerPromise;
|
||||
};
|
||||
}
|
||||
@@ -35,7 +40,7 @@ type SkillSearchEntry = {
|
||||
skill: NonNullable<ReturnType<typeof toPublicSkill>>;
|
||||
version: Doc<"skillVersions"> | null;
|
||||
ownerHandle: string | null;
|
||||
owner: ReturnType<typeof toPublicUser> | null;
|
||||
owner: PublicPublisher | null;
|
||||
};
|
||||
|
||||
type SearchResult = SkillSearchEntry & { score: number };
|
||||
@@ -111,6 +116,10 @@ function mergeUniqueBySkillId(primary: SkillSearchEntry[], fallback: SkillSearch
|
||||
return out;
|
||||
}
|
||||
|
||||
function isSlugLikeQuery(query: string) {
|
||||
return /^[a-z0-9][a-z0-9-]*$/.test(query.trim().toLowerCase());
|
||||
}
|
||||
|
||||
export const searchSkills: ReturnType<typeof action> = action({
|
||||
args: {
|
||||
query: v.string(),
|
||||
@@ -123,6 +132,17 @@ export const searchSkills: ReturnType<typeof action> = action({
|
||||
if (!query) return [];
|
||||
const queryTokens = tokenize(query);
|
||||
if (queryTokens.length === 0) return [];
|
||||
const rawExactSlugMatch =
|
||||
isSlugLikeQuery(query)
|
||||
? ((await ctx.runQuery(internal.search.getExactSkillSlugMatch, {
|
||||
slug: query.toLowerCase(),
|
||||
nonSuspiciousOnly: args.nonSuspiciousOnly,
|
||||
})) as SkillSearchEntry | null)
|
||||
: null;
|
||||
const exactSlugMatch =
|
||||
rawExactSlugMatch && (!args.highlightedOnly || isSkillHighlighted(rawExactSlugMatch.skill))
|
||||
? rawExactSlugMatch
|
||||
: null;
|
||||
let vector: number[];
|
||||
try {
|
||||
vector = await generateEmbedding(query);
|
||||
@@ -187,8 +207,12 @@ export const searchSkills: ReturnType<typeof action> = action({
|
||||
candidateLimit = nextLimit;
|
||||
}
|
||||
|
||||
const primaryMatches = exactSlugMatch
|
||||
? mergeUniqueBySkillId([exactSlugMatch], exactMatches)
|
||||
: exactMatches;
|
||||
|
||||
const fallbackMatches =
|
||||
exactMatches.length >= limit
|
||||
primaryMatches.length >= limit
|
||||
? []
|
||||
: ((await ctx.runQuery(internal.search.lexicalFallbackSkills, {
|
||||
query,
|
||||
@@ -196,9 +220,9 @@ export const searchSkills: ReturnType<typeof action> = action({
|
||||
limit: Math.min(Math.max(limit * 4, 200), FALLBACK_SCAN_LIMIT),
|
||||
highlightedOnly: args.highlightedOnly,
|
||||
nonSuspiciousOnly: args.nonSuspiciousOnly,
|
||||
skipExactSlugLookup: true,
|
||||
})) as SkillSearchEntry[]);
|
||||
|
||||
const mergedMatches = mergeUniqueBySkillId(exactMatches, fallbackMatches);
|
||||
const mergedMatches = mergeUniqueBySkillId(primaryMatches, fallbackMatches);
|
||||
|
||||
return mergedMatches
|
||||
.map((entry) => {
|
||||
@@ -220,6 +244,33 @@ export const searchSkills: ReturnType<typeof action> = action({
|
||||
},
|
||||
});
|
||||
|
||||
export const getExactSkillSlugMatch = internalQuery({
|
||||
args: {
|
||||
slug: v.string(),
|
||||
nonSuspiciousOnly: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<SkillSearchEntry | null> => {
|
||||
const skill = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
|
||||
.unique();
|
||||
if (!skill || skill.softDeletedAt) return null;
|
||||
if (args.nonSuspiciousOnly && isSkillSuspicious(skill)) return null;
|
||||
|
||||
const getOwnerInfo = makeOwnerInfoGetter(ctx);
|
||||
const resolved = await getOwnerInfo(skill.ownerUserId, skill.ownerPublisherId);
|
||||
const publicSkill = toPublicSkill(skill);
|
||||
if (!publicSkill || !resolved.owner) return null;
|
||||
|
||||
return {
|
||||
skill: publicSkill,
|
||||
version: null,
|
||||
ownerHandle: resolved.ownerHandle,
|
||||
owner: resolved.owner,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const hydrateResults = internalQuery({
|
||||
args: {
|
||||
embeddingIds: v.array(v.id("skillEmbeddings")),
|
||||
@@ -254,7 +305,9 @@ export const hydrateResults = internalQuery({
|
||||
// Use pre-resolved owner from digest to avoid reading the users table.
|
||||
// Fall back to live lookup when digest owner is null (deactivated/deleted user).
|
||||
const preResolved = digest ? digestToOwnerInfo(digest) : null;
|
||||
const resolved = preResolved?.owner ? preResolved : await getOwnerInfo(skill.ownerUserId);
|
||||
const resolved = preResolved?.owner
|
||||
? preResolved
|
||||
: await getOwnerInfo(skill.ownerUserId, skill.ownerPublisherId);
|
||||
const publicSkill = toPublicSkill(skill);
|
||||
if (!publicSkill || !resolved.owner) return null;
|
||||
return {
|
||||
@@ -278,6 +331,7 @@ export const lexicalFallbackSkills = internalQuery({
|
||||
limit: v.optional(v.number()),
|
||||
highlightedOnly: v.optional(v.boolean()),
|
||||
nonSuspiciousOnly: v.optional(v.boolean()),
|
||||
skipExactSlugLookup: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<SkillSearchEntry[]> => {
|
||||
const limit = Math.min(Math.max(args.limit ?? 200, 10), FALLBACK_SCAN_LIMIT);
|
||||
@@ -286,12 +340,12 @@ export const lexicalFallbackSkills = internalQuery({
|
||||
// Keep digest rows around so we can resolve owner info without hitting users table.
|
||||
const preResolvedOwners = new Map<
|
||||
Id<"skills">,
|
||||
{ ownerHandle: string | null; owner: ReturnType<typeof toPublicUser> | null }
|
||||
{ ownerHandle: string | null; owner: PublicPublisher | null }
|
||||
>();
|
||||
|
||||
// Exact slug match via the skills table (only one row, cheap).
|
||||
const slugQuery = args.query.trim().toLowerCase();
|
||||
if (/^[a-z0-9][a-z0-9-]*$/.test(slugQuery)) {
|
||||
if (!args.skipExactSlugLookup && /^[a-z0-9][a-z0-9-]*$/.test(slugQuery)) {
|
||||
const exactSlugSkill = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_slug", (q) => q.eq("slug", slugQuery))
|
||||
@@ -335,7 +389,9 @@ export const lexicalFallbackSkills = internalQuery({
|
||||
const entries = await Promise.all(
|
||||
matched.map(async (skill) => {
|
||||
const preResolved = preResolvedOwners.get(skill._id);
|
||||
const resolved = preResolved?.owner ? preResolved : await getOwnerInfo(skill.ownerUserId);
|
||||
const resolved = preResolved?.owner
|
||||
? preResolved
|
||||
: await getOwnerInfo(skill.ownerUserId, skill.ownerPublisherId);
|
||||
const publicSkill = toPublicSkill(skill);
|
||||
if (!publicSkill || !resolved.owner) return null;
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { list } from "./skills";
|
||||
|
||||
vi.mock("@convex-dev/auth/server", () => ({
|
||||
getAuthUserId: vi.fn(),
|
||||
authTables: {},
|
||||
}));
|
||||
|
||||
type WrappedHandler<TArgs, TResult = unknown> = {
|
||||
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
|
||||
};
|
||||
|
||||
const listHandler = (
|
||||
list as unknown as WrappedHandler<
|
||||
{ ownerPublisherId?: string; ownerUserId?: string; limit?: number },
|
||||
Array<{ slug: string }>
|
||||
>
|
||||
)._handler;
|
||||
|
||||
describe("skills.list", () => {
|
||||
it("includes legacy personal skills when listing a personal publisher", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
|
||||
const legacySkill = {
|
||||
_id: "skills:legacy",
|
||||
_creationTime: 1,
|
||||
slug: "legacy-skill",
|
||||
displayName: "Legacy Skill",
|
||||
summary: "Pre-backfill skill",
|
||||
ownerUserId: "users:owner",
|
||||
ownerPublisherId: undefined,
|
||||
canonicalSkillId: undefined,
|
||||
forkOf: undefined,
|
||||
latestVersionId: undefined,
|
||||
tags: {},
|
||||
badges: undefined,
|
||||
stats: {
|
||||
downloads: 0,
|
||||
installsCurrent: 0,
|
||||
installsAllTime: 0,
|
||||
stars: 0,
|
||||
versions: 1,
|
||||
comments: 0,
|
||||
},
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
moderationFlags: [],
|
||||
moderationReason: undefined,
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "publishers:self") {
|
||||
return {
|
||||
_id: "publishers:self",
|
||||
_creationTime: 1,
|
||||
kind: "user",
|
||||
handle: "owner",
|
||||
displayName: "Owner",
|
||||
linkedUserId: "users:owner",
|
||||
};
|
||||
}
|
||||
if (id === "users:owner") {
|
||||
return {
|
||||
_id: "users:owner",
|
||||
_creationTime: 1,
|
||||
handle: "owner",
|
||||
displayName: "Owner",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn().mockResolvedValue(null),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (table === "skills") {
|
||||
return {
|
||||
withIndex: vi.fn((indexName: string) => {
|
||||
if (indexName === "by_owner_publisher") {
|
||||
return {
|
||||
order: vi.fn(() => ({
|
||||
take: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (indexName === "by_owner") {
|
||||
return {
|
||||
order: vi.fn(() => ({
|
||||
take: vi.fn().mockResolvedValue([legacySkill]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected skills index ${indexName}`);
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "skillBadges") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
take: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await listHandler(
|
||||
ctx as never,
|
||||
{ ownerPublisherId: "publishers:self", limit: 10 } as never,
|
||||
);
|
||||
|
||||
expect(result).toEqual([expect.objectContaining({ slug: "legacy-skill" })]);
|
||||
});
|
||||
});
|
||||
@@ -113,13 +113,14 @@ describe("skills.getBySlug", () => {
|
||||
const result = await getBySlugHandler(ctx, { slug: "demo" } as never);
|
||||
|
||||
expect(result?.owner).toEqual({
|
||||
_id: "users:1",
|
||||
_id: "publishers:demo-owner",
|
||||
_creationTime: 1,
|
||||
kind: "user",
|
||||
handle: "demo-owner",
|
||||
name: "Demo Owner",
|
||||
displayName: "Demo Owner",
|
||||
image: null,
|
||||
bio: "Ships demo skills",
|
||||
linkedUserId: "users:1",
|
||||
});
|
||||
expect(result?.owner).not.toHaveProperty("email");
|
||||
expect(result?.owner).not.toHaveProperty("emailVerificationTime");
|
||||
|
||||
+217
-33
@@ -41,7 +41,18 @@ import {
|
||||
summarizeReasonCodes,
|
||||
verdictFromCodes,
|
||||
} from "./lib/moderationReasonCodes";
|
||||
import { type HydratableSkill, toPublicSkill, toPublicUser } from "./lib/public";
|
||||
import {
|
||||
type HydratableSkill,
|
||||
type PublicPublisher,
|
||||
toPublicPublisher,
|
||||
toPublicSkill,
|
||||
toPublicUser,
|
||||
} from "./lib/public";
|
||||
import {
|
||||
ensurePersonalPublisherForUser,
|
||||
getOwnerPublisher,
|
||||
requirePublisherRole,
|
||||
} from "./lib/publishers";
|
||||
import {
|
||||
AUTO_HIDE_REPORT_THRESHOLD,
|
||||
MAX_ACTIVE_REPORTS_PER_USER,
|
||||
@@ -437,21 +448,36 @@ async function syncSkillModerationFromLatestVersion(
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
}
|
||||
|
||||
function buildConflictingSkillUrl(skill: Doc<"skills">, owner: Doc<"users"> | null | undefined) {
|
||||
function buildConflictingSkillUrl(
|
||||
skill: Doc<"skills">,
|
||||
owner: Doc<"users"> | Doc<"publishers"> | null | undefined,
|
||||
) {
|
||||
if (!owner || owner.deletedAt || owner.deactivatedAt || !isPublicSkillDoc(skill)) return null;
|
||||
const ownerParam = owner.handle?.trim() || String(owner._id);
|
||||
if (!ownerParam) return null;
|
||||
return `/${encodeURIComponent(ownerParam)}/${encodeURIComponent(skill.slug)}`;
|
||||
}
|
||||
|
||||
function buildSlugTakenErrorMessage(skill: Doc<"skills">, owner: Doc<"users"> | null | undefined) {
|
||||
function buildSlugTakenErrorMessage(
|
||||
skill: Doc<"skills">,
|
||||
owner: Doc<"users"> | Doc<"publishers"> | null | undefined,
|
||||
) {
|
||||
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."
|
||||
);
|
||||
}
|
||||
const base = "Slug is already taken. Choose a different slug.";
|
||||
const url = buildConflictingSkillUrl(skill, owner);
|
||||
if (!url) return base;
|
||||
return `${base} Existing skill: ${url}`;
|
||||
}
|
||||
|
||||
function buildAliasTakenErrorMessage(skill: Doc<"skills">, owner: Doc<"users"> | null | undefined) {
|
||||
function buildAliasTakenErrorMessage(
|
||||
skill: Doc<"skills">,
|
||||
owner: Doc<"users"> | Doc<"publishers"> | null | undefined,
|
||||
) {
|
||||
const base = "Slug redirects to an existing skill. Choose a different slug.";
|
||||
const url = buildConflictingSkillUrl(skill, owner);
|
||||
if (!url) return base;
|
||||
@@ -972,7 +998,7 @@ type PublicSkillEntry = {
|
||||
skill: NonNullable<ReturnType<typeof toPublicSkill>>;
|
||||
latestVersion: PublicSkillListVersion | null;
|
||||
ownerHandle: string | null;
|
||||
owner: ReturnType<typeof toPublicUser> | null;
|
||||
owner: PublicPublisher | null;
|
||||
};
|
||||
|
||||
type StaffSkillAuditLogEntry = Doc<"auditLogs"> & {
|
||||
@@ -1050,30 +1076,38 @@ async function buildPublicSkillEntries(
|
||||
includeVersion?: boolean;
|
||||
preResolvedOwners?: Map<
|
||||
Id<"skills">,
|
||||
{ ownerHandle: string | null; owner: ReturnType<typeof toPublicUser> | null }
|
||||
{ ownerHandle: string | null; owner: PublicPublisher | null }
|
||||
>;
|
||||
},
|
||||
) {
|
||||
const includeVersion = opts?.includeVersion ?? true;
|
||||
const ownerInfoCache = new Map<
|
||||
Id<"users">,
|
||||
string,
|
||||
Promise<{
|
||||
ownerHandle: string | null;
|
||||
owner: ReturnType<typeof toPublicUser> | null;
|
||||
owner: PublicPublisher | null;
|
||||
}>
|
||||
>();
|
||||
|
||||
const getOwnerInfo = (skillId: Id<"skills">, ownerUserId: Id<"users">) => {
|
||||
const getOwnerInfo = (
|
||||
skillId: Id<"skills">,
|
||||
ownerUserId: Id<"users">,
|
||||
ownerPublisherId?: Id<"publishers"> | null,
|
||||
) => {
|
||||
// Use pre-resolved owner from digest when available to avoid adding the
|
||||
// users table to the reactive read set (which causes thundering-herd
|
||||
// invalidation on every user-doc write).
|
||||
const preResolved = opts?.preResolvedOwners?.get(skillId);
|
||||
if (preResolved?.owner) return Promise.resolve(preResolved);
|
||||
|
||||
const cached = ownerInfoCache.get(ownerUserId);
|
||||
const cacheKey = String(ownerPublisherId ?? ownerUserId);
|
||||
const cached = ownerInfoCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
const ownerPromise = ctx.db.get(ownerUserId).then((ownerDoc) => {
|
||||
const publicOwner = toPublicUser(ownerDoc);
|
||||
const ownerPromise = getOwnerPublisher(ctx, {
|
||||
ownerPublisherId,
|
||||
ownerUserId,
|
||||
}).then((ownerDoc) => {
|
||||
const publicOwner = toPublicPublisher(ownerDoc);
|
||||
if (!publicOwner) {
|
||||
return { ownerHandle: null, owner: null };
|
||||
}
|
||||
@@ -1082,7 +1116,7 @@ async function buildPublicSkillEntries(
|
||||
owner: publicOwner,
|
||||
};
|
||||
});
|
||||
ownerInfoCache.set(ownerUserId, ownerPromise);
|
||||
ownerInfoCache.set(cacheKey, ownerPromise);
|
||||
return ownerPromise;
|
||||
};
|
||||
|
||||
@@ -1095,7 +1129,7 @@ async function buildPublicSkillEntries(
|
||||
includeVersion && !hasSummary && skill.latestVersionId
|
||||
? ctx.db.get(skill.latestVersionId)
|
||||
: null,
|
||||
getOwnerInfo(skill._id, skill.ownerUserId),
|
||||
getOwnerInfo(skill._id, skill.ownerUserId, skill.ownerPublisherId),
|
||||
]);
|
||||
const publicSkill = toPublicSkill(skill);
|
||||
if (!publicSkill || !ownerInfo.owner) return null;
|
||||
@@ -1337,20 +1371,43 @@ export const getBySlug = query({
|
||||
if (!skill) return null;
|
||||
|
||||
const userId = await getAuthUserId(ctx);
|
||||
const isOwner = Boolean(userId && userId === skill.ownerUserId);
|
||||
const ownerPublisher = await getOwnerPublisher(ctx, {
|
||||
ownerPublisherId: skill.ownerPublisherId,
|
||||
ownerUserId: skill.ownerUserId,
|
||||
});
|
||||
const membership =
|
||||
userId && skill.ownerPublisherId
|
||||
? await ctx.db
|
||||
.query("publisherMembers")
|
||||
.withIndex("by_publisher_user", (q) =>
|
||||
q.eq("publisherId", skill.ownerPublisherId!).eq("userId", userId),
|
||||
)
|
||||
.unique()
|
||||
: null;
|
||||
const isOwner = Boolean(userId && (userId === skill.ownerUserId || membership));
|
||||
|
||||
const latestVersion = toPublicSkillVersion(
|
||||
skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null,
|
||||
);
|
||||
const owner = toPublicUser(await ctx.db.get(skill.ownerUserId));
|
||||
const owner = toPublicPublisher(ownerPublisher);
|
||||
if (!owner) return null;
|
||||
const badges = await getSkillBadgeMap(ctx, skill._id);
|
||||
|
||||
const forkOfSkill = skill.forkOf?.skillId ? await ctx.db.get(skill.forkOf.skillId) : null;
|
||||
const forkOfOwner = forkOfSkill ? await ctx.db.get(forkOfSkill.ownerUserId) : null;
|
||||
const forkOfOwner = forkOfSkill
|
||||
? await getOwnerPublisher(ctx, {
|
||||
ownerPublisherId: forkOfSkill.ownerPublisherId,
|
||||
ownerUserId: forkOfSkill.ownerUserId,
|
||||
})
|
||||
: null;
|
||||
|
||||
const canonicalSkill = skill.canonicalSkillId ? await ctx.db.get(skill.canonicalSkillId) : null;
|
||||
const canonicalOwner = canonicalSkill ? await ctx.db.get(canonicalSkill.ownerUserId) : null;
|
||||
const canonicalOwner = canonicalSkill
|
||||
? await getOwnerPublisher(ctx, {
|
||||
ownerPublisherId: canonicalSkill.ownerPublisherId,
|
||||
ownerUserId: canonicalSkill.ownerUserId,
|
||||
})
|
||||
: null;
|
||||
|
||||
const publicSkill = toPublicSkill({ ...skill, badges });
|
||||
|
||||
@@ -1426,8 +1483,8 @@ export const getBySlug = query({
|
||||
displayName: forkOfSkill.displayName,
|
||||
},
|
||||
owner: {
|
||||
handle: forkOfOwner?.handle ?? forkOfOwner?.name ?? null,
|
||||
userId: forkOfOwner?._id ?? null,
|
||||
handle: forkOfOwner?.handle ?? null,
|
||||
userId: forkOfOwner?.linkedUserId ?? null,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
@@ -1438,8 +1495,8 @@ export const getBySlug = query({
|
||||
displayName: canonicalSkill.displayName,
|
||||
},
|
||||
owner: {
|
||||
handle: canonicalOwner?.handle ?? canonicalOwner?.name ?? null,
|
||||
userId: canonicalOwner?._id ?? null,
|
||||
handle: canonicalOwner?.handle ?? null,
|
||||
userId: canonicalOwner?.linkedUserId ?? null,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
@@ -1958,6 +2015,7 @@ export const list = query({
|
||||
args: {
|
||||
batch: v.optional(v.string()),
|
||||
ownerUserId: v.optional(v.id("users")),
|
||||
ownerPublisherId: v.optional(v.id("publishers")),
|
||||
limit: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
@@ -1984,6 +2042,81 @@ export const list = query({
|
||||
.map((skill) => toPublicSkill(skill))
|
||||
.filter((skill): skill is NonNullable<typeof skill> => Boolean(skill));
|
||||
}
|
||||
const ownerPublisherId = args.ownerPublisherId;
|
||||
if (ownerPublisherId) {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
const ownerPublisher = await ctx.db.get(ownerPublisherId);
|
||||
const membership =
|
||||
userId &&
|
||||
(await ctx.db
|
||||
.query("publisherMembers")
|
||||
.withIndex("by_publisher_user", (q) =>
|
||||
q.eq("publisherId", ownerPublisherId).eq("userId", userId),
|
||||
)
|
||||
.unique());
|
||||
const isOwnDashboard = Boolean(
|
||||
membership || (userId && ownerPublisher?.kind === "user" && ownerPublisher.linkedUserId === userId),
|
||||
);
|
||||
const scopedEntries = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", ownerPublisherId))
|
||||
.order("desc")
|
||||
.take(takeLimit);
|
||||
const legacyEntries =
|
||||
ownerPublisher?.kind === "user" && ownerPublisher.linkedUserId
|
||||
? await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_owner", (q) => q.eq("ownerUserId", ownerPublisher.linkedUserId!))
|
||||
.order("desc")
|
||||
.take(takeLimit)
|
||||
: [];
|
||||
const combined = [...scopedEntries, ...legacyEntries].filter(
|
||||
(skill, index, all) =>
|
||||
!skill.softDeletedAt &&
|
||||
(!skill.ownerPublisherId || skill.ownerPublisherId === ownerPublisherId) &&
|
||||
all.findIndex((candidate) => candidate._id === skill._id) === index,
|
||||
);
|
||||
const filtered = combined.slice(0, limit);
|
||||
const withBadges = await attachBadgesToSkills(ctx, filtered);
|
||||
|
||||
if (isOwnDashboard) {
|
||||
return withBadges
|
||||
.map((skill) => {
|
||||
const publicSkill = toPublicSkill(skill);
|
||||
if (publicSkill) return publicSkill;
|
||||
const isPending =
|
||||
skill.moderationStatus === "hidden" && skill.moderationReason === "pending.scan";
|
||||
if (isPending) {
|
||||
const { badges } = skill;
|
||||
return {
|
||||
_id: skill._id,
|
||||
_creationTime: skill._creationTime,
|
||||
slug: skill.slug,
|
||||
displayName: skill.displayName,
|
||||
summary: skill.summary,
|
||||
ownerUserId: skill.ownerUserId,
|
||||
ownerPublisherId: skill.ownerPublisherId,
|
||||
canonicalSkillId: skill.canonicalSkillId,
|
||||
forkOf: skill.forkOf,
|
||||
latestVersionId: skill.latestVersionId,
|
||||
tags: skill.tags,
|
||||
badges,
|
||||
stats: skill.stats,
|
||||
createdAt: skill.createdAt,
|
||||
updatedAt: skill.updatedAt,
|
||||
pendingReview: true as const,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((skill): skill is NonNullable<typeof skill> => Boolean(skill));
|
||||
}
|
||||
|
||||
const visibleSkills = await filterSkillsByActiveOwner(ctx, withBadges);
|
||||
return visibleSkills
|
||||
.map((skill) => toPublicSkill(skill))
|
||||
.filter((skill): skill is NonNullable<typeof skill> => Boolean(skill));
|
||||
}
|
||||
const ownerUserId = args.ownerUserId;
|
||||
if (ownerUserId) {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
@@ -4358,6 +4491,7 @@ export const getVersionBySkillAndVersion = query({
|
||||
|
||||
export const publishVersion: ReturnType<typeof action> = action({
|
||||
args: {
|
||||
ownerHandle: v.optional(v.string()),
|
||||
slug: v.string(),
|
||||
displayName: v.string(),
|
||||
version: v.string(),
|
||||
@@ -4385,7 +4519,14 @@ export const publishVersion: ReturnType<typeof action> = action({
|
||||
throw new ConvexError("MIT-0 license terms must be accepted to publish skills");
|
||||
}
|
||||
const { userId } = await requireUserFromAction(ctx);
|
||||
return publishVersionForUser(ctx, userId, args);
|
||||
const target = (await ctx.runMutation(internal.publishers.resolvePublishTargetForUserInternal, {
|
||||
actorUserId: userId,
|
||||
ownerHandle: args.ownerHandle,
|
||||
minimumRole: "publisher",
|
||||
})) as { publisherId: Id<"publishers"> };
|
||||
return publishVersionForUser(ctx, userId, args, {
|
||||
ownerPublisherId: target.publisherId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4419,6 +4560,15 @@ async function canReadSkillVersionFiles(ctx: ActionCtx, version: Doc<"skillVersi
|
||||
if (authUserId === skill.ownerUserId && !skill.softDeletedAt && !version.softDeletedAt) {
|
||||
return true;
|
||||
}
|
||||
if (skill.ownerPublisherId && !skill.softDeletedAt && !version.softDeletedAt) {
|
||||
const memberRole = (await ctx.runQuery(internal.publishers.getMemberRoleInternal, {
|
||||
publisherId: skill.ownerPublisherId,
|
||||
userId: authUserId,
|
||||
})) as "owner" | "admin" | "publisher" | null;
|
||||
if (memberRole) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const actor = (await ctx.runQuery(internal.users.getByIdInternal, {
|
||||
userId: authUserId,
|
||||
})) as Doc<"users"> | null;
|
||||
@@ -5511,6 +5661,7 @@ export const hardDeleteInternal = internalMutation({
|
||||
export const insertVersion = internalMutation({
|
||||
args: {
|
||||
userId: v.id("users"),
|
||||
ownerPublisherId: v.optional(v.id("publishers")),
|
||||
slug: v.string(),
|
||||
displayName: v.string(),
|
||||
version: v.string(),
|
||||
@@ -5584,6 +5735,16 @@ export const insertVersion = internalMutation({
|
||||
const slug = normalizeSkillSlugForWrite(args.slug);
|
||||
const user = await ctx.db.get(userId);
|
||||
if (!user || user.deletedAt || user.deactivatedAt) throw new Error("User not found");
|
||||
const personalPublisher = await ensurePersonalPublisherForUser(ctx, user);
|
||||
if (!personalPublisher) throw new ConvexError("Personal publisher not found");
|
||||
const ownerPublisherId = args.ownerPublisherId ?? personalPublisher._id;
|
||||
if (ownerPublisherId !== personalPublisher._id) {
|
||||
await requirePublisherRole(ctx, {
|
||||
publisherId: ownerPublisherId,
|
||||
userId,
|
||||
allowed: ["publisher"],
|
||||
});
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
@@ -5596,7 +5757,12 @@ export const insertVersion = internalMutation({
|
||||
const alias = await getSkillSlugAliasBySlug(ctx, slug);
|
||||
if (alias) {
|
||||
const aliasedSkill = await ctx.db.get(alias.skillId);
|
||||
const owner = aliasedSkill ? await ctx.db.get(aliasedSkill.ownerUserId) : null;
|
||||
const owner = aliasedSkill
|
||||
? await getOwnerPublisher(ctx, {
|
||||
ownerPublisherId: aliasedSkill.ownerPublisherId,
|
||||
ownerUserId: aliasedSkill.ownerUserId,
|
||||
})
|
||||
: null;
|
||||
throw new ConvexError(
|
||||
aliasedSkill
|
||||
? buildAliasTakenErrorMessage(aliasedSkill, owner)
|
||||
@@ -5605,10 +5771,21 @@ export const insertVersion = internalMutation({
|
||||
}
|
||||
}
|
||||
|
||||
if (skill && skill.ownerUserId !== userId) {
|
||||
if (skill && skill.ownerPublisherId && skill.ownerPublisherId !== ownerPublisherId) {
|
||||
const owner = await getOwnerPublisher(ctx, {
|
||||
ownerPublisherId: skill.ownerPublisherId,
|
||||
ownerUserId: skill.ownerUserId,
|
||||
});
|
||||
throw new ConvexError(buildSlugTakenErrorMessage(skill, owner));
|
||||
}
|
||||
|
||||
if (skill && !skill.ownerPublisherId && skill.ownerUserId !== userId) {
|
||||
// Fallback: Convex Auth can create duplicate `users` records. Heal ownership ONLY
|
||||
// when the underlying GitHub identity matches (authAccounts.providerAccountId).
|
||||
const owner = await ctx.db.get(skill.ownerUserId);
|
||||
const owner = await getOwnerPublisher(ctx, {
|
||||
ownerPublisherId: skill.ownerPublisherId,
|
||||
ownerUserId: skill.ownerUserId,
|
||||
});
|
||||
const slugTakenMessage = buildSlugTakenErrorMessage(skill, owner);
|
||||
|
||||
// Check GitHub identity FIRST so ownership healing works even when the
|
||||
@@ -5625,16 +5802,21 @@ export const insertVersion = internalMutation({
|
||||
callerProviderAccountId,
|
||||
)
|
||||
) {
|
||||
await ctx.db.patch(skill._id, { ownerUserId: userId, updatedAt: now });
|
||||
skill = { ...skill, ownerUserId: userId };
|
||||
} else if (!owner || owner.deletedAt || owner.deactivatedAt) {
|
||||
throw new ConvexError(
|
||||
"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.",
|
||||
);
|
||||
await ctx.db.patch(skill._id, {
|
||||
ownerUserId: userId,
|
||||
ownerPublisherId,
|
||||
updatedAt: now,
|
||||
});
|
||||
skill = { ...skill, ownerUserId: userId, ownerPublisherId };
|
||||
} else {
|
||||
throw new ConvexError(slugTakenMessage);
|
||||
}
|
||||
} else if (skill && !skill.ownerPublisherId) {
|
||||
await ctx.db.patch(skill._id, {
|
||||
ownerPublisherId,
|
||||
updatedAt: now,
|
||||
});
|
||||
skill = { ...skill, ownerPublisherId };
|
||||
}
|
||||
|
||||
const qualityAssessment = args.qualityAssessment;
|
||||
@@ -5755,6 +5937,7 @@ export const insertVersion = internalMutation({
|
||||
displayName: args.displayName,
|
||||
summary: summaryValue,
|
||||
ownerUserId: userId,
|
||||
ownerPublisherId,
|
||||
canonicalSkillId,
|
||||
forkOf,
|
||||
latestVersionId: undefined,
|
||||
@@ -5862,6 +6045,7 @@ export const insertVersion = internalMutation({
|
||||
const basePatch: SkillModerationPatch = {
|
||||
displayName: args.displayName,
|
||||
summary: nextSummary ?? undefined,
|
||||
ownerPublisherId: skill.ownerPublisherId ?? ownerPublisherId,
|
||||
latestVersionId: versionId,
|
||||
latestVersionSummary: {
|
||||
version: args.version,
|
||||
|
||||
+304
-3
@@ -1,5 +1,9 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@convex-dev/auth/server", () => ({
|
||||
getAuthUserId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./lib/access", async () => {
|
||||
const actual = await vi.importActual<typeof import("./lib/access")>("./lib/access");
|
||||
return { ...actual, requireUser: vi.fn() };
|
||||
@@ -10,21 +14,45 @@ vi.mock("./skillStatEvents", () => ({
|
||||
}));
|
||||
|
||||
const { requireUser } = await import("./lib/access");
|
||||
const { getAuthUserId } = await import("@convex-dev/auth/server");
|
||||
const { insertStatEvent } = await import("./skillStatEvents");
|
||||
const {
|
||||
ensureHandler,
|
||||
list,
|
||||
searchInternal,
|
||||
banUserInternal,
|
||||
me,
|
||||
placeUserUnderModerationInternal,
|
||||
reserveHandleInternal,
|
||||
syncGitHubProfileInternal,
|
||||
} = await import("./users");
|
||||
|
||||
type WrappedHandler<TArgs, TResult> = {
|
||||
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
|
||||
};
|
||||
|
||||
const meHandler = (me as unknown as WrappedHandler<Record<string, never>, unknown>)._handler;
|
||||
|
||||
function makeCtx() {
|
||||
const patch = vi.fn();
|
||||
const get = vi.fn();
|
||||
const insert = vi.fn();
|
||||
const publisherRows = new Map<string, Record<string, unknown>>();
|
||||
const publisherMembers: Array<Record<string, unknown>> = [];
|
||||
const get = vi.fn(async (id: string) => publisherRows.get(id) ?? null);
|
||||
const insert = vi.fn(async (table: string, value: Record<string, unknown>) => {
|
||||
if (table === "publishers") {
|
||||
const handle = typeof value.handle === "string" ? value.handle : "user";
|
||||
const id = `publishers:${handle}`;
|
||||
publisherRows.set(id, { _id: id, _creationTime: 1, ...value });
|
||||
return id;
|
||||
}
|
||||
if (table === "publisherMembers") {
|
||||
const id = `publisherMembers:${publisherMembers.length + 1}`;
|
||||
publisherMembers.push({ _id: id, ...value });
|
||||
return id;
|
||||
}
|
||||
if (table === "auditLogs") return "auditLogs:1";
|
||||
return `${table}:1`;
|
||||
});
|
||||
const query = vi.fn((table: string) => {
|
||||
if (table === "reservedHandles") {
|
||||
return {
|
||||
@@ -44,6 +72,39 @@ function makeCtx() {
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name === "by_handle") {
|
||||
return { unique: vi.fn(async () => null) };
|
||||
}
|
||||
if (name === "by_linked_user") {
|
||||
return { unique: vi.fn(async () => null) };
|
||||
}
|
||||
throw new Error(`Unexpected publishers index ${name}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_publisher_user") {
|
||||
throw new Error(`Unexpected publisherMembers index ${name}`);
|
||||
}
|
||||
return { unique: vi.fn(async () => null) };
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "packages" || table === "skills") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_owner_publisher") {
|
||||
throw new Error(`Unexpected ${table} index ${name}`);
|
||||
}
|
||||
return { collect: vi.fn(async () => []) };
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
});
|
||||
return {
|
||||
@@ -116,6 +177,7 @@ function makeBanCtx() {
|
||||
describe("ensureHandler", () => {
|
||||
afterEach(() => {
|
||||
vi.mocked(requireUser).mockReset();
|
||||
vi.mocked(getAuthUserId).mockReset();
|
||||
});
|
||||
|
||||
it("updates handle and display name when GitHub login changes", async () => {
|
||||
@@ -212,7 +274,14 @@ describe("ensureHandler", () => {
|
||||
|
||||
const result = await ensureHandler(ctx);
|
||||
|
||||
expect(patch).not.toHaveBeenCalled();
|
||||
expect(patch).not.toHaveBeenCalledWith(
|
||||
"users:4",
|
||||
expect.objectContaining({
|
||||
handle: expect.anything(),
|
||||
displayName: expect.anything(),
|
||||
role: expect.anything(),
|
||||
}),
|
||||
);
|
||||
expect(get).toHaveBeenCalledWith("users:4");
|
||||
expect(result).toMatchObject({ _id: "users:4" });
|
||||
});
|
||||
@@ -310,6 +379,125 @@ describe("ensureHandler", () => {
|
||||
|
||||
expect(patch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not auto-claim a handle already owned by an org publisher", async () => {
|
||||
const { ctx, patch, query } = makeCtx();
|
||||
query.mockImplementation(((table: string) => {
|
||||
if (table === "reservedHandles") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_handle_active_updatedAt") {
|
||||
throw new Error(`Unexpected reservedHandles index ${name}`);
|
||||
}
|
||||
return { order: () => ({ take: async () => [] }) };
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: (name: string, builder?: (q: { eq: (field: string, value: string) => 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 () =>
|
||||
handle === "openclaw"
|
||||
? {
|
||||
_id: "publishers:openclaw",
|
||||
kind: "org",
|
||||
handle: "openclaw",
|
||||
displayName: "OpenClaw",
|
||||
}
|
||||
: null,
|
||||
),
|
||||
};
|
||||
}
|
||||
if (name === "by_linked_user") {
|
||||
return {
|
||||
unique: vi.fn(async () =>
|
||||
linkedUserId === "users:other"
|
||||
? {
|
||||
_id: "publishers:openclaw-user",
|
||||
kind: "user",
|
||||
handle: "openclaw-user",
|
||||
linkedUserId: "users:other",
|
||||
displayName: "OpenClaw User",
|
||||
}
|
||||
: null,
|
||||
),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected publishers index ${name}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_publisher_user") {
|
||||
throw new Error(`Unexpected publisherMembers index ${name}`);
|
||||
}
|
||||
return { unique: vi.fn(async () => null) };
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "packages" || table === "skills") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_owner_publisher") {
|
||||
throw new Error(`Unexpected ${table} index ${name}`);
|
||||
}
|
||||
return { collect: vi.fn(async () => []) };
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
}) as never);
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:other",
|
||||
user: {
|
||||
_id: "users:other",
|
||||
_creationTime: 1,
|
||||
handle: undefined,
|
||||
displayName: undefined,
|
||||
name: "openclaw",
|
||||
email: undefined,
|
||||
role: "user",
|
||||
createdAt: 1,
|
||||
},
|
||||
} as never);
|
||||
|
||||
await ensureHandler(ctx);
|
||||
|
||||
expect(patch).not.toHaveBeenCalledWith(
|
||||
"users:other",
|
||||
expect.objectContaining({ handle: "openclaw" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("me", () => {
|
||||
afterEach(() => {
|
||||
vi.mocked(getAuthUserId).mockReset();
|
||||
});
|
||||
|
||||
it("returns null when auth resolution throws", async () => {
|
||||
vi.mocked(getAuthUserId).mockRejectedValue(new Error("stale session"));
|
||||
const get = vi.fn();
|
||||
|
||||
const result = await meHandler({ db: { get } } as never, {});
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("users.syncGitHubProfileInternal", () => {
|
||||
@@ -370,6 +558,119 @@ describe("users.syncGitHubProfileInternal", () => {
|
||||
expect.objectContaining({ handle: "openclaw" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a derived handle unchanged when the new login belongs to an org publisher", async () => {
|
||||
const { ctx, get, patch, query } = makeCtx();
|
||||
get.mockResolvedValue({
|
||||
_id: "users:other",
|
||||
handle: "old-handle",
|
||||
displayName: "old-handle",
|
||||
name: "old-handle",
|
||||
});
|
||||
query.mockImplementation(((table: string) => {
|
||||
if (table === "reservedHandles") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_handle_active_updatedAt") {
|
||||
throw new Error(`Unexpected reservedHandles index ${name}`);
|
||||
}
|
||||
return { order: () => ({ take: async () => [] }) };
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: (name: string, builder?: (q: { eq: (field: string, value: string) => 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 () =>
|
||||
handle === "openclaw"
|
||||
? {
|
||||
_id: "publishers:openclaw",
|
||||
kind: "org",
|
||||
handle: "openclaw",
|
||||
displayName: "OpenClaw",
|
||||
}
|
||||
: null,
|
||||
),
|
||||
};
|
||||
}
|
||||
if (name === "by_linked_user") {
|
||||
return {
|
||||
unique: vi.fn(async () =>
|
||||
linkedUserId === "users:other"
|
||||
? {
|
||||
_id: "publishers:old-handle",
|
||||
kind: "user",
|
||||
handle: "old-handle",
|
||||
linkedUserId: "users:other",
|
||||
displayName: "Old Handle",
|
||||
}
|
||||
: null,
|
||||
),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected publishers index ${name}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_publisher_user") {
|
||||
throw new Error(`Unexpected publisherMembers index ${name}`);
|
||||
}
|
||||
return { unique: vi.fn(async () => null) };
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "packages" || table === "skills") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_owner_publisher") {
|
||||
throw new Error(`Unexpected ${table} index ${name}`);
|
||||
}
|
||||
return { collect: vi.fn(async () => []) };
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
}) as never);
|
||||
|
||||
const handler = (
|
||||
syncGitHubProfileInternal as unknown as {
|
||||
_handler: (ctx: unknown, args: unknown) => Promise<void>;
|
||||
}
|
||||
)._handler;
|
||||
|
||||
await handler(ctx, {
|
||||
userId: "users:other",
|
||||
name: "openclaw",
|
||||
syncedAt: 10,
|
||||
});
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"users:other",
|
||||
expect.objectContaining({
|
||||
githubProfileSyncedAt: 10,
|
||||
name: "openclaw",
|
||||
}),
|
||||
);
|
||||
expect(patch).not.toHaveBeenCalledWith(
|
||||
"users:other",
|
||||
expect.objectContaining({ handle: "openclaw" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("users.list", () => {
|
||||
|
||||
+180
-6
@@ -6,6 +6,7 @@ import type { ActionCtx, MutationCtx } from "./_generated/server";
|
||||
import { internalAction, internalMutation, internalQuery, mutation, query } from "./functions";
|
||||
import { assertAdmin, assertModerator, requireUser } from "./lib/access";
|
||||
import { syncGitHubProfile } from "./lib/githubAccount";
|
||||
import { ensurePersonalPublisherForUser, getPublisherByHandle } from "./lib/publishers";
|
||||
import { toPublicUser } from "./lib/public";
|
||||
import {
|
||||
getLatestActiveReservedHandle,
|
||||
@@ -32,6 +33,18 @@ export const getByIdInternal = internalQuery({
|
||||
handler: async (ctx, args) => ctx.db.get(args.userId),
|
||||
});
|
||||
|
||||
export const getByHandleInternal = internalQuery({
|
||||
args: { handle: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const normalizedHandle = normalizeReservedHandle(args.handle);
|
||||
if (!normalizedHandle) return null;
|
||||
return await ctx.db
|
||||
.query("users")
|
||||
.withIndex("handle", (q) => q.eq("handle", normalizedHandle))
|
||||
.unique();
|
||||
},
|
||||
});
|
||||
|
||||
export const searchInternal = internalQuery({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
@@ -84,7 +97,7 @@ export const syncGitHubProfileInternal = internalMutation({
|
||||
handler: async (ctx, args) => {
|
||||
const user = await ctx.db.get(args.userId);
|
||||
if (!user || user.deletedAt || user.deactivatedAt) return;
|
||||
const canClaimNewHandle = !(await isHandleReservedForAnotherUser(ctx, args.name, args.userId));
|
||||
const canClaimNewHandle = await canUserClaimHandle(ctx, args.name, args.userId);
|
||||
|
||||
const updates: Partial<Doc<"users">> = { githubProfileSyncedAt: args.syncedAt };
|
||||
let didChangeProfile = false;
|
||||
@@ -134,6 +147,8 @@ export const syncGitHubProfileInternal = internalMutation({
|
||||
updates.updatedAt = Date.now();
|
||||
}
|
||||
await ctx.db.patch(args.userId, updates);
|
||||
const nextUser = didChangeProfile ? ({ ...user, ...updates } as Doc<"users">) : user;
|
||||
await ensurePersonalPublisherForUser(ctx, nextUser);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -151,7 +166,13 @@ export const syncGitHubProfileAction = internalAction({
|
||||
export const me = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
let userId: Awaited<ReturnType<typeof getAuthUserId>>;
|
||||
try {
|
||||
userId = await getAuthUserId(ctx);
|
||||
} catch {
|
||||
// Public pages should treat broken/stale auth as anonymous instead of crashing SSR.
|
||||
return null;
|
||||
}
|
||||
if (!userId) return null;
|
||||
const user = await ctx.db.get(userId);
|
||||
if (!user || user.deletedAt || user.deactivatedAt) return null;
|
||||
@@ -176,6 +197,40 @@ function deriveHandle(args: { existingHandle?: string; githubLogin?: string; ema
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function appendHandleSuffix(base: string, suffix: number) {
|
||||
const suffixText = suffix <= 1 ? "" : `-${suffix}`;
|
||||
const maxBaseLength = Math.max(2, 40 - suffixText.length);
|
||||
return `${base.slice(0, maxBaseLength)}${suffixText}`;
|
||||
}
|
||||
|
||||
async function resolveAvailableHandle(
|
||||
ctx: MutationCtx,
|
||||
preferredHandle: string | undefined,
|
||||
userId: Id<"users">,
|
||||
) {
|
||||
const normalizedHandle = normalizeReservedHandle(preferredHandle);
|
||||
if (!normalizedHandle) return undefined;
|
||||
for (let suffix = 1; suffix <= 50; suffix += 1) {
|
||||
const candidate = appendHandleSuffix(normalizedHandle, suffix);
|
||||
if (await canUserClaimHandle(ctx, candidate, userId)) return candidate;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function canUserClaimHandle(
|
||||
ctx: MutationCtx,
|
||||
handle: string | undefined,
|
||||
userId: Id<"users">,
|
||||
) {
|
||||
const normalizedHandle = normalizeReservedHandle(handle);
|
||||
if (!normalizedHandle) return false;
|
||||
if (await isHandleReservedForAnotherUser(ctx, normalizedHandle, userId)) return false;
|
||||
|
||||
const publisher = await getPublisherByHandle(ctx, normalizedHandle);
|
||||
if (!publisher || publisher.deletedAt || publisher.deactivatedAt) return true;
|
||||
return publisher.kind === "user" && publisher.linkedUserId === userId;
|
||||
}
|
||||
|
||||
async function computeEnsureUpdates(ctx: MutationCtx, user: Doc<"users">) {
|
||||
const updates: Record<string, unknown> = {};
|
||||
|
||||
@@ -186,10 +241,18 @@ async function computeEnsureUpdates(ctx: MutationCtx, user: Doc<"users">) {
|
||||
githubLogin,
|
||||
email: user.email,
|
||||
});
|
||||
const derivedHandle =
|
||||
requestedHandle && !(await isHandleReservedForAnotherUser(ctx, requestedHandle, user._id))
|
||||
let derivedHandle =
|
||||
requestedHandle && (await canUserClaimHandle(ctx, requestedHandle, user._id))
|
||||
? requestedHandle
|
||||
: undefined;
|
||||
if (!derivedHandle && !existingHandle) {
|
||||
const emailFallback = !requestedHandle && user.email ? user.email.split("@")[0]?.trim() : user.email?.split("@")[0]?.trim();
|
||||
derivedHandle =
|
||||
(emailFallback &&
|
||||
emailFallback !== requestedHandle &&
|
||||
(await resolveAvailableHandle(ctx, emailFallback, user._id))) ||
|
||||
(await resolveAvailableHandle(ctx, requestedHandle, user._id));
|
||||
}
|
||||
const baseHandle = derivedHandle ?? existingHandle;
|
||||
|
||||
if (derivedHandle && existingHandle !== derivedHandle) {
|
||||
@@ -216,12 +279,14 @@ export async function ensureHandler(ctx: MutationCtx) {
|
||||
const { userId, user } = await requireUser(ctx);
|
||||
const updates = await computeEnsureUpdates(ctx, user);
|
||||
|
||||
const hasUpdates = Object.keys(updates).length > 0;
|
||||
if (Object.keys(updates).length > 0) {
|
||||
updates.updatedAt = Date.now();
|
||||
await ctx.db.patch(userId, updates);
|
||||
}
|
||||
|
||||
return ctx.db.get(userId);
|
||||
const ensuredUser = hasUpdates ? ({ ...user, ...updates } as Doc<"users">) : ((await ctx.db.get(userId)) ?? user);
|
||||
await ensurePersonalPublisherForUser(ctx, ensuredUser);
|
||||
return await ctx.db.get(userId);
|
||||
}
|
||||
|
||||
export const updateProfile = mutation({
|
||||
@@ -236,6 +301,10 @@ export const updateProfile = mutation({
|
||||
bio: args.bio?.trim(),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
const user = await ctx.db.get(userId);
|
||||
if (user) {
|
||||
await ensurePersonalPublisherForUser(ctx, user);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -713,6 +782,111 @@ export const setTrustedPublisherInternal = internalMutation({
|
||||
},
|
||||
});
|
||||
|
||||
async function ensurePublisherHandleWithActor(
|
||||
ctx: MutationCtx,
|
||||
args: {
|
||||
actorUserId: Id<"users">;
|
||||
handle: string;
|
||||
displayName?: string;
|
||||
trusted?: boolean;
|
||||
},
|
||||
) {
|
||||
const actor = await ctx.db.get(args.actorUserId);
|
||||
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new Error("User not found");
|
||||
assertAdmin(actor);
|
||||
|
||||
const normalizedHandle = normalizeReservedHandle(args.handle);
|
||||
if (!normalizedHandle) throw new Error("Handle required");
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("handle", (q) => q.eq("handle", normalizedHandle))
|
||||
.unique();
|
||||
if (existing?.deletedAt || existing?.deactivatedAt) {
|
||||
throw new Error("Handle belongs to a deleted or deactivated user");
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const displayName = args.displayName?.trim() || normalizedHandle;
|
||||
const trusted = args.trusted === false ? undefined : true;
|
||||
const userId =
|
||||
existing?._id ??
|
||||
(await ctx.db.insert("users", {
|
||||
handle: normalizedHandle,
|
||||
displayName,
|
||||
role: "user",
|
||||
trustedPublisher: trusted,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}));
|
||||
|
||||
if (existing) {
|
||||
const nextDisplayName =
|
||||
args.displayName?.trim() && (!existing.displayName || existing.displayName === existing.handle)
|
||||
? displayName
|
||||
: existing.displayName;
|
||||
await ctx.db.patch(existing._id, {
|
||||
displayName: nextDisplayName,
|
||||
trustedPublisher: trusted,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
await upsertReservedHandleForRightfulOwner(ctx, {
|
||||
handle: normalizedHandle,
|
||||
rightfulOwnerUserId: userId,
|
||||
reason: "shared publisher",
|
||||
now,
|
||||
});
|
||||
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: args.actorUserId,
|
||||
action: "user.publisher.ensure",
|
||||
targetType: "user",
|
||||
targetId: userId,
|
||||
metadata: {
|
||||
handle: normalizedHandle,
|
||||
trusted: trusted === true,
|
||||
},
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
userId,
|
||||
handle: normalizedHandle,
|
||||
created: !existing,
|
||||
trusted: trusted === true,
|
||||
};
|
||||
}
|
||||
|
||||
export const ensurePublisherHandle = mutation({
|
||||
args: {
|
||||
handle: v.string(),
|
||||
displayName: v.optional(v.string()),
|
||||
trusted: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUser(ctx);
|
||||
return await ensurePublisherHandleWithActor(ctx, {
|
||||
actorUserId: user._id,
|
||||
handle: args.handle,
|
||||
displayName: args.displayName,
|
||||
trusted: args.trusted,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const ensurePublisherHandleInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
handle: v.string(),
|
||||
displayName: v.optional(v.string()),
|
||||
trusted: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => await ensurePublisherHandleWithActor(ctx, args),
|
||||
});
|
||||
|
||||
/**
|
||||
* Auto-ban a user whose skill was flagged malicious by VT.
|
||||
* Skips moderators/admins. No actor required — this is a system-level action.
|
||||
|
||||
@@ -47,12 +47,16 @@ function makeActionCtx(args: {
|
||||
soul?: Record<string, unknown> | null;
|
||||
version?: Record<string, unknown> | null;
|
||||
actor?: Record<string, unknown> | null;
|
||||
publisherMemberRole?: "owner" | "admin" | "publisher" | null;
|
||||
}) {
|
||||
return {
|
||||
runQuery: vi.fn(async (_endpoint: unknown, payload: Record<string, unknown>) => {
|
||||
if (payload.versionId && args.version) return args.version ?? null;
|
||||
if (payload.skillId && args.skill) return args.skill ?? null;
|
||||
if (payload.soulId && args.soul) return args.soul ?? null;
|
||||
if (payload.publisherId && payload.userId === args.actor?._id) {
|
||||
return args.publisherMemberRole ?? null;
|
||||
}
|
||||
if (payload.userId === args.actor?._id) {
|
||||
return args.actor ?? null;
|
||||
}
|
||||
@@ -104,6 +108,28 @@ describe("version file access actions", () => {
|
||||
).resolves.toEqual({ path: "SKILL.md", text: "# skill" });
|
||||
});
|
||||
|
||||
it("allows org collaborators to read hidden skill versions", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:member" as never);
|
||||
const ctx = makeActionCtx({
|
||||
actor: { _id: "users:member", role: "user" },
|
||||
publisherMemberRole: "publisher",
|
||||
version: makeSkillVersion(),
|
||||
skill: {
|
||||
_id: "skills:1",
|
||||
ownerUserId: "users:owner",
|
||||
ownerPublisherId: "publishers:org",
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "pending.scan",
|
||||
moderationFlags: [],
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
getSkillReadmeHandler._handler(ctx, { versionId: "skillVersions:1" } as never),
|
||||
).resolves.toEqual({ path: "SKILL.md", text: "# skill" });
|
||||
});
|
||||
|
||||
it("allows owners to read hidden skill files", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
|
||||
const ctx = makeActionCtx({
|
||||
|
||||
+512
-2
@@ -1,5 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { __test } from "./vt";
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { __test, pollPackageReleaseScanResults, scanPackageReleaseWithVirusTotal } from "./vt";
|
||||
|
||||
type WrappedHandler<TArgs, TResult> = {
|
||||
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
|
||||
};
|
||||
|
||||
const scanPackageReleaseWithVirusTotalHandler = (
|
||||
scanPackageReleaseWithVirusTotal as unknown as WrappedHandler<
|
||||
{ releaseId: string; attempt?: number },
|
||||
void
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const pollPackageReleaseScanResultsHandler = (
|
||||
pollPackageReleaseScanResults as unknown as WrappedHandler<
|
||||
{ releaseId: string; attempt?: number },
|
||||
void
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const originalVtApiKey = process.env.VT_API_KEY;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalVtApiKey === undefined) {
|
||||
delete process.env.VT_API_KEY;
|
||||
} else {
|
||||
process.env.VT_API_KEY = originalVtApiKey;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("vt activation fallback", () => {
|
||||
it("activates only VT-pending hidden skills", () => {
|
||||
@@ -100,3 +132,481 @@ describe("vt AV engine fallback verdicts", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("package VT retries", () => {
|
||||
it("retries package scan when release files are not readable yet", async () => {
|
||||
process.env.VT_API_KEY = "test-key";
|
||||
const scheduler = { runAfter: vi.fn(async () => null) };
|
||||
|
||||
await scanPackageReleaseWithVirusTotalHandler(
|
||||
{
|
||||
runQuery: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packageReleases:demo",
|
||||
packageId: "packages:demo",
|
||||
version: "1.0.0",
|
||||
files: [{ path: "package.json", storageId: "storage:pkg" }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packages:demo",
|
||||
name: "demo-plugin",
|
||||
}),
|
||||
runMutation: vi.fn(async () => null),
|
||||
scheduler,
|
||||
storage: {
|
||||
get: vi.fn(async () => null),
|
||||
},
|
||||
} as never,
|
||||
{ releaseId: "packageReleases:demo", attempt: 2 },
|
||||
);
|
||||
|
||||
expect(scheduler.runAfter).toHaveBeenCalledWith(
|
||||
5 * 60 * 1000,
|
||||
expect.anything(),
|
||||
{ releaseId: "packageReleases:demo", attempt: 3 },
|
||||
);
|
||||
});
|
||||
|
||||
it("retries package upload when VT upload fails", async () => {
|
||||
process.env.VT_API_KEY = "test-key";
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response("", { status: 404 }))
|
||||
.mockResolvedValueOnce(new Response("rate limited", { status: 429 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const runMutation = vi.fn(async () => null);
|
||||
const scheduler = { runAfter: vi.fn(async () => null) };
|
||||
await scanPackageReleaseWithVirusTotalHandler(
|
||||
{
|
||||
runQuery: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packageReleases:demo",
|
||||
packageId: "packages:demo",
|
||||
version: "1.0.0",
|
||||
files: [{ path: "package.json", storageId: "storage:pkg" }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packages:demo",
|
||||
name: "demo-plugin",
|
||||
}),
|
||||
runMutation,
|
||||
scheduler,
|
||||
storage: {
|
||||
get: vi.fn(async () => new Blob(['{"name":"demo-plugin"}'], { type: "application/json" })),
|
||||
},
|
||||
} as never,
|
||||
{ releaseId: "packageReleases:demo" },
|
||||
);
|
||||
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
releaseId: "packageReleases:demo",
|
||||
sha256hash: expect.any(String),
|
||||
}),
|
||||
);
|
||||
expect(scheduler.runAfter).toHaveBeenCalledWith(
|
||||
5 * 60 * 1000,
|
||||
expect.anything(),
|
||||
{ releaseId: "packageReleases:demo", attempt: 2 },
|
||||
);
|
||||
});
|
||||
|
||||
it("uses existing AV engine verdicts for packages without re-uploading", async () => {
|
||||
process.env.VT_API_KEY = "test-key";
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
attributes: {
|
||||
last_analysis_stats: {
|
||||
malicious: 0,
|
||||
suspicious: 1,
|
||||
harmless: 10,
|
||||
undetected: 40,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const runMutation = vi.fn(async () => null);
|
||||
const scheduler = { runAfter: vi.fn(async () => null) };
|
||||
await scanPackageReleaseWithVirusTotalHandler(
|
||||
{
|
||||
runQuery: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packageReleases:demo",
|
||||
packageId: "packages:demo",
|
||||
version: "1.0.0",
|
||||
verification: { tier: "source-linked" },
|
||||
llmAnalysis: { status: "clean" },
|
||||
staticScan: { status: "clean" },
|
||||
files: [{ path: "package.json", storageId: "storage:pkg" }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packages:demo",
|
||||
name: "demo-plugin",
|
||||
family: "code-plugin",
|
||||
isOfficial: true,
|
||||
}),
|
||||
runMutation,
|
||||
scheduler,
|
||||
storage: {
|
||||
get: vi.fn(async () => new Blob(['{"name":"demo-plugin"}'], { type: "application/json" })),
|
||||
},
|
||||
} as never,
|
||||
{ releaseId: "packageReleases:demo" },
|
||||
);
|
||||
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
releaseId: "packageReleases:demo",
|
||||
vtAnalysis: expect.objectContaining({ status: "suspicious", source: "engines" }),
|
||||
}),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(scheduler.runAfter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("promotes official source-linked packages with undetected-only VT stats via fallback", async () => {
|
||||
process.env.VT_API_KEY = "test-key";
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
attributes: {
|
||||
last_analysis_stats: {
|
||||
malicious: 0,
|
||||
suspicious: 0,
|
||||
harmless: 0,
|
||||
undetected: 66,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const runMutation = vi.fn(async () => null);
|
||||
const scheduler = { runAfter: vi.fn(async () => null) };
|
||||
await scanPackageReleaseWithVirusTotalHandler(
|
||||
{
|
||||
runQuery: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packageReleases:demo",
|
||||
packageId: "packages:demo",
|
||||
version: "1.0.0",
|
||||
verification: { tier: "source-linked" },
|
||||
llmAnalysis: { status: "clean" },
|
||||
staticScan: { status: "suspicious" },
|
||||
files: [{ path: "package.json", storageId: "storage:pkg" }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packages:demo",
|
||||
name: "demo-plugin",
|
||||
family: "code-plugin",
|
||||
isOfficial: true,
|
||||
}),
|
||||
runMutation,
|
||||
scheduler,
|
||||
storage: {
|
||||
get: vi.fn(async () => new Blob(['{"name":"demo-plugin"}'], { type: "application/json" })),
|
||||
},
|
||||
} as never,
|
||||
{ releaseId: "packageReleases:demo" },
|
||||
);
|
||||
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
releaseId: "packageReleases:demo",
|
||||
vtAnalysis: expect.objectContaining({
|
||||
status: "clean",
|
||||
source: "engines-undetected-fallback",
|
||||
verdict: "undetected-only-fallback",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(scheduler.runAfter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("promotes community source-linked packages with undetected-only VT stats via fallback", async () => {
|
||||
process.env.VT_API_KEY = "test-key";
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
attributes: {
|
||||
last_analysis_stats: {
|
||||
malicious: 0,
|
||||
suspicious: 0,
|
||||
harmless: 0,
|
||||
undetected: 66,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const runMutation = vi.fn(async () => null);
|
||||
const scheduler = { runAfter: vi.fn(async () => null) };
|
||||
await scanPackageReleaseWithVirusTotalHandler(
|
||||
{
|
||||
runQuery: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packageReleases:demo",
|
||||
packageId: "packages:demo",
|
||||
version: "1.0.0",
|
||||
verification: { tier: "source-linked" },
|
||||
llmAnalysis: { status: "clean" },
|
||||
staticScan: { status: "clean" },
|
||||
files: [{ path: "package.json", storageId: "storage:pkg" }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packages:demo",
|
||||
name: "demo-plugin",
|
||||
family: "code-plugin",
|
||||
isOfficial: false,
|
||||
}),
|
||||
runMutation,
|
||||
scheduler,
|
||||
storage: {
|
||||
get: vi.fn(async () => new Blob(['{"name":"demo-plugin"}'], { type: "application/json" })),
|
||||
},
|
||||
} as never,
|
||||
{ releaseId: "packageReleases:demo" },
|
||||
);
|
||||
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
releaseId: "packageReleases:demo",
|
||||
vtAnalysis: expect.objectContaining({
|
||||
status: "clean",
|
||||
source: "engines-undetected-fallback",
|
||||
verdict: "undetected-only-fallback",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(scheduler.runAfter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retries package poll when VT lookup throws", async () => {
|
||||
process.env.VT_API_KEY = "test-key";
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network error")));
|
||||
|
||||
const scheduler = { runAfter: vi.fn(async () => null) };
|
||||
await pollPackageReleaseScanResultsHandler(
|
||||
{
|
||||
runQuery: vi.fn().mockResolvedValue({
|
||||
_id: "packageReleases:demo",
|
||||
packageId: "packages:demo",
|
||||
version: "1.0.0",
|
||||
sha256hash: "abc123",
|
||||
}),
|
||||
runMutation: vi.fn(async () => null),
|
||||
scheduler,
|
||||
} as never,
|
||||
{ releaseId: "packageReleases:demo", attempt: 3 },
|
||||
);
|
||||
|
||||
expect(scheduler.runAfter).toHaveBeenCalledWith(
|
||||
5 * 60 * 1000,
|
||||
expect.anything(),
|
||||
{ releaseId: "packageReleases:demo", attempt: 4 },
|
||||
);
|
||||
});
|
||||
|
||||
it("applies the same undetected-only fallback during package polling", async () => {
|
||||
process.env.VT_API_KEY = "test-key";
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
attributes: {
|
||||
last_analysis_stats: {
|
||||
malicious: 0,
|
||||
suspicious: 0,
|
||||
harmless: 0,
|
||||
undetected: 66,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const runMutation = vi.fn(async () => null);
|
||||
const scheduler = { runAfter: vi.fn(async () => null) };
|
||||
await pollPackageReleaseScanResultsHandler(
|
||||
{
|
||||
runQuery: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packageReleases:demo",
|
||||
packageId: "packages:demo",
|
||||
version: "1.0.0",
|
||||
sha256hash: "abc123",
|
||||
verification: { tier: "source-linked" },
|
||||
llmAnalysis: { status: "clean" },
|
||||
staticScan: { status: "suspicious" },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packages:demo",
|
||||
family: "code-plugin",
|
||||
isOfficial: true,
|
||||
}),
|
||||
runMutation,
|
||||
scheduler,
|
||||
} as never,
|
||||
{ releaseId: "packageReleases:demo", attempt: 3 },
|
||||
);
|
||||
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
releaseId: "packageReleases:demo",
|
||||
vtAnalysis: expect.objectContaining({
|
||||
status: "clean",
|
||||
source: "engines-undetected-fallback",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(scheduler.runAfter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies the same undetected-only fallback during community package polling", async () => {
|
||||
process.env.VT_API_KEY = "test-key";
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
attributes: {
|
||||
last_analysis_stats: {
|
||||
malicious: 0,
|
||||
suspicious: 0,
|
||||
harmless: 0,
|
||||
undetected: 66,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const runMutation = vi.fn(async () => null);
|
||||
const scheduler = { runAfter: vi.fn(async () => null) };
|
||||
await pollPackageReleaseScanResultsHandler(
|
||||
{
|
||||
runQuery: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packageReleases:demo",
|
||||
packageId: "packages:demo",
|
||||
version: "1.0.0",
|
||||
sha256hash: "abc123",
|
||||
verification: { tier: "source-linked" },
|
||||
llmAnalysis: { status: "clean" },
|
||||
staticScan: { status: "clean" },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packages:demo",
|
||||
family: "code-plugin",
|
||||
isOfficial: false,
|
||||
}),
|
||||
runMutation,
|
||||
scheduler,
|
||||
} as never,
|
||||
{ releaseId: "packageReleases:demo", attempt: 3 },
|
||||
);
|
||||
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
releaseId: "packageReleases:demo",
|
||||
vtAnalysis: expect.objectContaining({
|
||||
status: "clean",
|
||||
source: "engines-undetected-fallback",
|
||||
verdict: "undetected-only-fallback",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(scheduler.runAfter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not promote undetected-only community packages without trusted verification", async () => {
|
||||
process.env.VT_API_KEY = "test-key";
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
attributes: {
|
||||
last_analysis_stats: {
|
||||
malicious: 0,
|
||||
suspicious: 0,
|
||||
harmless: 0,
|
||||
undetected: 66,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const runMutation = vi.fn(async () => null);
|
||||
const scheduler = { runAfter: vi.fn(async () => null) };
|
||||
await pollPackageReleaseScanResultsHandler(
|
||||
{
|
||||
runQuery: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packageReleases:demo",
|
||||
packageId: "packages:demo",
|
||||
version: "1.0.0",
|
||||
sha256hash: "abc123",
|
||||
verification: { tier: "artifact-only" },
|
||||
llmAnalysis: { status: "clean" },
|
||||
staticScan: { status: "clean" },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packages:demo",
|
||||
family: "code-plugin",
|
||||
isOfficial: false,
|
||||
}),
|
||||
runMutation,
|
||||
scheduler,
|
||||
} as never,
|
||||
{ releaseId: "packageReleases:demo", attempt: 3 },
|
||||
);
|
||||
|
||||
expect(runMutation).not.toHaveBeenCalled();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(scheduler.runAfter).toHaveBeenCalledWith(
|
||||
5 * 60 * 1000,
|
||||
expect.anything(),
|
||||
{ releaseId: "packageReleases:demo", attempt: 4 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+291
-2
@@ -1,9 +1,46 @@
|
||||
import { v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { ActionCtx } from "./_generated/server";
|
||||
import { action, internalAction, internalMutation } from "./functions";
|
||||
import { buildDeterministicZip } from "./lib/skillZip";
|
||||
import { buildDeterministicPackageZip, buildDeterministicZip } from "./lib/skillZip";
|
||||
|
||||
const internalRefs = internal as unknown as {
|
||||
packages: {
|
||||
getReleaseByIdInternal: unknown;
|
||||
getPackageByIdInternal: unknown;
|
||||
updateReleaseScanResultsInternal: unknown;
|
||||
};
|
||||
vt: {
|
||||
scanPackageReleaseWithVirusTotal: unknown;
|
||||
pollPackageReleaseScanResults: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
async function runQueryRef<T>(
|
||||
ctx: { runQuery: (ref: never, args: never) => Promise<unknown> },
|
||||
ref: unknown,
|
||||
args: unknown,
|
||||
): Promise<T> {
|
||||
return (await ctx.runQuery(ref as never, args as never)) as T;
|
||||
}
|
||||
|
||||
async function runMutationRef<T>(
|
||||
ctx: { runMutation: (ref: never, args: never) => Promise<unknown> },
|
||||
ref: unknown,
|
||||
args: unknown,
|
||||
): Promise<T> {
|
||||
return (await ctx.runMutation(ref as never, args as never)) as T;
|
||||
}
|
||||
|
||||
async function runAfterRef(
|
||||
ctx: { scheduler: { runAfter: (delayMs: number, ref: never, args: never) => Promise<unknown> } },
|
||||
delayMs: number,
|
||||
ref: unknown,
|
||||
args: unknown,
|
||||
) {
|
||||
return await ctx.scheduler.runAfter(delayMs, ref as never, args as never);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix skills that have version.vtAnalysis but null skill.moderationReason.
|
||||
@@ -123,6 +160,70 @@ type VTFileResponse = {
|
||||
};
|
||||
|
||||
type VTAnalysisStats = NonNullable<VTFileResponse["data"]["attributes"]["last_analysis_stats"]>;
|
||||
type PackageReleaseScanDoc = Pick<
|
||||
Doc<"packageReleases">,
|
||||
"verification" | "llmAnalysis" | "staticScan"
|
||||
>;
|
||||
type PackageScanDoc = Pick<Doc<"packages">, "family" | "isOfficial">;
|
||||
|
||||
function buildPackageUndetectedFallbackAnalysis(
|
||||
release: PackageReleaseScanDoc,
|
||||
pkg: PackageScanDoc,
|
||||
stats?: VTAnalysisStats,
|
||||
) {
|
||||
if (!stats) return null;
|
||||
if (pkg.family === "skill") return null;
|
||||
|
||||
const tier = release.verification?.tier;
|
||||
if (tier !== "source-linked" && tier !== "provenance-verified" && tier !== "rebuild-verified") {
|
||||
return null;
|
||||
}
|
||||
if (release.llmAnalysis?.status !== "clean") return null;
|
||||
if (!release.staticScan || release.staticScan.status === "malicious") return null;
|
||||
if (stats.malicious !== 0 || stats.suspicious !== 0) return null;
|
||||
if ((stats.harmless ?? 0) <= 0 && (stats.undetected ?? 0) <= 0) return null;
|
||||
|
||||
return {
|
||||
status: "clean",
|
||||
verdict: "undetected-only-fallback",
|
||||
analysis:
|
||||
"VirusTotal reported no malicious or suspicious engine hits. ClawHub promoted this source-linked package after clean LLM and non-malicious static scans.",
|
||||
source: "engines-undetected-fallback",
|
||||
checkedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildPackageScanAnalysisFromVtResult(
|
||||
release: PackageReleaseScanDoc,
|
||||
pkg: PackageScanDoc,
|
||||
vtResult: VTFileResponse,
|
||||
) {
|
||||
const aiResult = vtResult.data.attributes.crowdsourced_ai_results?.find(
|
||||
(r) => r.category === "code_insight",
|
||||
);
|
||||
if (aiResult) {
|
||||
const verdict = normalizeVerdict(aiResult.verdict);
|
||||
return {
|
||||
status: verdictToStatus(verdict),
|
||||
verdict: aiResult.verdict,
|
||||
analysis: aiResult.analysis,
|
||||
source: aiResult.source,
|
||||
checkedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
const stats = vtResult.data.attributes.last_analysis_stats;
|
||||
const status = statusFromAvStats(stats);
|
||||
if (status) {
|
||||
return {
|
||||
status,
|
||||
source: "engines",
|
||||
checkedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
return buildPackageUndetectedFallbackAnalysis(release, pkg, stats);
|
||||
}
|
||||
|
||||
type ScanQueueHealth = {
|
||||
queueSize: number;
|
||||
@@ -484,6 +585,194 @@ export const scanWithVirusTotal = internalAction({
|
||||
},
|
||||
});
|
||||
|
||||
const PACKAGE_SCAN_RETRY_DELAY_MS = 5 * 60 * 1000;
|
||||
const PACKAGE_SCAN_MAX_ATTEMPTS = 10;
|
||||
|
||||
export const scanPackageReleaseWithVirusTotal = internalAction({
|
||||
args: {
|
||||
releaseId: v.id("packageReleases"),
|
||||
attempt: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const apiKey = process.env.VT_API_KEY;
|
||||
if (!apiKey) {
|
||||
console.log("[vt:package] VT_API_KEY not configured, skipping package release scan");
|
||||
return;
|
||||
}
|
||||
|
||||
const release = (await runQueryRef(ctx, internalRefs.packages.getReleaseByIdInternal, {
|
||||
releaseId: args.releaseId,
|
||||
})) as Doc<"packageReleases"> | null;
|
||||
if (!release || release.softDeletedAt) {
|
||||
console.error(`[vt:package] Release ${args.releaseId} not found for scanning`);
|
||||
return;
|
||||
}
|
||||
|
||||
const pkg = (await runQueryRef(ctx, internalRefs.packages.getPackageByIdInternal, {
|
||||
packageId: release.packageId,
|
||||
})) as Doc<"packages"> | null;
|
||||
if (!pkg) {
|
||||
console.error(`[vt:package] Package ${release.packageId} not found for scanning`);
|
||||
return;
|
||||
}
|
||||
|
||||
const attempt = args.attempt ?? 1;
|
||||
const entries: Array<{ path: string; bytes: Uint8Array }> = [];
|
||||
let missingFiles = 0;
|
||||
for (const file of release.files) {
|
||||
const content = await ctx.storage.get(file.storageId);
|
||||
if (!content) {
|
||||
missingFiles += 1;
|
||||
continue;
|
||||
}
|
||||
entries.push({
|
||||
path: file.path,
|
||||
bytes: new Uint8Array(await content.arrayBuffer()),
|
||||
});
|
||||
}
|
||||
if (entries.length === 0 || missingFiles > 0) {
|
||||
console.warn(
|
||||
`[vt:package] Release ${args.releaseId} missing ${missingFiles}/${release.files.length} files, retrying`,
|
||||
);
|
||||
if (attempt < PACKAGE_SCAN_MAX_ATTEMPTS) {
|
||||
await runAfterRef(ctx, PACKAGE_SCAN_RETRY_DELAY_MS, internalRefs.vt.scanPackageReleaseWithVirusTotal, {
|
||||
releaseId: args.releaseId,
|
||||
attempt: attempt + 1,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const zipArray = buildDeterministicPackageZip(entries);
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", zipArray);
|
||||
const sha256hash = Array.from(new Uint8Array(hashBuffer))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
|
||||
await runMutationRef(ctx, internalRefs.packages.updateReleaseScanResultsInternal, {
|
||||
releaseId: args.releaseId,
|
||||
sha256hash,
|
||||
});
|
||||
|
||||
try {
|
||||
const existingFile = await checkExistingFile(apiKey, sha256hash);
|
||||
const vtAnalysis = existingFile
|
||||
? buildPackageScanAnalysisFromVtResult(release, pkg, existingFile)
|
||||
: null;
|
||||
|
||||
if (vtAnalysis) {
|
||||
await runMutationRef(ctx, internalRefs.packages.updateReleaseScanResultsInternal, {
|
||||
releaseId: args.releaseId,
|
||||
vtAnalysis,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[vt:package] Error checking existing file in VT:", error);
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
const blob = new Blob([zipArray], { type: "application/zip" });
|
||||
formData.append("file", blob, "package.zip");
|
||||
|
||||
try {
|
||||
const response = await fetch("https://www.virustotal.com/api/v3/files", {
|
||||
method: "POST",
|
||||
headers: { "x-apikey": apiKey },
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
console.error("[vt:package] VirusTotal upload error:", error);
|
||||
if (attempt < PACKAGE_SCAN_MAX_ATTEMPTS) {
|
||||
await runAfterRef(ctx, PACKAGE_SCAN_RETRY_DELAY_MS, internalRefs.vt.scanPackageReleaseWithVirusTotal, {
|
||||
releaseId: args.releaseId,
|
||||
attempt: attempt + 1,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await runAfterRef(ctx, PACKAGE_SCAN_RETRY_DELAY_MS, internalRefs.vt.pollPackageReleaseScanResults, {
|
||||
releaseId: args.releaseId,
|
||||
attempt: 1,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[vt:package] Uploaded ${pkg.name}@${release.version} for scanning (${sha256hash})`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[vt:package] Failed to upload to VirusTotal:", error);
|
||||
if (attempt < PACKAGE_SCAN_MAX_ATTEMPTS) {
|
||||
await runAfterRef(ctx, PACKAGE_SCAN_RETRY_DELAY_MS, internalRefs.vt.scanPackageReleaseWithVirusTotal, {
|
||||
releaseId: args.releaseId,
|
||||
attempt: attempt + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const pollPackageReleaseScanResults = internalAction({
|
||||
args: {
|
||||
releaseId: v.id("packageReleases"),
|
||||
attempt: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const apiKey = process.env.VT_API_KEY;
|
||||
if (!apiKey) return;
|
||||
|
||||
const release = (await runQueryRef(ctx, internalRefs.packages.getReleaseByIdInternal, {
|
||||
releaseId: args.releaseId,
|
||||
})) as Doc<"packageReleases"> | null;
|
||||
if (!release || release.softDeletedAt || !release.sha256hash) return;
|
||||
const pkg = (await runQueryRef(ctx, internalRefs.packages.getPackageByIdInternal, {
|
||||
packageId: release.packageId,
|
||||
})) as Doc<"packages"> | null;
|
||||
if (!pkg || pkg.softDeletedAt) return;
|
||||
|
||||
const attempt = args.attempt ?? 1;
|
||||
try {
|
||||
const vtResult = await checkExistingFile(apiKey, release.sha256hash);
|
||||
if (!vtResult) {
|
||||
if (attempt < PACKAGE_SCAN_MAX_ATTEMPTS) {
|
||||
await runAfterRef(ctx, PACKAGE_SCAN_RETRY_DELAY_MS, internalRefs.vt.pollPackageReleaseScanResults, {
|
||||
releaseId: args.releaseId,
|
||||
attempt: attempt + 1,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const vtAnalysis = buildPackageScanAnalysisFromVtResult(release, pkg, vtResult);
|
||||
if (vtAnalysis) {
|
||||
await runMutationRef(ctx, internalRefs.packages.updateReleaseScanResultsInternal, {
|
||||
releaseId: args.releaseId,
|
||||
vtAnalysis,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await requestRescan(apiKey, release.sha256hash);
|
||||
if (attempt < PACKAGE_SCAN_MAX_ATTEMPTS) {
|
||||
await runAfterRef(ctx, PACKAGE_SCAN_RETRY_DELAY_MS, internalRefs.vt.pollPackageReleaseScanResults, {
|
||||
releaseId: args.releaseId,
|
||||
attempt: attempt + 1,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[vt:package] Error polling ${release.sha256hash}:`, error);
|
||||
if (attempt < PACKAGE_SCAN_MAX_ATTEMPTS) {
|
||||
await runAfterRef(ctx, PACKAGE_SCAN_RETRY_DELAY_MS, internalRefs.vt.pollPackageReleaseScanResults, {
|
||||
releaseId: args.releaseId,
|
||||
attempt: attempt + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Poll for pending scans and update skill moderation status
|
||||
* Called by cron job to check VT results for skills awaiting scan
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
summary: "Marketplace policy: what ClawHub will not allow."
|
||||
read_when:
|
||||
- Reviewing uploads for abuse or policy violations
|
||||
- Writing moderation docs or reviewer runbooks
|
||||
- Deciding whether a skill should be hidden or a user banned
|
||||
---
|
||||
|
||||
# Acceptable Usage
|
||||
|
||||
This page describes the kinds of skills and content ClawHub is not okay with.
|
||||
|
||||
These rules are intentionally practical. We care most about end-to-end abuse workflows, not just isolated keywords. If a skill is built to evade defenses, abuse platforms, scam people, invade privacy, or enable non-consensual behavior, it does not belong on ClawHub.
|
||||
|
||||
## Not okay
|
||||
|
||||
- Security-bypass or unauthorized-access workflows.
|
||||
- Examples: auth bypass, account takeover, CAPTCHA bypass, Cloudflare or anti-bot evasion, rate-limit bypass, stealth scraping designed to defeat protections, live call or agent takeover, reusable session theft, auto-approving pairing flows for unapproved users.
|
||||
|
||||
- Platform abuse and ban evasion.
|
||||
- Examples: stealth accounts after bans, account warming/farming, fake engagement, karma or follower cultivation, multi-account automation, mass posting, spam bots, marketplace or social automation built to avoid detection.
|
||||
|
||||
- Fraud, scams, and deceptive financial workflows.
|
||||
- Examples: fake certificates, fake invoices, deceptive payment flows, scam outreach, fake social proof, tools that enable spending or charging without clear human approval and transparent controls, or synthetic-identity workflows built to create accounts for fraud.
|
||||
|
||||
- Privacy-invasive scraping, enrichment, or surveillance.
|
||||
- Examples: scraping contact details at scale for spam, doxxing, stalking, lead extraction paired with unsolicited outreach, covert monitoring, face search or biometric matching used without clear consent, or buying, publishing, downloading, or operationalizing leaked data or breach dumps.
|
||||
|
||||
- Non-consensual impersonation or deceptive identity manipulation.
|
||||
- Examples: face swap, digital twins, fake personas, cloned influencers, or other identity-manipulation tooling used to impersonate or mislead.
|
||||
|
||||
- Explicit sexual content and safety-disabled adult generation.
|
||||
- Examples: NSFW image/video/content generation, adult-content wrappers around third-party APIs, or skills whose primary purpose is explicit sexual content.
|
||||
|
||||
- Hidden, unsafe, or misleading execution requirements.
|
||||
- Examples: obfuscated install commands, `curl | sh`, undeclared secret requirements, undeclared private-key use, remote `npx @latest` execution without clear reviewability, misleading metadata that hides what the skill really needs to run.
|
||||
|
||||
## Recent patterns we are explicitly not okay with
|
||||
|
||||
- “Create stealth seller accounts after marketplace bans.”
|
||||
- “Modify Telegram pairing so unapproved users automatically receive pairing codes.”
|
||||
- “Cultivate Reddit/Twitter accounts with undetectable automation.”
|
||||
- “Generate professional certificates or invoices for arbitrary use.”
|
||||
- “Generate NSFW content with safety checks disabled.”
|
||||
- “Scrape leads, enrich contacts, and launch cold outreach at scale.”
|
||||
- “Buy, publish, or download leaked data or breach dumps.”
|
||||
- “Bulk-create email or social accounts with synthetic identities or CAPTCHA solving.”
|
||||
|
||||
## Notes for reviewers
|
||||
|
||||
- Context matters. The same topic can be legitimate in a narrow defensive or consent-based setting and unacceptable when packaged as an abuse workflow.
|
||||
- We should bias toward action when a skill is clearly optimized for evasion, deception, or non-consensual use.
|
||||
- Repeated uploads in these categories are grounds for hiding content and banning the account.
|
||||
|
||||
## Enforcement
|
||||
|
||||
- We may hide, remove, or hard-delete violating skills.
|
||||
- We may revoke tokens, soft-delete associated content, and ban repeat or severe offenders.
|
||||
- We do not guarantee warning-first enforcement for obvious abuse.
|
||||
+2
-2
@@ -24,8 +24,8 @@ Auth-aware enforcement:
|
||||
- Authenticated requests (valid Bearer token): per user bucket.
|
||||
- Missing/invalid token falls back to IP enforcement.
|
||||
|
||||
- Read: 120/min per IP, 600/min per key
|
||||
- Write: 30/min per IP, 120/min per key
|
||||
- Read: 180/min per IP, 900/min per key
|
||||
- Write: 45/min per IP, 180/min per key
|
||||
|
||||
Headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`, `Retry-After` (on 429).
|
||||
|
||||
|
||||
@@ -208,6 +208,12 @@ Stores your API token + cached registry URL.
|
||||
- `--fuzzy` resolves the handle via fuzzy user search (admin only).
|
||||
- `--yes` skips confirmation.
|
||||
|
||||
### `package publish <path>`
|
||||
|
||||
- Publishes a code plugin or bundle plugin via `POST /api/v1/packages`.
|
||||
- `--owner <handle>` lets admins publish under a shared owner account while keeping their own token as the actor.
|
||||
- Code plugins still require `--source-repo` and `--source-commit`.
|
||||
|
||||
### `sync`
|
||||
|
||||
- Scans for local skill folders and publishes new/changed ones.
|
||||
|
||||
+8
-6
@@ -31,11 +31,15 @@ gh workflow run deploy.yml
|
||||
GitHub Actions secrets required for `deploy.yml`:
|
||||
|
||||
- `CONVEX_DEPLOY_KEY`
|
||||
- `VERCEL_TOKEN`
|
||||
- Optional: `PLAYWRIGHT_AUTH_STORAGE_STATE_JSON` for authenticated smoke coverage
|
||||
|
||||
`deploy.yml` now fails in preflight if either required secret is missing. It no
|
||||
longer reports a successful workflow while skipping the actual deploy jobs.
|
||||
`deploy.yml` now fails in preflight if `CONVEX_DEPLOY_KEY` is missing. Web deploy
|
||||
verification no longer depends on a separate Vercel token in GitHub Actions.
|
||||
|
||||
That workflow assumes Vercel Git integration is enabled for this repo. It does
|
||||
not run `vercel deploy` directly; instead it waits for the GitHub commit status
|
||||
`Vercel – clawhub` for the pushed SHA, then runs smoke tests against
|
||||
production.
|
||||
|
||||
Ensure Convex env is set (auth + embeddings):
|
||||
|
||||
@@ -63,11 +67,9 @@ Deploy order:
|
||||
|
||||
1. Convex
|
||||
2. contract verify
|
||||
3. web
|
||||
3. wait for Vercel production deploy for the same Git SHA
|
||||
4. smoke
|
||||
|
||||
Do not let Vercel auto-promote a newer web build before Convex is deployed.
|
||||
|
||||
## 3) Route `/api/*` to Convex
|
||||
|
||||
This repo currently uses `vercel.json` rewrites:
|
||||
|
||||
+25
-12
@@ -21,9 +21,9 @@ Enforcement model:
|
||||
- Authenticated requests (valid Bearer token): enforced per user bucket.
|
||||
- If token is missing/invalid, behavior falls back to IP enforcement.
|
||||
|
||||
- Read: 120/min per IP, 600/min per key
|
||||
- Write: 30/min per IP, 120/min per key
|
||||
- Download: 20/min per IP, 120/min per key (`/api/v1/download`)
|
||||
- Read: 180/min per IP, 900/min per key
|
||||
- Write: 45/min per IP, 180/min per key
|
||||
- Download: 30/min per IP, 180/min per key (`/api/v1/download`)
|
||||
|
||||
Headers:
|
||||
|
||||
@@ -280,8 +280,8 @@ Notes:
|
||||
- Skill entries stay backed by the skill registry and can still be published only through `POST /api/v1/skills`.
|
||||
- `POST /api/v1/packages` is still only for code-plugin and bundle-plugin releases.
|
||||
- Anonymous callers only see public package channels.
|
||||
- Authenticated callers can see their own private packages in list/search results.
|
||||
- `channel=private` only returns packages owned by the authenticated caller.
|
||||
- Authenticated callers can see private packages for publishers they belong to in list/search results.
|
||||
- `channel=private` only returns packages the authenticated caller can read.
|
||||
|
||||
### `GET /api/v1/packages/search`
|
||||
|
||||
@@ -300,8 +300,8 @@ Query params:
|
||||
Notes:
|
||||
|
||||
- Anonymous callers only see public package channels.
|
||||
- Authenticated callers can search their own private packages.
|
||||
- `channel=private` only returns packages owned by the authenticated caller.
|
||||
- Authenticated callers can search private packages for publishers they belong to.
|
||||
- `channel=private` only returns packages the authenticated caller can read.
|
||||
|
||||
### `GET /api/v1/packages/{name}`
|
||||
|
||||
@@ -310,7 +310,7 @@ Returns package detail metadata.
|
||||
Notes:
|
||||
|
||||
- Skills can also resolve through this route in the unified catalog.
|
||||
- Private packages return `404` unless the caller is the owner.
|
||||
- Private packages return `404` unless the caller can read the owning publisher.
|
||||
|
||||
### `GET /api/v1/packages/{name}/versions`
|
||||
|
||||
@@ -323,15 +323,16 @@ Query params:
|
||||
|
||||
Notes:
|
||||
|
||||
- Private packages return `404` unless the caller is the owner.
|
||||
- Private packages return `404` unless the caller can read the owning publisher.
|
||||
|
||||
### `GET /api/v1/packages/{name}/versions/{version}`
|
||||
|
||||
Returns one package version, including file metadata, compatibility, capabilities, and verification.
|
||||
Returns one package version, including file metadata, compatibility, capabilities, verification, and scan data.
|
||||
|
||||
Notes:
|
||||
|
||||
- Private packages return `404` unless the caller is the owner.
|
||||
- `version.sha256hash`, `version.vtAnalysis`, `version.llmAnalysis`, and `version.staticScan` are included when scan data exists.
|
||||
- Private packages return `404` unless the caller can read the owning publisher.
|
||||
|
||||
### `GET /api/v1/packages/{name}/file`
|
||||
|
||||
@@ -349,7 +350,8 @@ Notes:
|
||||
- Uses the read rate bucket, not the download bucket.
|
||||
- Binary files return `415`.
|
||||
- File size limit: 200KB.
|
||||
- Private packages return `404` unless the caller is the owner.
|
||||
- Pending VirusTotal scans do not block reads; malicious releases may still be withheld elsewhere.
|
||||
- Private packages return `404` unless the caller can read the owning publisher.
|
||||
|
||||
### `GET /api/v1/packages/{name}/download`
|
||||
|
||||
@@ -366,6 +368,7 @@ Notes:
|
||||
- Skills redirect to `GET /api/v1/download`.
|
||||
- Plugin/package archives are zip files with a `package/` root so they install directly in OpenClaw without repacking.
|
||||
- Registry-only metadata is not injected into the downloaded archive.
|
||||
- Pending VirusTotal scans do not block downloads; malicious releases return `403`.
|
||||
- Private packages return `404` unless the caller is the owner.
|
||||
|
||||
### `GET /api/v1/resolve`
|
||||
@@ -425,6 +428,7 @@ Publishes a code-plugin or bundle-plugin release.
|
||||
- Requires Bearer token auth.
|
||||
- Preferred: `multipart/form-data` with `payload` JSON + `files[]` blobs.
|
||||
- JSON body with `files` (storageId-based) is also accepted.
|
||||
- Optional payload field: `ownerHandle`. When present, only admins may publish on behalf of that owner.
|
||||
|
||||
Validation highlights:
|
||||
|
||||
@@ -432,6 +436,7 @@ Validation highlights:
|
||||
- Code plugins require `package.json`, `openclaw.plugin.json`, source repo metadata, source commit metadata, and config schema metadata.
|
||||
- Bundle plugins require at least one host target.
|
||||
- Only trusted publishers may publish to the `official` channel.
|
||||
- On-behalf publishes still validate official-channel eligibility against the target owner account.
|
||||
|
||||
### `DELETE /api/v1/skills/{slug}` / `POST /api/v1/skills/{slug}/undelete`
|
||||
|
||||
@@ -445,6 +450,14 @@ Status codes:
|
||||
- `404`: skill/user not found
|
||||
- `500`: internal server error
|
||||
|
||||
### `POST /api/v1/users/publisher`
|
||||
|
||||
Admin-only. Ensures an org publisher exists for a handle. If the handle still points at a
|
||||
legacy shared user/personal publisher, the endpoint migrates it into an org publisher first.
|
||||
|
||||
- Body: `{ "handle": "openclaw", "displayName": "OpenClaw", "trusted": true }`
|
||||
- Response: `{ "ok": true, "publisherId": "...", "handle": "openclaw", "created": true, "migrated": false, "trusted": true }`
|
||||
|
||||
### Owner slug management endpoints
|
||||
|
||||
- `POST /api/v1/skills/{slug}/rename`
|
||||
|
||||
+488
@@ -0,0 +1,488 @@
|
||||
---
|
||||
summary: "Plan for orgs, publisher membership, and scoped @owner/name identities."
|
||||
read_when:
|
||||
- Implementing orgs or publisher membership
|
||||
- Changing skill or package ownership semantics
|
||||
- Migrating routes or APIs to scoped identities
|
||||
---
|
||||
|
||||
# Orgs And Scoped Names
|
||||
|
||||
## Goal
|
||||
|
||||
Add real multi-member orgs and make `@owner/name` the canonical identity for
|
||||
published content.
|
||||
|
||||
This is not just a collaboration feature. It is an ownership and namespace
|
||||
migration.
|
||||
|
||||
## Product Decisions
|
||||
|
||||
### Canonical identity
|
||||
|
||||
- Canonical registry identity: `@owner/name`
|
||||
- `owner` is a publisher handle
|
||||
- `name` is a local name inside that publisher namespace
|
||||
- Users and orgs both publish through the same publisher abstraction
|
||||
- New content is always scoped
|
||||
- Legacy unscoped names remain compatibility aliases only when resolution is
|
||||
unambiguous
|
||||
|
||||
### Publisher model
|
||||
|
||||
- A publisher is either a personal publisher or an org publisher
|
||||
- Every user gets a personal publisher
|
||||
- Org publishers can have multiple members
|
||||
- Content is owned by a publisher, not directly by a user
|
||||
- Audit actor stays user-level
|
||||
|
||||
### Scope of change
|
||||
|
||||
- Skills: yes
|
||||
- Packages: yes
|
||||
- Souls: probably yes, for consistency, even if lower priority in UI
|
||||
|
||||
Avoid split models like "skills stay user-owned, packages become org-owned".
|
||||
That creates permanent complexity in auth, routes, and migrations.
|
||||
|
||||
## Why This Requires A Real Migration
|
||||
|
||||
Today the system is globally named and single-owner.
|
||||
|
||||
- Skills store `ownerUserId` and are looked up by global `slug`
|
||||
- Packages store `ownerUserId` and are looked up by global `normalizedName`
|
||||
- Skill/package digests denormalize owner handle from a user row
|
||||
- Permissions use direct `ownerUserId === userId` checks
|
||||
- Transfer flows are user-to-user only
|
||||
|
||||
With `@owner/name`, owner becomes part of identity, not just presentation.
|
||||
|
||||
## Target Model
|
||||
|
||||
### Publishers
|
||||
|
||||
Add `publishers`.
|
||||
|
||||
Suggested fields:
|
||||
|
||||
- `kind`: `user | org`
|
||||
- `handle`
|
||||
- `displayName`
|
||||
- `bio`
|
||||
- `image`
|
||||
- `linkedUserId?`
|
||||
- set for personal publishers
|
||||
- unset for org publishers
|
||||
- `trustedPublisher`
|
||||
- `deactivatedAt?`
|
||||
- `deletedAt?`
|
||||
- `createdAt`
|
||||
- `updatedAt`
|
||||
|
||||
Indexes:
|
||||
|
||||
- `by_handle`
|
||||
- `by_linked_user`
|
||||
- `by_kind_handle`
|
||||
|
||||
### Publisher members
|
||||
|
||||
Add `publisherMembers`.
|
||||
|
||||
Suggested fields:
|
||||
|
||||
- `publisherId`
|
||||
- `userId`
|
||||
- `role`: `owner | admin | publisher`
|
||||
- `createdAt`
|
||||
- `updatedAt`
|
||||
|
||||
Indexes:
|
||||
|
||||
- `by_publisher`
|
||||
- `by_user`
|
||||
- `by_publisher_user`
|
||||
|
||||
### Optional: publisher invites
|
||||
|
||||
Add later if needed:
|
||||
|
||||
- `publisherInvites`
|
||||
- email or GitHub-login based invite target
|
||||
- inviter user id
|
||||
- target publisher id
|
||||
- role
|
||||
- token / expiry / status
|
||||
|
||||
Keep this out of the first migration if it slows down ownership work.
|
||||
|
||||
## Ownership Changes
|
||||
|
||||
### Replace direct user ownership
|
||||
|
||||
Content tables should move to `ownerPublisherId`.
|
||||
|
||||
Affected tables:
|
||||
|
||||
- `skills`
|
||||
- `souls`
|
||||
- `packages`
|
||||
- search digest tables
|
||||
- slug/name alias tables
|
||||
- transfer tables
|
||||
- backup metadata payloads if they persist owner identity
|
||||
|
||||
Keep actor fields user-level:
|
||||
|
||||
- `createdBy`
|
||||
- `updatedBy`
|
||||
- audit log actor
|
||||
|
||||
### Transition strategy
|
||||
|
||||
Do not hard-cut immediately.
|
||||
|
||||
Use dual fields during rollout:
|
||||
|
||||
- add `ownerPublisherId`
|
||||
- keep `ownerUserId` temporarily
|
||||
- dual write
|
||||
- migrate read paths
|
||||
- backfill digests
|
||||
- remove `ownerUserId` from hot paths later
|
||||
|
||||
## Naming Rules
|
||||
|
||||
### New uniqueness rules
|
||||
|
||||
- Skill uniqueness: `(ownerPublisherId, slug)`
|
||||
- Package uniqueness: `(ownerPublisherId, normalizedName)`
|
||||
- Soul uniqueness: `(ownerPublisherId, slug)`
|
||||
|
||||
### Legacy compatibility
|
||||
|
||||
Existing global names become legacy aliases.
|
||||
|
||||
Rules:
|
||||
|
||||
- old `/api/v1/skills/{slug}` can continue only if exactly one live scoped skill
|
||||
matches that slug
|
||||
- if multiple scoped skills share the same local name, old unscoped lookup must
|
||||
stop pretending there is one canonical answer
|
||||
- web redirects from legacy URLs should only happen when target resolution is
|
||||
unambiguous
|
||||
|
||||
### Reserved handles
|
||||
|
||||
Handle reservation must move from user-centric to publisher-centric.
|
||||
|
||||
Current reservation logic is anchored to rightful owner user id. Replace with
|
||||
publisher-aware reservations so org handles are first-class.
|
||||
|
||||
## Routing
|
||||
|
||||
### Web routes
|
||||
|
||||
Keep human-readable web routes:
|
||||
|
||||
- `/$owner/$name`
|
||||
|
||||
Examples:
|
||||
|
||||
- `/openai/chatgpt`
|
||||
- `/steipete/peekaboo`
|
||||
|
||||
This matches the canonical `@owner/name` identity without exposing `@` in page
|
||||
paths.
|
||||
|
||||
### CLI and API locators
|
||||
|
||||
CLI and machine-facing APIs should accept:
|
||||
|
||||
- `@owner/name`
|
||||
|
||||
Examples:
|
||||
|
||||
- `clawhub inspect @openai/chatgpt`
|
||||
- `clawhub install @steipete/peekaboo`
|
||||
|
||||
### Owner lookup
|
||||
|
||||
Owner is no longer decorative.
|
||||
|
||||
Current route behavior often resolves by slug and then redirects owner to the
|
||||
canonical handle. After migration:
|
||||
|
||||
- route lookup must resolve by owner + local name
|
||||
- wrong owner should 404 or redirect only through explicit alias records
|
||||
- owner is part of primary key semantics
|
||||
|
||||
## Publisher Permissions
|
||||
|
||||
Replace direct ownership checks with publisher membership checks.
|
||||
|
||||
Suggested helpers:
|
||||
|
||||
- `requirePublisherMember(publisherId)`
|
||||
- `requirePublisherRole(publisherId, ["owner", "admin"])`
|
||||
- `canPublishAsPublisher(userId, publisherId)`
|
||||
- `canManageOwnedResource(userId, ownerPublisherId)`
|
||||
|
||||
Role semantics:
|
||||
|
||||
- `owner`: full control, manage members, transfer ownership, delete publisher
|
||||
- `admin`: manage content and members except destructive publisher-level actions
|
||||
- `publisher`: publish new versions, update metadata, no membership changes
|
||||
|
||||
Moderators/admins keep global override powers as they do today.
|
||||
|
||||
## Publishing Flow Changes
|
||||
|
||||
### Skills
|
||||
|
||||
Skill publishing currently assumes the actor is the owner.
|
||||
|
||||
Target behavior:
|
||||
|
||||
- actor selects publisher in UI/CLI
|
||||
- publish mutation validates publisher membership
|
||||
- resource stores `ownerPublisherId`
|
||||
- version keeps `createdBy`
|
||||
|
||||
### Packages
|
||||
|
||||
Package publish already has a primitive shared-owner path via `ownerHandle`, but
|
||||
it is admin-only.
|
||||
|
||||
Replace that with:
|
||||
|
||||
- `ownerHandle` resolves to publisher handle
|
||||
- allowed for publisher members
|
||||
- no admin impersonation required for normal org publishing
|
||||
|
||||
### Upload UI
|
||||
|
||||
Add owner selector to:
|
||||
|
||||
- upload page
|
||||
- package publish page
|
||||
- dashboard quick actions
|
||||
|
||||
Selector rules:
|
||||
|
||||
- default to personal publisher
|
||||
- list orgs where actor is member
|
||||
- hide publishers where actor cannot publish
|
||||
|
||||
## API Changes
|
||||
|
||||
### Read APIs
|
||||
|
||||
Add scoped read shape.
|
||||
|
||||
Preferred new endpoints:
|
||||
|
||||
- `GET /api/v1/skills/@{owner}/{name}`
|
||||
- `GET /api/v1/skills/@{owner}/{name}/versions`
|
||||
- `GET /api/v1/packages/@{owner}/{name}`
|
||||
- `GET /api/v1/packages/@{owner}/{name}/versions`
|
||||
|
||||
Alternative if path encoding is awkward:
|
||||
|
||||
- `GET /api/v1/skills/{owner}/{name}`
|
||||
- `GET /api/v1/packages/{owner}/{name}`
|
||||
|
||||
Keep one canonical format internally. Do not support multiple equivalent primary
|
||||
keys forever.
|
||||
|
||||
### Search/list APIs
|
||||
|
||||
Search/list responses should return publisher identity explicitly.
|
||||
|
||||
Suggested fields:
|
||||
|
||||
- `owner`
|
||||
- `handle`
|
||||
- `displayName`
|
||||
- `kind`
|
||||
- `image`
|
||||
- `locator`
|
||||
- `scoped`: `@owner/name`
|
||||
- `path`: `/owner/name`
|
||||
|
||||
### Publish APIs
|
||||
|
||||
Publish payloads should take:
|
||||
|
||||
- `ownerHandle`
|
||||
|
||||
Semantics:
|
||||
|
||||
- resolve to publisher
|
||||
- validate membership
|
||||
- reject unknown publishers
|
||||
- reject insufficient role
|
||||
|
||||
## Transfer Model
|
||||
|
||||
Current transfers are user-to-user only. That is too narrow.
|
||||
|
||||
New transfer target should be a publisher.
|
||||
|
||||
Support:
|
||||
|
||||
- user publisher -> org publisher
|
||||
- org publisher -> user publisher
|
||||
- org publisher -> org publisher
|
||||
|
||||
Transfer acceptance rule:
|
||||
|
||||
- actor must have `owner` or `admin` on target publisher
|
||||
|
||||
Audit should record:
|
||||
|
||||
- actor user id
|
||||
- source publisher id
|
||||
- target publisher id
|
||||
- resource id
|
||||
|
||||
## Search Digest Changes
|
||||
|
||||
Digest rows should stop denormalizing only user ownership.
|
||||
|
||||
Add publisher projection fields:
|
||||
|
||||
- `ownerPublisherId`
|
||||
- `ownerHandle`
|
||||
- `ownerDisplayName`
|
||||
- `ownerKind`
|
||||
- `ownerImage`
|
||||
|
||||
Do not join hot-path list views against publisher + content + version unless
|
||||
necessary. Keep digest-first reads.
|
||||
|
||||
## Backfill Plan
|
||||
|
||||
### Phase 0: schema
|
||||
|
||||
- add `publishers`
|
||||
- add `publisherMembers`
|
||||
- add `ownerPublisherId` to content + digests
|
||||
- add publisher-aware indexes
|
||||
|
||||
### Phase 1: bootstrap personal publishers
|
||||
|
||||
- create one personal publisher per existing user
|
||||
- set `linkedUserId`
|
||||
- create `publisherMembers` row with role `owner`
|
||||
|
||||
### Phase 2: content backfill
|
||||
|
||||
- backfill `ownerPublisherId` from `ownerUserId`
|
||||
- backfill digest owner publisher fields
|
||||
- backfill alias tables if needed
|
||||
|
||||
### Phase 3: dual read/write
|
||||
|
||||
- all writes set both old and new ownership fields
|
||||
- reads prefer `ownerPublisherId`
|
||||
- UI uses publisher handles
|
||||
|
||||
### Phase 4: scoped routing and APIs
|
||||
|
||||
- add scoped resolvers
|
||||
- update CLI to parse `@owner/name`
|
||||
- update web routes to rely on owner + name
|
||||
|
||||
### Phase 5: cleanup
|
||||
|
||||
- stop using `ownerUserId` in permission checks
|
||||
- remove legacy fallbacks from hot paths
|
||||
- keep compatibility alias endpoints only where still useful
|
||||
|
||||
## Compatibility Policy
|
||||
|
||||
### New writes
|
||||
|
||||
- new content must use publisher ownership
|
||||
- new locators returned by UI/API/CLI should be scoped
|
||||
|
||||
### Old reads
|
||||
|
||||
Temporary compatibility allowed for:
|
||||
|
||||
- existing user profile links
|
||||
- old unscoped API calls
|
||||
- old CLI invocations without `@owner/`
|
||||
|
||||
Compatibility should have clear limits:
|
||||
|
||||
- only when resolution is unambiguous
|
||||
- return canonical scoped locator in responses
|
||||
- do not allow old format to remain canonical in docs or new UI
|
||||
|
||||
## UI Surfaces
|
||||
|
||||
Need updates in:
|
||||
|
||||
- dashboard
|
||||
- upload
|
||||
- package publish flow
|
||||
- skill/package cards and detail pages
|
||||
- owner profile pages
|
||||
- settings
|
||||
|
||||
New UI surfaces:
|
||||
|
||||
- org profile page
|
||||
- org settings
|
||||
- org members management
|
||||
- create org flow
|
||||
|
||||
## Suggested Delivery Order
|
||||
|
||||
1. Add publisher schema and personal publisher backfill
|
||||
2. Add owner publisher fields and dual-write support
|
||||
3. Switch auth helpers and permission checks
|
||||
4. Switch digests and list/search outputs
|
||||
5. Add owner selector in publish flows
|
||||
6. Add scoped CLI/API parsing
|
||||
7. Add org management UI
|
||||
8. Migrate transfers to publisher targets
|
||||
9. Remove legacy ownership assumptions
|
||||
|
||||
## Testing Plan
|
||||
|
||||
Add or update tests for:
|
||||
|
||||
- personal publisher bootstrap
|
||||
- org creation
|
||||
- membership role enforcement
|
||||
- publish-as-org for skills
|
||||
- publish-as-org for packages
|
||||
- scoped uniqueness
|
||||
- legacy alias resolution
|
||||
- ambiguous unscoped lookup failure
|
||||
- transfer user -> org
|
||||
- transfer org -> user
|
||||
- transfer org -> org
|
||||
- dashboard/upload owner selection
|
||||
- search/list digest hydration with publisher owners
|
||||
|
||||
## Non-Goals For First Pass
|
||||
|
||||
- npm-style team subgroups inside orgs
|
||||
- package-level ACLs separate from org membership
|
||||
- invite workflows with complex approval states
|
||||
- org billing or paid features
|
||||
- multiple namespace syntaxes
|
||||
|
||||
## Open Implementation Notes
|
||||
|
||||
- Canonical page URL should stay readable: `/owner/name`
|
||||
- Canonical machine locator should be `@owner/name`
|
||||
- Keep one internal parser/formatter for locators so CLI, API, and UI do not
|
||||
drift
|
||||
- Do not keep slug-only and scoped lookup logic equally primary; one must win
|
||||
- Prefer publisher abstraction over `ownerUserId | ownerOrgId` unions
|
||||
|
||||
@@ -8,6 +8,8 @@ read_when:
|
||||
|
||||
# Security + Moderation
|
||||
|
||||
See also: [acceptable-usage.md](./acceptable-usage.md) for the marketplace policy on prohibited skill categories.
|
||||
|
||||
## Roles + permissions
|
||||
|
||||
- user: upload skills/souls (subject to GitHub age gate), report skills/comments.
|
||||
@@ -42,6 +44,11 @@ read_when:
|
||||
## Skill moderation pipeline
|
||||
|
||||
- New skill publishes now persist a deterministic static scan result on the version.
|
||||
- Package/plugin scan backfills now also recompute deterministic static scan results for older releases,
|
||||
so legacy plugin versions can surface OpenClaw scan findings without republishing.
|
||||
- Source-linked packages can fall back to a clean package verdict when VirusTotal only returns
|
||||
undetected engine results, provided the LLM scan is clean and static scan is non-malicious. This
|
||||
avoids indefinite pending scans when VT Code Insight never materializes.
|
||||
- Skill moderation state stores a structured snapshot:
|
||||
- `moderationVerdict`: `clean | suspicious | malicious`
|
||||
- `moderationReasonCodes[]`: canonical machine-readable reasons
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expectHealthyPage, trackRuntimeErrors } from "./helpers/runtimeErrors";
|
||||
|
||||
const navLabels = ["Skills", "Upload", "Import", "Search"];
|
||||
const navLabels = ["Skills", "Plugins", "Search"];
|
||||
|
||||
test("skills loads without error", async ({ page }) => {
|
||||
const errors = trackRuntimeErrors(page);
|
||||
@@ -31,18 +31,9 @@ test("header menu routes render", async ({ page }) => {
|
||||
await expect(page.locator("h1", { hasText: "Skills" })).toBeVisible();
|
||||
}
|
||||
|
||||
if (label === "Upload") {
|
||||
await expect(page).toHaveURL(/\/upload/);
|
||||
const heading = page.locator("h1.section-title", { hasText: /^Publish a /i });
|
||||
const signInCard = page.locator("text=Sign in to upload");
|
||||
await expect(heading.or(signInCard)).toBeVisible();
|
||||
}
|
||||
|
||||
if (label === "Import") {
|
||||
await expect(page).toHaveURL(/\/import/);
|
||||
const heading = page.getByRole("heading", { name: "Import from GitHub" });
|
||||
const signInCard = page.locator("text=Sign in to import and publish skills.");
|
||||
await expect(heading.or(signInCard)).toBeVisible();
|
||||
if (label === "Plugins") {
|
||||
await expect(page).toHaveURL(/\/plugins(\?|$)/);
|
||||
await expect(page.locator("h1", { hasText: "Plugins" })).toBeVisible();
|
||||
}
|
||||
|
||||
if (label === "Search") {
|
||||
|
||||
@@ -40,7 +40,7 @@ clawhub publish ./my-skill-pack --slug my-skill-pack --name "My Skill Pack" --ve
|
||||
clawhub package explore --family skill
|
||||
clawhub package explore --family code-plugin
|
||||
clawhub package inspect @openclaw/example-plugin
|
||||
clawhub package publish ./example-plugin --source-repo openclaw/example-plugin --source-commit abc123
|
||||
clawhub package publish ./example-plugin --owner openclaw --source-repo openclaw/example-plugin --source-commit abc123
|
||||
```
|
||||
|
||||
## Sync (upload local skills)
|
||||
|
||||
@@ -377,6 +377,7 @@ packageCmd
|
||||
.option("--family <family>", "code-plugin|bundle-plugin")
|
||||
.option("--name <name>", "Package name")
|
||||
.option("--display-name <name>", "Display name")
|
||||
.option("--owner <handle>", "Publish under this owner handle (admin only)")
|
||||
.option("--version <version>", "Version")
|
||||
.option("--changelog <text>", "Changelog text")
|
||||
.option("--tags <tags>", "Comma-separated tags", "latest")
|
||||
|
||||
@@ -191,6 +191,7 @@ describe("package commands", () => {
|
||||
});
|
||||
|
||||
await cmdPublishPackage(makeOpts(workdir), "demo-plugin", {
|
||||
owner: "@openclaw",
|
||||
sourceRepo: "openclaw/demo-plugin",
|
||||
sourceCommit: "abc123",
|
||||
sourceRef: "refs/tags/v1.0.0",
|
||||
@@ -206,6 +207,7 @@ describe("package commands", () => {
|
||||
if (typeof payloadEntry !== "string") throw new Error("Missing publish payload");
|
||||
const payload = JSON.parse(payloadEntry);
|
||||
expect(payload.name).toBe("@scope/demo-plugin");
|
||||
expect(payload.ownerHandle).toBe("openclaw");
|
||||
expect(payload.family).toBe("code-plugin");
|
||||
expect(payload.version).toBe("1.0.0");
|
||||
expect(payload.source).toMatchObject({
|
||||
|
||||
@@ -50,6 +50,7 @@ type PackagePublishOptions = {
|
||||
family?: "code-plugin" | "bundle-plugin";
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
owner?: string;
|
||||
version?: string;
|
||||
changelog?: string;
|
||||
tags?: string;
|
||||
@@ -303,6 +304,7 @@ export async function cmdPublishPackage(
|
||||
options.displayName?.trim() ||
|
||||
packageJsonString(packageJson, "displayName") ||
|
||||
titleCase(basename(folder));
|
||||
const ownerHandle = options.owner?.trim().replace(/^@+/, "");
|
||||
const version = options.version?.trim() || packageJsonString(packageJson, "version");
|
||||
const changelog = options.changelog ?? "";
|
||||
const tags = parseTags(options.tags ?? "latest");
|
||||
@@ -334,6 +336,7 @@ export async function cmdPublishPackage(
|
||||
JSON.stringify({
|
||||
name,
|
||||
displayName,
|
||||
...(ownerHandle ? { ownerHandle } : {}),
|
||||
family,
|
||||
version,
|
||||
changelog,
|
||||
|
||||
@@ -57,6 +57,57 @@ export const PackageVerificationSummarySchema = type({
|
||||
});
|
||||
export type PackageVerificationSummary = (typeof PackageVerificationSummarySchema)[inferred];
|
||||
|
||||
export const PackageVtAnalysisSchema = type({
|
||||
status: "string",
|
||||
verdict: "string?",
|
||||
analysis: "string?",
|
||||
source: "string?",
|
||||
checkedAt: "number",
|
||||
});
|
||||
export type PackageVtAnalysis = (typeof PackageVtAnalysisSchema)[inferred];
|
||||
|
||||
export const PackageLlmAnalysisDimensionSchema = type({
|
||||
name: "string",
|
||||
label: "string",
|
||||
rating: "string",
|
||||
detail: "string",
|
||||
});
|
||||
export type PackageLlmAnalysisDimension =
|
||||
(typeof PackageLlmAnalysisDimensionSchema)[inferred];
|
||||
|
||||
export const PackageLlmAnalysisSchema = type({
|
||||
status: "string",
|
||||
verdict: "string?",
|
||||
confidence: "string?",
|
||||
summary: "string?",
|
||||
dimensions: PackageLlmAnalysisDimensionSchema.array().optional(),
|
||||
guidance: "string?",
|
||||
findings: "string?",
|
||||
model: "string?",
|
||||
checkedAt: "number",
|
||||
});
|
||||
export type PackageLlmAnalysis = (typeof PackageLlmAnalysisSchema)[inferred];
|
||||
|
||||
export const PackageStaticFindingSchema = type({
|
||||
code: "string",
|
||||
severity: "string",
|
||||
file: "string",
|
||||
line: "number",
|
||||
message: "string",
|
||||
evidence: "string",
|
||||
});
|
||||
export type PackageStaticFinding = (typeof PackageStaticFindingSchema)[inferred];
|
||||
|
||||
export const PackageStaticScanSchema = type({
|
||||
status: "string",
|
||||
reasonCodes: "string[]",
|
||||
findings: PackageStaticFindingSchema.array(),
|
||||
summary: "string",
|
||||
engineVersion: "string",
|
||||
checkedAt: "number",
|
||||
});
|
||||
export type PackageStaticScan = (typeof PackageStaticScanSchema)[inferred];
|
||||
|
||||
export const BundlePublishMetadataSchema = type({
|
||||
id: "string?",
|
||||
format: "string?",
|
||||
@@ -67,6 +118,7 @@ export type BundlePublishMetadata = (typeof BundlePublishMetadataSchema)[inferre
|
||||
export const PackagePublishRequestSchema = type({
|
||||
name: "string",
|
||||
displayName: "string?",
|
||||
ownerHandle: "string?",
|
||||
family: PackageFamilySchema,
|
||||
version: "string",
|
||||
changelog: "string",
|
||||
@@ -158,6 +210,10 @@ export const ApiV1PackageVersionResponseSchema = type({
|
||||
compatibility: PackageCompatibilitySchema.or("null").optional(),
|
||||
capabilities: PackageCapabilitySummarySchema.or("null").optional(),
|
||||
verification: PackageVerificationSummarySchema.or("null").optional(),
|
||||
sha256hash: "string?",
|
||||
vtAnalysis: PackageVtAnalysisSchema.or("null").optional(),
|
||||
llmAnalysis: PackageLlmAnalysisSchema.or("null").optional(),
|
||||
staticScan: PackageStaticScanSchema.or("null").optional(),
|
||||
}).or("null"),
|
||||
});
|
||||
|
||||
|
||||
Vendored
+97
@@ -46,6 +46,63 @@ export declare const PackageVerificationSummarySchema: import("arktype/internal/
|
||||
scanStatus?: "clean" | "suspicious" | "malicious" | "pending" | "not-run" | undefined;
|
||||
}, {}>;
|
||||
export type PackageVerificationSummary = (typeof PackageVerificationSummarySchema)[inferred];
|
||||
export declare const PackageVtAnalysisSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
status: string;
|
||||
checkedAt: number;
|
||||
verdict?: string | undefined;
|
||||
analysis?: string | undefined;
|
||||
source?: string | undefined;
|
||||
}, {}>;
|
||||
export type PackageVtAnalysis = (typeof PackageVtAnalysisSchema)[inferred];
|
||||
export declare const PackageLlmAnalysisDimensionSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
name: string;
|
||||
label: string;
|
||||
rating: string;
|
||||
detail: string;
|
||||
}, {}>;
|
||||
export type PackageLlmAnalysisDimension = (typeof PackageLlmAnalysisDimensionSchema)[inferred];
|
||||
export declare const PackageLlmAnalysisSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
status: string;
|
||||
checkedAt: number;
|
||||
verdict?: string | undefined;
|
||||
confidence?: string | undefined;
|
||||
summary?: string | undefined;
|
||||
dimensions?: {
|
||||
name: string;
|
||||
label: string;
|
||||
rating: string;
|
||||
detail: string;
|
||||
}[] | undefined;
|
||||
guidance?: string | undefined;
|
||||
findings?: string | undefined;
|
||||
model?: string | undefined;
|
||||
}, {}>;
|
||||
export type PackageLlmAnalysis = (typeof PackageLlmAnalysisSchema)[inferred];
|
||||
export declare const PackageStaticFindingSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
code: string;
|
||||
severity: string;
|
||||
file: string;
|
||||
line: number;
|
||||
message: string;
|
||||
evidence: string;
|
||||
}, {}>;
|
||||
export type PackageStaticFinding = (typeof PackageStaticFindingSchema)[inferred];
|
||||
export declare const PackageStaticScanSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
status: string;
|
||||
reasonCodes: string[];
|
||||
findings: {
|
||||
code: string;
|
||||
severity: string;
|
||||
file: string;
|
||||
line: number;
|
||||
message: string;
|
||||
evidence: string;
|
||||
}[];
|
||||
summary: string;
|
||||
engineVersion: string;
|
||||
checkedAt: number;
|
||||
}, {}>;
|
||||
export type PackageStaticScan = (typeof PackageStaticScanSchema)[inferred];
|
||||
export declare const BundlePublishMetadataSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
id?: string | undefined;
|
||||
format?: string | undefined;
|
||||
@@ -65,6 +122,7 @@ export declare const PackagePublishRequestSchema: import("arktype/internal/varia
|
||||
contentType?: string | undefined;
|
||||
}[];
|
||||
displayName?: string | undefined;
|
||||
ownerHandle?: string | undefined;
|
||||
channel?: "official" | "community" | "private" | undefined;
|
||||
tags?: string[] | undefined;
|
||||
source?: {
|
||||
@@ -254,6 +312,45 @@ export declare const ApiV1PackageVersionResponseSchema: import("arktype/internal
|
||||
hasProvenance?: boolean | undefined;
|
||||
scanStatus?: "clean" | "suspicious" | "malicious" | "pending" | "not-run" | undefined;
|
||||
} | null | undefined;
|
||||
sha256hash?: string | undefined;
|
||||
vtAnalysis?: {
|
||||
status: string;
|
||||
checkedAt: number;
|
||||
verdict?: string | undefined;
|
||||
analysis?: string | undefined;
|
||||
source?: string | undefined;
|
||||
} | null | undefined;
|
||||
llmAnalysis?: {
|
||||
status: string;
|
||||
checkedAt: number;
|
||||
verdict?: string | undefined;
|
||||
confidence?: string | undefined;
|
||||
summary?: string | undefined;
|
||||
dimensions?: {
|
||||
name: string;
|
||||
label: string;
|
||||
rating: string;
|
||||
detail: string;
|
||||
}[] | undefined;
|
||||
guidance?: string | undefined;
|
||||
findings?: string | undefined;
|
||||
model?: string | undefined;
|
||||
} | null | undefined;
|
||||
staticScan?: {
|
||||
status: string;
|
||||
reasonCodes: string[];
|
||||
findings: {
|
||||
code: string;
|
||||
severity: string;
|
||||
file: string;
|
||||
line: number;
|
||||
message: string;
|
||||
evidence: string;
|
||||
}[];
|
||||
summary: string;
|
||||
engineVersion: string;
|
||||
checkedAt: number;
|
||||
} | null | undefined;
|
||||
} | null;
|
||||
}, {}>;
|
||||
export declare const ApiV1PackagePublishResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
|
||||
Vendored
+45
@@ -40,6 +40,46 @@ export const PackageVerificationSummarySchema = type({
|
||||
hasProvenance: "boolean?",
|
||||
scanStatus: '"clean"|"suspicious"|"malicious"|"pending"|"not-run"?',
|
||||
});
|
||||
export const PackageVtAnalysisSchema = type({
|
||||
status: "string",
|
||||
verdict: "string?",
|
||||
analysis: "string?",
|
||||
source: "string?",
|
||||
checkedAt: "number",
|
||||
});
|
||||
export const PackageLlmAnalysisDimensionSchema = type({
|
||||
name: "string",
|
||||
label: "string",
|
||||
rating: "string",
|
||||
detail: "string",
|
||||
});
|
||||
export const PackageLlmAnalysisSchema = type({
|
||||
status: "string",
|
||||
verdict: "string?",
|
||||
confidence: "string?",
|
||||
summary: "string?",
|
||||
dimensions: PackageLlmAnalysisDimensionSchema.array().optional(),
|
||||
guidance: "string?",
|
||||
findings: "string?",
|
||||
model: "string?",
|
||||
checkedAt: "number",
|
||||
});
|
||||
export const PackageStaticFindingSchema = type({
|
||||
code: "string",
|
||||
severity: "string",
|
||||
file: "string",
|
||||
line: "number",
|
||||
message: "string",
|
||||
evidence: "string",
|
||||
});
|
||||
export const PackageStaticScanSchema = type({
|
||||
status: "string",
|
||||
reasonCodes: "string[]",
|
||||
findings: PackageStaticFindingSchema.array(),
|
||||
summary: "string",
|
||||
engineVersion: "string",
|
||||
checkedAt: "number",
|
||||
});
|
||||
export const BundlePublishMetadataSchema = type({
|
||||
id: "string?",
|
||||
format: "string?",
|
||||
@@ -48,6 +88,7 @@ export const BundlePublishMetadataSchema = type({
|
||||
export const PackagePublishRequestSchema = type({
|
||||
name: "string",
|
||||
displayName: "string?",
|
||||
ownerHandle: "string?",
|
||||
family: PackageFamilySchema,
|
||||
version: "string",
|
||||
changelog: "string",
|
||||
@@ -131,6 +172,10 @@ export const ApiV1PackageVersionResponseSchema = type({
|
||||
compatibility: PackageCompatibilitySchema.or("null").optional(),
|
||||
capabilities: PackageCapabilitySummarySchema.or("null").optional(),
|
||||
verification: PackageVerificationSummarySchema.or("null").optional(),
|
||||
sha256hash: "string?",
|
||||
vtAnalysis: PackageVtAnalysisSchema.or("null").optional(),
|
||||
llmAnalysis: PackageLlmAnalysisSchema.or("null").optional(),
|
||||
staticScan: PackageStaticScanSchema.or("null").optional(),
|
||||
}).or("null"),
|
||||
});
|
||||
export const ApiV1PackagePublishResponseSchema = type({
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -15,6 +15,7 @@ export declare const ApiRoutes: {
|
||||
readonly resolve: "/api/v1/resolve";
|
||||
readonly download: "/api/v1/download";
|
||||
readonly skills: "/api/v1/skills";
|
||||
readonly plugins: "/api/v1/plugins";
|
||||
readonly packages: "/api/v1/packages";
|
||||
readonly codePlugins: "/api/v1/code-plugins";
|
||||
readonly bundlePlugins: "/api/v1/bundle-plugins";
|
||||
|
||||
Vendored
+1
@@ -15,6 +15,7 @@ export const ApiRoutes = {
|
||||
resolve: "/api/v1/resolve",
|
||||
download: "/api/v1/download",
|
||||
skills: "/api/v1/skills",
|
||||
plugins: "/api/v1/plugins",
|
||||
packages: "/api/v1/packages",
|
||||
codePlugins: "/api/v1/code-plugins",
|
||||
bundlePlugins: "/api/v1/bundle-plugins",
|
||||
|
||||
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,MAAM,EAAE,gBAAgB;IACxB,QAAQ,EAAE,kBAAkB;IAC5B,WAAW,EAAE,sBAAsB;IACnC,aAAa,EAAE,wBAAwB;IACvC,KAAK,EAAE,eAAe;IACtB,SAAS,EAAE,mBAAmB;IAC9B,KAAK,EAAE,eAAe;IACtB,KAAK,EAAE,eAAe;IACtB,MAAM,EAAE,gBAAgB;CAChB,CAAC"}
|
||||
{"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,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,WAAW,EAAE,sBAAsB;IACnC,aAAa,EAAE,wBAAwB;IACvC,KAAK,EAAE,eAAe;IACtB,SAAS,EAAE,mBAAmB;IAC9B,KAAK,EAAE,eAAe;IACtB,KAAK,EAAE,eAAe;IACtB,MAAM,EAAE,gBAAgB;CAChB,CAAC"}
|
||||
@@ -57,6 +57,57 @@ export const PackageVerificationSummarySchema = type({
|
||||
});
|
||||
export type PackageVerificationSummary = (typeof PackageVerificationSummarySchema)[inferred];
|
||||
|
||||
export const PackageVtAnalysisSchema = type({
|
||||
status: "string",
|
||||
verdict: "string?",
|
||||
analysis: "string?",
|
||||
source: "string?",
|
||||
checkedAt: "number",
|
||||
});
|
||||
export type PackageVtAnalysis = (typeof PackageVtAnalysisSchema)[inferred];
|
||||
|
||||
export const PackageLlmAnalysisDimensionSchema = type({
|
||||
name: "string",
|
||||
label: "string",
|
||||
rating: "string",
|
||||
detail: "string",
|
||||
});
|
||||
export type PackageLlmAnalysisDimension =
|
||||
(typeof PackageLlmAnalysisDimensionSchema)[inferred];
|
||||
|
||||
export const PackageLlmAnalysisSchema = type({
|
||||
status: "string",
|
||||
verdict: "string?",
|
||||
confidence: "string?",
|
||||
summary: "string?",
|
||||
dimensions: PackageLlmAnalysisDimensionSchema.array().optional(),
|
||||
guidance: "string?",
|
||||
findings: "string?",
|
||||
model: "string?",
|
||||
checkedAt: "number",
|
||||
});
|
||||
export type PackageLlmAnalysis = (typeof PackageLlmAnalysisSchema)[inferred];
|
||||
|
||||
export const PackageStaticFindingSchema = type({
|
||||
code: "string",
|
||||
severity: "string",
|
||||
file: "string",
|
||||
line: "number",
|
||||
message: "string",
|
||||
evidence: "string",
|
||||
});
|
||||
export type PackageStaticFinding = (typeof PackageStaticFindingSchema)[inferred];
|
||||
|
||||
export const PackageStaticScanSchema = type({
|
||||
status: "string",
|
||||
reasonCodes: "string[]",
|
||||
findings: PackageStaticFindingSchema.array(),
|
||||
summary: "string",
|
||||
engineVersion: "string",
|
||||
checkedAt: "number",
|
||||
});
|
||||
export type PackageStaticScan = (typeof PackageStaticScanSchema)[inferred];
|
||||
|
||||
export const BundlePublishMetadataSchema = type({
|
||||
id: "string?",
|
||||
format: "string?",
|
||||
@@ -67,6 +118,7 @@ export type BundlePublishMetadata = (typeof BundlePublishMetadataSchema)[inferre
|
||||
export const PackagePublishRequestSchema = type({
|
||||
name: "string",
|
||||
displayName: "string?",
|
||||
ownerHandle: "string?",
|
||||
family: PackageFamilySchema,
|
||||
version: "string",
|
||||
changelog: "string",
|
||||
@@ -158,6 +210,10 @@ export const ApiV1PackageVersionResponseSchema = type({
|
||||
compatibility: PackageCompatibilitySchema.or("null").optional(),
|
||||
capabilities: PackageCapabilitySummarySchema.or("null").optional(),
|
||||
verification: PackageVerificationSummarySchema.or("null").optional(),
|
||||
sha256hash: "string?",
|
||||
vtAnalysis: PackageVtAnalysisSchema.or("null").optional(),
|
||||
llmAnalysis: PackageLlmAnalysisSchema.or("null").optional(),
|
||||
staticScan: PackageStaticScanSchema.or("null").optional(),
|
||||
}).or("null"),
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export const ApiRoutes = {
|
||||
resolve: "/api/v1/resolve",
|
||||
download: "/api/v1/download",
|
||||
skills: "/api/v1/skills",
|
||||
plugins: "/api/v1/plugins",
|
||||
packages: "/api/v1/packages",
|
||||
codePlugins: "/api/v1/code-plugins",
|
||||
bundlePlugins: "/api/v1/bundle-plugins",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { vi } from "vitest";
|
||||
import { ImportGitHub } from "../routes/import";
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute: () => (config: { component: unknown }) => config,
|
||||
Link: (props: { children: ReactNode }) => <a href="/">{props.children}</a>,
|
||||
useNavigate: () => vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -2,10 +2,17 @@
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { ComponentType } from "react";
|
||||
import type { PackageDetailResponse, PackageVersionDetail } from "../lib/packageApi";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type PluginDetailLoaderData = {
|
||||
detail: PackageDetailResponse;
|
||||
version: PackageVersionDetail | null;
|
||||
readme: string | null;
|
||||
};
|
||||
|
||||
let paramsMock = { name: "demo-plugin" };
|
||||
let loaderDataMock = {
|
||||
let loaderDataMock: PluginDetailLoaderData = {
|
||||
detail: {
|
||||
package: {
|
||||
name: "demo-plugin",
|
||||
@@ -52,14 +59,14 @@ vi.mock("../lib/packageApi", () => ({
|
||||
}));
|
||||
|
||||
async function loadRoute() {
|
||||
return (await import("../routes/packages/$name")).Route as unknown as {
|
||||
return (await import("../routes/plugins/$name")).Route as unknown as {
|
||||
__config: {
|
||||
component?: ComponentType;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
describe("package detail route", () => {
|
||||
describe("plugin detail route", () => {
|
||||
beforeEach(() => {
|
||||
paramsMock = { name: "demo-plugin" };
|
||||
loaderDataMock = {
|
||||
@@ -86,7 +93,7 @@ describe("package detail route", () => {
|
||||
};
|
||||
});
|
||||
|
||||
it("hides download actions when the package has no latest release", async () => {
|
||||
it("hides download actions when the plugin has no latest release", async () => {
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
@@ -95,4 +102,56 @@ describe("package detail route", () => {
|
||||
expect(screen.getByText("No latest tag")).toBeTruthy();
|
||||
expect(screen.queryByRole("link", { name: "Download zip" })).toBeNull();
|
||||
});
|
||||
|
||||
it("renders package security scan results when scan data is present", async () => {
|
||||
loaderDataMock = {
|
||||
detail: loaderDataMock.detail,
|
||||
version: {
|
||||
package: {
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
},
|
||||
version: {
|
||||
version: "1.0.0",
|
||||
createdAt: 1,
|
||||
changelog: "Initial release",
|
||||
distTags: ["latest"],
|
||||
files: [],
|
||||
compatibility: null,
|
||||
capabilities: null,
|
||||
verification: { tier: "source-linked", scope: "artifact-only", scanStatus: "clean" },
|
||||
sha256hash: "a".repeat(64),
|
||||
vtAnalysis: {
|
||||
status: "clean",
|
||||
checkedAt: 1,
|
||||
},
|
||||
llmAnalysis: {
|
||||
status: "clean",
|
||||
verdict: "clean",
|
||||
summary: "Looks safe.",
|
||||
checkedAt: 1,
|
||||
},
|
||||
staticScan: {
|
||||
status: "clean",
|
||||
reasonCodes: [],
|
||||
findings: [],
|
||||
summary: "No issues",
|
||||
engineVersion: "1",
|
||||
checkedAt: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
readme: null,
|
||||
};
|
||||
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
expect(screen.getByText("Security Scan")).toBeTruthy();
|
||||
expect(screen.getAllByText("VirusTotal").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("OpenClaw").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { createElement } from "react";
|
||||
import { vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute:
|
||||
(path: string) =>
|
||||
(config: { component: unknown }) => ({ __config: config, __path: path }),
|
||||
useSearch: () => ({
|
||||
ownerHandle: undefined,
|
||||
name: undefined,
|
||||
displayName: undefined,
|
||||
family: undefined,
|
||||
nextVersion: undefined,
|
||||
sourceRepo: undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
const generateUploadUrl = vi.fn();
|
||||
@@ -16,13 +24,14 @@ const useAuthStatusMock = vi.fn();
|
||||
vi.mock("convex/react", () => ({
|
||||
useMutation: () => generateUploadUrl,
|
||||
useAction: () => publishRelease,
|
||||
useQuery: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock("../lib/useAuthStatus", () => ({
|
||||
useAuthStatus: () => useAuthStatusMock(),
|
||||
}));
|
||||
|
||||
import { Route } from "../routes/packages/new";
|
||||
import { Route } from "../routes/publish-plugin";
|
||||
|
||||
function renderPublishRoute() {
|
||||
const route = Route as unknown as {
|
||||
@@ -47,7 +56,7 @@ function getFileInput() {
|
||||
return input;
|
||||
}
|
||||
|
||||
describe("packages publish route", () => {
|
||||
describe("plugins publish route", () => {
|
||||
beforeEach(() => {
|
||||
generateUploadUrl.mockReset();
|
||||
publishRelease.mockReset();
|
||||
@@ -74,12 +83,23 @@ describe("packages publish route", () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("registers the publish form on /packages/new", () => {
|
||||
it("registers the publish form on /publish-plugin", () => {
|
||||
const route = Route as unknown as {
|
||||
__path: string;
|
||||
};
|
||||
|
||||
expect(route.__path).toBe("/packages/new");
|
||||
expect(route.__path).toBe("/publish-plugin");
|
||||
});
|
||||
|
||||
it("keeps metadata inputs locked until plugin code is uploaded", () => {
|
||||
renderPublishRoute();
|
||||
|
||||
expect(screen.getByText(/Upload plugin code to detect the package shape/i)).toBeTruthy();
|
||||
expect(screen.getByPlaceholderText("Plugin name").getAttribute("disabled")).not.toBeNull();
|
||||
expect(screen.getByPlaceholderText("Display name").getAttribute("disabled")).not.toBeNull();
|
||||
expect(screen.getByPlaceholderText("Version").getAttribute("disabled")).not.toBeNull();
|
||||
expect(screen.getByPlaceholderText("Changelog").getAttribute("disabled")).not.toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Publish" }).getAttribute("disabled")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("publishes a code plugin folder with source metadata and normalized file paths", async () => {
|
||||
@@ -92,6 +112,7 @@ describe("packages publish route", () => {
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
version: "1.2.3",
|
||||
repository: "https://github.com/openclaw/demo-plugin.git",
|
||||
}),
|
||||
],
|
||||
"package.json",
|
||||
@@ -114,14 +135,13 @@ describe("packages publish route", () => {
|
||||
expect(screen.getByDisplayValue("demo-plugin")).toBeTruthy();
|
||||
expect(screen.getByDisplayValue("Demo Plugin")).toBeTruthy();
|
||||
expect(screen.getByDisplayValue("1.2.3")).toBeTruthy();
|
||||
expect(screen.getByDisplayValue("openclaw/demo-plugin")).toBeTruthy();
|
||||
expect(screen.getByPlaceholderText("Plugin name").getAttribute("disabled")).toBeNull();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("Changelog"), {
|
||||
target: { value: "Initial release" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("Source repo (owner/repo)"), {
|
||||
target: { value: "openclaw/demo-plugin" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("Source commit"), {
|
||||
target: { value: "abc123" },
|
||||
});
|
||||
@@ -164,10 +184,6 @@ describe("packages publish route", () => {
|
||||
it("publishes a bundle plugin folder with bundle metadata", async () => {
|
||||
renderPublishRoute();
|
||||
|
||||
fireEvent.change(screen.getByRole("combobox"), {
|
||||
target: { value: "bundle-plugin" },
|
||||
});
|
||||
|
||||
const packageJson = withRelativePath(
|
||||
new File(
|
||||
[
|
||||
@@ -175,6 +191,10 @@ describe("packages publish route", () => {
|
||||
name: "demo-bundle",
|
||||
displayName: "Demo Bundle",
|
||||
version: "0.4.0",
|
||||
openclaw: {
|
||||
bundleFormat: "openclaw-bundle",
|
||||
hostTargets: ["desktop", "mobile"],
|
||||
},
|
||||
}),
|
||||
],
|
||||
"package.json",
|
||||
@@ -197,17 +217,16 @@ describe("packages publish route", () => {
|
||||
expect(screen.getByDisplayValue("demo-bundle")).toBeTruthy();
|
||||
expect(screen.getByDisplayValue("Demo Bundle")).toBeTruthy();
|
||||
expect(screen.getByDisplayValue("0.4.0")).toBeTruthy();
|
||||
expect((screen.getAllByRole("combobox")[0] as HTMLSelectElement).value).toBe("bundle-plugin");
|
||||
expect(screen.getByDisplayValue("openclaw-bundle")).toBeTruthy();
|
||||
expect(screen.getByDisplayValue("desktop, mobile")).toBeTruthy();
|
||||
expect(screen.getByText(/Browse files/i)).toBeTruthy();
|
||||
expect(screen.getByText(/Choose folder/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("Changelog"), {
|
||||
target: { value: "Bundle release" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("Bundle format"), {
|
||||
target: { value: "openclaw-bundle" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("Host targets (comma separated)"), {
|
||||
target: { value: "desktop, mobile" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Publish" }));
|
||||
|
||||
@@ -236,6 +255,49 @@ describe("packages publish route", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("prefills metadata from a wrapped GitHub release package", async () => {
|
||||
renderPublishRoute();
|
||||
|
||||
const packageJson = new File(
|
||||
[
|
||||
JSON.stringify({
|
||||
name: "@opik/opik-openclaw",
|
||||
version: "0.2.9",
|
||||
repository: {
|
||||
type: "git",
|
||||
url: "https://github.com/comet-ml/opik-openclaw.git",
|
||||
},
|
||||
}),
|
||||
],
|
||||
"opik-openclaw-0.2.9/package.json",
|
||||
{ type: "application/json" },
|
||||
);
|
||||
const manifest = new File(
|
||||
[JSON.stringify({ id: "opik-openclaw", name: "Opik" })],
|
||||
"opik-openclaw-0.2.9/openclaw.plugin.json",
|
||||
{ type: "application/json" },
|
||||
);
|
||||
const readme = new File(
|
||||
["# Opik OpenClaw\n"],
|
||||
"opik-openclaw-0.2.9/README.md",
|
||||
{ type: "text/markdown" },
|
||||
);
|
||||
|
||||
fireEvent.change(getFileInput(), { target: { files: [packageJson, manifest, readme] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("@opik/opik-openclaw")).toBeTruthy();
|
||||
expect(screen.getByDisplayValue("Opik")).toBeTruthy();
|
||||
expect(screen.getByDisplayValue("0.2.9")).toBeTruthy();
|
||||
expect(screen.getByDisplayValue("comet-ml/opik-openclaw")).toBeTruthy();
|
||||
expect(screen.getByText(/Metadata detected and prefilled/i)).toBeTruthy();
|
||||
expect(screen.getByText(/Autofilled package type, plugin name, display name, version, source repo\./i)).toBeTruthy();
|
||||
expect(screen.getByText("Package manifest")).toBeTruthy();
|
||||
expect(screen.getByText("Plugin manifest")).toBeTruthy();
|
||||
expect(screen.queryByText("opik-openclaw-0.2.9/package.json")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("applies ignore rules before uploading a plugin folder", async () => {
|
||||
renderPublishRoute();
|
||||
|
||||
@@ -271,7 +333,7 @@ describe("packages publish route", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Ignored 1 files via ignore rules\./)).toBeTruthy();
|
||||
expect(screen.getByText(/Ignored 1 files/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("Changelog"), {
|
||||
@@ -302,4 +364,74 @@ describe("packages publish route", () => {
|
||||
"src/index.js",
|
||||
]);
|
||||
});
|
||||
|
||||
it("blocks plugin publish when a file exceeds 10MB", async () => {
|
||||
renderPublishRoute();
|
||||
|
||||
const packageJson = withRelativePath(
|
||||
new File([JSON.stringify({ name: "demo-plugin", version: "1.0.0" })], "package.json", {
|
||||
type: "application/json",
|
||||
}),
|
||||
"demo-plugin/package.json",
|
||||
);
|
||||
const manifest = withRelativePath(
|
||||
new File(['{"id":"demo.plugin"}'], "openclaw.plugin.json", { type: "application/json" }),
|
||||
"demo-plugin/openclaw.plugin.json",
|
||||
);
|
||||
const huge = withRelativePath(
|
||||
new File(["x"], "plugin.wasm", { type: "application/wasm" }),
|
||||
"demo-plugin/dist/plugin.wasm",
|
||||
);
|
||||
Object.defineProperty(huge, "size", {
|
||||
value: 10 * 1024 * 1024 + 1,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
fireEvent.change(getFileInput(), { target: { files: [packageJson, manifest, huge] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Each file must be 10MB or smaller: plugin\.wasm/i)).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Publish" }).getAttribute("disabled")).not.toBeNull();
|
||||
expect(publishRelease).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows pending verification messaging after plugin publish", async () => {
|
||||
renderPublishRoute();
|
||||
|
||||
const packageJson = withRelativePath(
|
||||
new File([JSON.stringify({ name: "demo-plugin", version: "1.0.0" })], "package.json", {
|
||||
type: "application/json",
|
||||
}),
|
||||
"demo-plugin/package.json",
|
||||
);
|
||||
const manifest = withRelativePath(
|
||||
new File(['{"id":"demo.plugin"}'], "openclaw.plugin.json", { type: "application/json" }),
|
||||
"demo-plugin/openclaw.plugin.json",
|
||||
);
|
||||
const dist = withRelativePath(
|
||||
new File(["export const demo = true;\n"], "index.js", { type: "text/javascript" }),
|
||||
"demo-plugin/dist/index.js",
|
||||
);
|
||||
|
||||
fireEvent.change(getFileInput(), { target: { files: [packageJson, manifest, dist] } });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("demo-plugin")).toBeTruthy();
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("Changelog"), {
|
||||
target: { value: "Initial release" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("Source repo (owner/repo)"), {
|
||||
target: { value: "openclaw/demo-plugin" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("Source commit"), {
|
||||
target: { value: "abc123" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Publish" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(/Pending security checks and verification before public listing\./i),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const fetchPackagesMock = vi.fn();
|
||||
const fetchPluginCatalogMock = vi.fn();
|
||||
const navigateMock = vi.fn();
|
||||
let searchMock: Record<string, unknown> = {};
|
||||
let loaderDataMock: {
|
||||
@@ -44,11 +44,11 @@ vi.mock("@tanstack/react-router", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../lib/packageApi", () => ({
|
||||
fetchPackages: (...args: unknown[]) => fetchPackagesMock(...args),
|
||||
fetchPluginCatalog: (...args: unknown[]) => fetchPluginCatalogMock(...args),
|
||||
}));
|
||||
|
||||
async function loadRoute() {
|
||||
return (await import("../routes/packages/index")).Route as unknown as {
|
||||
return (await import("../routes/plugins/index")).Route as unknown as {
|
||||
__config: {
|
||||
loader?: (args: { deps: Record<string, unknown> }) => Promise<unknown>;
|
||||
component?: ComponentType;
|
||||
@@ -57,29 +57,29 @@ async function loadRoute() {
|
||||
};
|
||||
}
|
||||
|
||||
describe("packages route", () => {
|
||||
describe("plugins route", () => {
|
||||
beforeEach(() => {
|
||||
fetchPackagesMock.mockReset();
|
||||
fetchPluginCatalogMock.mockReset();
|
||||
navigateMock.mockReset();
|
||||
searchMock = {};
|
||||
loaderDataMock = { items: [], nextCursor: null };
|
||||
});
|
||||
|
||||
it("preserves skill family filters in search state", async () => {
|
||||
it("rejects skill family filter in search state", async () => {
|
||||
const route = await loadRoute();
|
||||
const validateSearch = route.__config.validateSearch as (search: Record<string, unknown>) => Record<string, unknown>;
|
||||
|
||||
expect(validateSearch({ family: "skill", q: "demo" })).toEqual({
|
||||
family: "skill",
|
||||
family: undefined,
|
||||
q: "demo",
|
||||
cursor: undefined,
|
||||
official: undefined,
|
||||
verified: undefined,
|
||||
executesCode: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards opaque cursors through the loader", async () => {
|
||||
fetchPackagesMock.mockResolvedValue({ items: [], nextCursor: "cursor:next" });
|
||||
fetchPluginCatalogMock.mockResolvedValue({ items: [], nextCursor: "cursor:next" });
|
||||
const route = await loadRoute();
|
||||
const loader = route.__config.loader as (args: {
|
||||
deps: Record<string, unknown>;
|
||||
@@ -92,7 +92,7 @@ describe("packages route", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(fetchPackagesMock).toHaveBeenCalledWith(
|
||||
expect(fetchPluginCatalogMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cursor: "cursor:current",
|
||||
family: "code-plugin",
|
||||
@@ -134,12 +134,43 @@ describe("packages route", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the Skills family option", async () => {
|
||||
it("filters out skills from loader results", async () => {
|
||||
fetchPluginCatalogMock.mockResolvedValue({
|
||||
items: [
|
||||
{ name: "my-skill", displayName: "My Skill", family: "skill", channel: "community", isOfficial: false, createdAt: 1, updatedAt: 1 },
|
||||
{ name: "my-plugin", displayName: "My Plugin", family: "code-plugin", channel: "community", isOfficial: false, createdAt: 1, updatedAt: 1 },
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
const loader = route.__config.loader as (args: {
|
||||
deps: Record<string, unknown>;
|
||||
}) => Promise<{ items: Array<{ name: string }>; nextCursor: string | null }>;
|
||||
|
||||
render(<Component />);
|
||||
const result = await loader({ deps: {} });
|
||||
|
||||
expect(screen.getByRole("option", { name: "Skills" })).toBeTruthy();
|
||||
expect(result.items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("uses plugin-only catalog fetching for verified browse", async () => {
|
||||
fetchPluginCatalogMock.mockResolvedValue({ items: [], nextCursor: null });
|
||||
const route = await loadRoute();
|
||||
const loader = route.__config.loader as (args: {
|
||||
deps: Record<string, unknown>;
|
||||
}) => Promise<unknown>;
|
||||
|
||||
await loader({
|
||||
deps: {
|
||||
verified: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(fetchPluginCatalogMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
family: undefined,
|
||||
isOfficial: true,
|
||||
limit: 50,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ vi.mock("../lib/useAuthStatus", () => ({
|
||||
describe("SkillDetailPage", () => {
|
||||
const skillId = "skills:1" as Id<"skills">;
|
||||
const ownerId = "users:1" as Id<"users">;
|
||||
const ownerPublisherId = "publishers:steipete" as Id<"publishers">;
|
||||
const versionId = "skillVersions:1" as Id<"skillVersions">;
|
||||
const storageId = "storage:1" as Id<"_storage">;
|
||||
|
||||
@@ -75,6 +76,7 @@ describe("SkillDetailPage", () => {
|
||||
displayName: "Weather",
|
||||
summary: "Get current weather.",
|
||||
ownerUserId: ownerId,
|
||||
ownerPublisherId,
|
||||
tags: {},
|
||||
badges: {},
|
||||
stats: {
|
||||
@@ -89,10 +91,12 @@ describe("SkillDetailPage", () => {
|
||||
updatedAt: 0,
|
||||
},
|
||||
owner: {
|
||||
_id: ownerId,
|
||||
_id: ownerPublisherId,
|
||||
_creationTime: 0,
|
||||
kind: "user",
|
||||
handle: "steipete",
|
||||
name: "Peter",
|
||||
displayName: "Peter",
|
||||
linkedUserId: ownerId,
|
||||
},
|
||||
latestVersion: {
|
||||
_id: versionId,
|
||||
@@ -148,6 +152,7 @@ describe("SkillDetailPage", () => {
|
||||
displayName: "Weather",
|
||||
summary: "Get current weather.",
|
||||
ownerUserId: ownerId,
|
||||
ownerPublisherId,
|
||||
tags: {},
|
||||
badges: {},
|
||||
stats: {
|
||||
@@ -162,10 +167,12 @@ describe("SkillDetailPage", () => {
|
||||
updatedAt: 0,
|
||||
},
|
||||
owner: {
|
||||
_id: ownerId,
|
||||
_id: ownerPublisherId,
|
||||
_creationTime: 0,
|
||||
kind: "user",
|
||||
handle: "steipete",
|
||||
name: "Peter",
|
||||
displayName: "Peter",
|
||||
linkedUserId: ownerId,
|
||||
},
|
||||
latestVersion: {
|
||||
_id: versionId,
|
||||
@@ -215,17 +222,25 @@ describe("SkillDetailPage", () => {
|
||||
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
|
||||
if (args === "skip") return undefined;
|
||||
if (args && typeof args === "object" && "skillId" in args) return [];
|
||||
return {
|
||||
skill: {
|
||||
_id: "skills:1",
|
||||
slug: "weather",
|
||||
displayName: "Weather",
|
||||
summary: "Get current weather.",
|
||||
ownerUserId: "users:1",
|
||||
tags: {},
|
||||
stats: { stars: 0, downloads: 0 },
|
||||
return {
|
||||
skill: {
|
||||
_id: "skills:1",
|
||||
slug: "weather",
|
||||
displayName: "Weather",
|
||||
summary: "Get current weather.",
|
||||
ownerUserId: "users:1",
|
||||
ownerPublisherId: "publishers:steipete",
|
||||
tags: {},
|
||||
stats: { stars: 0, downloads: 0 },
|
||||
},
|
||||
owner: {
|
||||
_id: "publishers:steipete",
|
||||
_creationTime: 0,
|
||||
kind: "user",
|
||||
handle: "steipete",
|
||||
displayName: "Peter",
|
||||
linkedUserId: "users:1",
|
||||
},
|
||||
owner: { handle: "steipete", name: "Peter" },
|
||||
latestVersion: { _id: "skillVersions:1", version: "1.0.0", parsed: {} },
|
||||
};
|
||||
});
|
||||
@@ -260,10 +275,18 @@ describe("SkillDetailPage", () => {
|
||||
displayName: "Weather",
|
||||
summary: "Get current weather.",
|
||||
ownerUserId: "users:1",
|
||||
ownerPublisherId: "publishers:steipete",
|
||||
tags: {},
|
||||
stats: { stars: 0, downloads: 0 },
|
||||
},
|
||||
owner: { handle: "steipete", name: "Peter" },
|
||||
owner: {
|
||||
_id: "publishers:steipete",
|
||||
_creationTime: 0,
|
||||
kind: "user",
|
||||
handle: "steipete",
|
||||
displayName: "Peter",
|
||||
linkedUserId: "users:1",
|
||||
},
|
||||
latestVersion: { _id: "skillVersions:1", version: "1.0.0", parsed: {}, files: [] },
|
||||
};
|
||||
}
|
||||
@@ -312,10 +335,18 @@ describe("SkillDetailPage", () => {
|
||||
displayName: "Weather",
|
||||
summary: "Get current weather.",
|
||||
ownerUserId: "users:1",
|
||||
ownerPublisherId: "publishers:steipete",
|
||||
tags: {},
|
||||
stats: { stars: 0, downloads: 0 },
|
||||
},
|
||||
owner: { _id: "users:1", handle: "steipete", name: "Peter" },
|
||||
owner: {
|
||||
_id: "publishers:steipete",
|
||||
_creationTime: 0,
|
||||
kind: "user",
|
||||
handle: "steipete",
|
||||
displayName: "Peter",
|
||||
linkedUserId: "users:1",
|
||||
},
|
||||
latestVersion: { _id: "skillVersions:1", version: "1.0.0", parsed: {}, files: [] },
|
||||
};
|
||||
}
|
||||
@@ -344,10 +375,18 @@ describe("SkillDetailPage", () => {
|
||||
displayName: "Weather",
|
||||
summary: "Get current weather.",
|
||||
ownerUserId: "users:1",
|
||||
ownerPublisherId: "publishers:steipete",
|
||||
tags: {},
|
||||
stats: { stars: 0, downloads: 0 },
|
||||
},
|
||||
owner: { handle: "steipete", name: "Peter" },
|
||||
owner: {
|
||||
_id: "publishers:steipete",
|
||||
_creationTime: 0,
|
||||
kind: "user",
|
||||
handle: "steipete",
|
||||
displayName: "Peter",
|
||||
linkedUserId: "users:1",
|
||||
},
|
||||
latestVersion: { _id: "skillVersions:1", version: "1.0.0", parsed: {}, files: [] },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -252,7 +252,7 @@ describe("SkillsIndex", () => {
|
||||
});
|
||||
|
||||
const titles = Array.from(
|
||||
document.querySelectorAll(".skills-row-title > span:first-child"),
|
||||
document.querySelectorAll(".skills-table-name > span:first-child"),
|
||||
).map((node) => node.textContent);
|
||||
|
||||
expect(titles[0]).toBe("Older High Score");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { strToU8, zipSync } from "fflate";
|
||||
import { vi } from "vitest";
|
||||
import { Upload } from "../routes/upload";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Upload } from "../routes/publish-skill";
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute: () => (config: { component: unknown }) => config,
|
||||
@@ -212,6 +212,37 @@ describe("Upload route", () => {
|
||||
expect(screen.getByText("screenshot.png")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows a validation error when a skill file exceeds 10MB", async () => {
|
||||
render(<Upload />);
|
||||
fireEvent.change(screen.getByPlaceholderText("skill-name"), {
|
||||
target: { value: "cool-skill" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("My skill"), {
|
||||
target: { value: "Cool Skill" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("1.0.0"), {
|
||||
target: { value: "1.2.3" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("latest, stable"), {
|
||||
target: { value: "latest" },
|
||||
});
|
||||
|
||||
const skill = new File(["hello"], "SKILL.md", { type: "text/markdown" });
|
||||
const huge = new File(["x"], "notes.md", { type: "text/markdown" });
|
||||
Object.defineProperty(huge, "size", {
|
||||
value: 10 * 1024 * 1024 + 1,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const input = screen.getByTestId("upload-input") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { files: [skill, huge] } });
|
||||
|
||||
expect(await screen.findByText(/Each file must be 10MB or smaller: notes\.md/i)).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /publish skill/i }).getAttribute("disabled"),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("shows an informational note when mac junk files are ignored", async () => {
|
||||
render(<Upload />);
|
||||
fireEvent.change(screen.getByPlaceholderText("skill-name"), {
|
||||
|
||||
@@ -17,6 +17,10 @@ vi.mock("../convex/client", () => ({
|
||||
convex: {},
|
||||
}));
|
||||
|
||||
vi.mock("./UserBootstrap", () => ({
|
||||
UserBootstrap: () => null,
|
||||
}));
|
||||
|
||||
describe("AuthCodeHandler", () => {
|
||||
beforeEach(() => {
|
||||
signInMock.mockReset();
|
||||
|
||||
@@ -93,11 +93,7 @@ export default function Header() {
|
||||
Skills
|
||||
</Link>
|
||||
)}
|
||||
{isSoulMode ? null : <Link to="/packages">Packages</Link>}
|
||||
<Link to="/upload" search={{ updateSlug: undefined }}>
|
||||
Upload
|
||||
</Link>
|
||||
{isSoulMode ? null : <Link to="/import">Import</Link>}
|
||||
{isSoulMode ? null : <Link to="/plugins">Plugins</Link>}
|
||||
<Link
|
||||
to={isSoulMode ? "/souls" : "/skills"}
|
||||
search={
|
||||
@@ -122,6 +118,7 @@ export default function Header() {
|
||||
>
|
||||
Search
|
||||
</Link>
|
||||
{isSoulMode ? null : <Link to="/about">About</Link>}
|
||||
{me ? <Link to="/stars">Stars</Link> : null}
|
||||
{isStaff ? (
|
||||
<Link to="/management" search={{ skill: undefined }}>
|
||||
@@ -176,17 +173,7 @@ export default function Header() {
|
||||
</DropdownMenuItem>
|
||||
{isSoulMode ? null : (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/packages">Packages</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/upload" search={{ updateSlug: undefined }}>
|
||||
Upload
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{isSoulMode ? null : (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/import">Import</Link>
|
||||
<Link to="/plugins">Plugins</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem asChild>
|
||||
@@ -215,6 +202,11 @@ export default function Header() {
|
||||
Search
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{isSoulMode ? null : (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/about">About</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{me ? (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/stars">Stars</Link>
|
||||
|
||||
@@ -81,6 +81,9 @@ export function SkillDetailPage({
|
||||
const reportSkill = useMutation(api.skills.report);
|
||||
const updateTags = useMutation(api.skills.updateTags);
|
||||
const getReadme = useAction(api.skills.getReadme);
|
||||
const myPublishers = useQuery(api.publishers.listMine) as
|
||||
| Array<{ publisher: { _id: Id<"publishers"> }; role: string }>
|
||||
| undefined;
|
||||
|
||||
const [readme, setReadme] = useState<string | null>(initialData?.readme ?? null);
|
||||
const [readmeError, setReadmeError] = useState<string | null>(initialData?.readmeError ?? null);
|
||||
@@ -118,14 +121,29 @@ export function SkillDetailPage({
|
||||
isAuthenticated && skill ? { skillId: skill._id } : "skip",
|
||||
);
|
||||
|
||||
const canManage = canManageSkill(me, skill);
|
||||
const isOwner = Boolean(me && skill && me._id === skill.ownerUserId);
|
||||
const myPublisherIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
(Array.isArray(myPublishers) ? myPublishers : []).map((entry) => entry.publisher._id),
|
||||
),
|
||||
[myPublishers],
|
||||
);
|
||||
const canManage =
|
||||
canManageSkill(me, skill) ||
|
||||
Boolean(skill?.ownerPublisherId && myPublisherIds.has(skill.ownerPublisherId));
|
||||
const isOwner =
|
||||
Boolean(me && skill && me._id === skill.ownerUserId) ||
|
||||
Boolean(skill?.ownerPublisherId && myPublisherIds.has(skill.ownerPublisherId));
|
||||
const ownedSkills = useQuery(
|
||||
api.skills.list,
|
||||
isOwner && skill ? { ownerUserId: skill.ownerUserId, limit: 100 } : "skip",
|
||||
isOwner && skill
|
||||
? skill.ownerPublisherId
|
||||
? { ownerPublisherId: skill.ownerPublisherId, limit: 100 }
|
||||
: { ownerUserId: skill.ownerUserId, limit: 100 }
|
||||
: "skip",
|
||||
) as Array<{ _id: Id<"skills">; slug: string; displayName: string }> | undefined;
|
||||
|
||||
const ownerHandle = owner?.handle ?? owner?.name ?? null;
|
||||
const ownerHandle = owner?.handle ?? null;
|
||||
const ownerParam = ownerHandle ?? (owner?._id ? String(owner._id) : null);
|
||||
const wantsCanonicalRedirect = Boolean(
|
||||
ownerParam &&
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Package } from "lucide-react";
|
||||
import type { Doc, Id } from "../../convex/_generated/dataModel";
|
||||
import { getSkillBadges } from "../lib/badges";
|
||||
import { formatCompactStat, formatSkillStatsTriplet } from "../lib/numberFormat";
|
||||
import type { PublicSkill, PublicUser } from "../lib/publicUser";
|
||||
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
|
||||
import { getRuntimeEnv } from "../lib/runtimeEnv";
|
||||
import { SkillInstallCard } from "./SkillInstallCard";
|
||||
import { type LlmAnalysis, SecurityScanResults } from "./SkillSecurityScanResults";
|
||||
@@ -39,7 +39,7 @@ type SkillCanonical = {
|
||||
|
||||
type SkillHeaderProps = {
|
||||
skill: Doc<"skills"> | PublicSkill;
|
||||
owner: Doc<"users"> | PublicUser | null;
|
||||
owner: PublicPublisher | null;
|
||||
ownerHandle: string | null;
|
||||
latestVersion: Doc<"skillVersions"> | null;
|
||||
modInfo: SkillModerationInfo | null;
|
||||
|
||||
@@ -15,7 +15,7 @@ type SkillOwnershipPanelProps = {
|
||||
skillId: Id<"skills">;
|
||||
slug: string;
|
||||
ownerHandle: string | null;
|
||||
ownerId: Id<"users"> | null;
|
||||
ownerId: Id<"users"> | Id<"publishers"> | null;
|
||||
ownedSkills: OwnedSkillOption[];
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PublicUser } from "../lib/publicUser";
|
||||
import type { PublicPublisher, PublicUser } from "../lib/publicUser";
|
||||
|
||||
type UserBadgeProps = {
|
||||
user: PublicUser | null | undefined;
|
||||
user: PublicUser | PublicPublisher | null | undefined;
|
||||
fallbackHandle?: string | null;
|
||||
prefix?: string;
|
||||
size?: "sm" | "md";
|
||||
@@ -17,17 +17,26 @@ export function UserBadge({
|
||||
link = true,
|
||||
showName = false,
|
||||
}: UserBadgeProps) {
|
||||
const handle = user?.handle ?? user?.name ?? fallbackHandle ?? null;
|
||||
const href = user?.handle ? `/u/${encodeURIComponent(user.handle)}` : null;
|
||||
const userName = user && "name" in user ? user.name?.trim() : undefined;
|
||||
const displayName =
|
||||
user?.displayName?.trim() || userName || null;
|
||||
const handle = user?.handle ?? fallbackHandle ?? null;
|
||||
const href =
|
||||
user?.handle && "kind" in user
|
||||
? user.kind === "org"
|
||||
? `/orgs/${encodeURIComponent(user.handle)}`
|
||||
: `/u/${encodeURIComponent(user.handle)}`
|
||||
: user?.handle
|
||||
? `/u/${encodeURIComponent(user.handle)}`
|
||||
: null;
|
||||
const label = handle ? `@${handle}` : "user";
|
||||
const image = user?.image ?? null;
|
||||
const displayName = user?.displayName?.trim() || null;
|
||||
const hasUsefulName =
|
||||
showName &&
|
||||
Boolean(displayName) &&
|
||||
Boolean(handle) &&
|
||||
displayName!.toLowerCase() !== handle!.toLowerCase();
|
||||
const initial = (user?.displayName ?? user?.name ?? handle ?? "u").charAt(0).toUpperCase();
|
||||
const initial = (displayName ?? handle ?? "u").charAt(0).toUpperCase();
|
||||
|
||||
return (
|
||||
<span className={`user-badge user-badge-${size}`}>
|
||||
|
||||
@@ -3,11 +3,11 @@ import type { Id } from "../../convex/_generated/dataModel";
|
||||
|
||||
export function buildSkillHref(
|
||||
ownerHandle: string | null,
|
||||
ownerId: Id<"users"> | null,
|
||||
ownerId: Id<"users"> | Id<"publishers"> | null,
|
||||
slug: string,
|
||||
) {
|
||||
const owner = ownerHandle?.trim() || (ownerId ? String(ownerId) : "unknown");
|
||||
return `/${owner}/${slug}`;
|
||||
return `/${encodeURIComponent(owner)}/${encodeURIComponent(slug)}`;
|
||||
}
|
||||
|
||||
export function formatConfigSnippet(raw: string) {
|
||||
|
||||
+160
-14
@@ -12,6 +12,7 @@ import {
|
||||
fetchPackageDetail,
|
||||
fetchPackageReadme,
|
||||
fetchPackageVersion,
|
||||
fetchPluginCatalog,
|
||||
fetchPackages,
|
||||
getPackageDownloadPath,
|
||||
} from "./packageApi";
|
||||
@@ -125,26 +126,23 @@ describe("fetchPackages", () => {
|
||||
expect(url.searchParams.get("limit")).toBe("7");
|
||||
});
|
||||
|
||||
it("falls back across supported README variants", async () => {
|
||||
it("requests README through the canonical package file path once", async () => {
|
||||
vi.stubEnv("VITE_CONVEX_URL", "https://registry.example");
|
||||
const fetchMock = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValueOnce(new Response("missing", { status: 404 }))
|
||||
.mockResolvedValueOnce(new Response("lowercase readme", { status: 200 }));
|
||||
.mockResolvedValue(new Response("lowercase readme", { status: 200 }));
|
||||
|
||||
const result = await fetchPackageReadme("demo-plugin", "1.0.0");
|
||||
|
||||
expect(result).toBe("lowercase readme");
|
||||
const firstRequest = fetchMock.mock.calls[0]?.[0];
|
||||
const secondRequest = fetchMock.mock.calls[1]?.[0];
|
||||
if (typeof firstRequest !== "string" || typeof secondRequest !== "string") {
|
||||
throw new Error("Expected fetch calls to use string URLs");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const requestUrl = fetchMock.mock.calls[0]?.[0];
|
||||
if (typeof requestUrl !== "string") {
|
||||
throw new Error("Expected fetch call to use a string URL");
|
||||
}
|
||||
const first = new URL(firstRequest);
|
||||
const second = new URL(secondRequest);
|
||||
expect(first.searchParams.get("path")).toBe("README.md");
|
||||
expect(second.searchParams.get("path")).toBe("readme.md");
|
||||
expect(second.searchParams.get("version")).toBe("1.0.0");
|
||||
const url = new URL(requestUrl);
|
||||
expect(url.searchParams.get("path")).toBe("README.md");
|
||||
expect(url.searchParams.get("version")).toBe("1.0.0");
|
||||
});
|
||||
|
||||
it("returns an empty package detail payload on 404", async () => {
|
||||
@@ -160,7 +158,15 @@ describe("fetchPackages", () => {
|
||||
it("forwards request cookies and includes credentials for package detail fetches", async () => {
|
||||
vi.stubEnv("VITE_CONVEX_URL", "https://registry.example");
|
||||
getRequestUrlMock.mockReturnValue(new URL("https://app.example/packages/private-plugin"));
|
||||
getRequestHeadersMock.mockReturnValue(new Headers({ cookie: "session=abc" }));
|
||||
getRequestHeadersMock.mockReturnValue(
|
||||
new Headers({
|
||||
cookie: "session=abc",
|
||||
"cf-connecting-ip": "203.0.113.9",
|
||||
"x-forwarded-for": "203.0.113.9, 198.51.100.2",
|
||||
"x-real-ip": "203.0.113.9",
|
||||
"fly-client-ip": "203.0.113.9",
|
||||
}),
|
||||
);
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({ package: null, owner: null }), { status: 200 }),
|
||||
);
|
||||
@@ -174,6 +180,10 @@ describe("fetchPackages", () => {
|
||||
headers: expect.objectContaining({
|
||||
Accept: "application/json",
|
||||
cookie: "session=abc",
|
||||
"cf-connecting-ip": "203.0.113.9",
|
||||
"x-forwarded-for": "203.0.113.9, 198.51.100.2",
|
||||
"x-real-ip": "203.0.113.9",
|
||||
"fly-client-ip": "203.0.113.9",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -212,6 +222,54 @@ describe("fetchPackages", () => {
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe("https://app.example/api/v1/bundle-plugins?limit=12");
|
||||
});
|
||||
|
||||
it("uses the dedicated plugins endpoint for mixed plugin browse", async () => {
|
||||
vi.stubEnv("VITE_CONVEX_URL", "https://registry.example");
|
||||
const fetchMock = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response(JSON.stringify({ items: [], nextCursor: null }), { status: 200 }));
|
||||
|
||||
await fetchPluginCatalog({
|
||||
limit: 12,
|
||||
cursor: "pkgpage:test",
|
||||
isOfficial: true,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const requestUrl = fetchMock.mock.calls[0]?.[0];
|
||||
if (typeof requestUrl !== "string") {
|
||||
throw new Error("Expected fetch to be called with a string URL");
|
||||
}
|
||||
const url = new URL(requestUrl);
|
||||
expect(url.pathname).toBe("/api/v1/plugins");
|
||||
expect(url.searchParams.get("limit")).toBe("12");
|
||||
expect(url.searchParams.get("cursor")).toBe("pkgpage:test");
|
||||
expect(url.searchParams.get("isOfficial")).toBe("true");
|
||||
});
|
||||
|
||||
it("uses the dedicated plugins search endpoint for mixed plugin search", async () => {
|
||||
vi.stubEnv("VITE_CONVEX_URL", "https://registry.example");
|
||||
const fetchMock = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response(JSON.stringify({ results: [] }), { status: 200 }));
|
||||
|
||||
await fetchPluginCatalog({
|
||||
q: "demo",
|
||||
limit: 8,
|
||||
executesCode: false,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const requestUrl = fetchMock.mock.calls[0]?.[0];
|
||||
if (typeof requestUrl !== "string") {
|
||||
throw new Error("Expected fetch to be called with a string URL");
|
||||
}
|
||||
const url = new URL(requestUrl);
|
||||
expect(url.pathname).toBe("/api/v1/plugins/search");
|
||||
expect(url.searchParams.get("q")).toBe("demo");
|
||||
expect(url.searchParams.get("limit")).toBe("8");
|
||||
expect(url.searchParams.get("executesCode")).toBe("false");
|
||||
});
|
||||
|
||||
it("throws package detail errors for non-404 failures", async () => {
|
||||
vi.stubEnv("VITE_CONVEX_URL", "https://registry.example");
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("boom", { status: 500 }));
|
||||
@@ -246,7 +304,17 @@ describe("fetchPackages", () => {
|
||||
.mockResolvedValue(new Response("missing", { status: 404 }));
|
||||
|
||||
await expect(fetchPackageReadme("demo-plugin", "1.0.0")).resolves.toBeNull();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns null when README access is blocked pending scan", async () => {
|
||||
vi.stubEnv("VITE_CONVEX_URL", "https://registry.example");
|
||||
const fetchMock = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response("pending scan", { status: 423 }));
|
||||
|
||||
await expect(fetchPackageReadme("demo-plugin", "1.0.0")).resolves.toBeNull();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("throws when README fetch fails for reasons other than 404", async () => {
|
||||
@@ -268,3 +336,81 @@ describe("fetchPackages", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchPluginCatalog", () => {
|
||||
afterEach(() => {
|
||||
getRequestHeadersMock.mockReset();
|
||||
getRequestUrlMock.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("uses the dedicated plugins endpoint for browse mode without touching the unified catalog", async () => {
|
||||
vi.stubEnv("VITE_CONVEX_URL", "https://registry.example");
|
||||
const fetchMock = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response(JSON.stringify({ items: [], nextCursor: "plugins:next" }), { status: 200 }));
|
||||
|
||||
const result = await fetchPluginCatalog({
|
||||
isOfficial: true,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
expect(result.nextCursor).toBe("plugins:next");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const url = new URL(fetchMock.mock.calls[0]?.[0] as string);
|
||||
expect(url.pathname).toBe("/api/v1/plugins");
|
||||
expect(url.searchParams.get("isOfficial")).toBe("true");
|
||||
});
|
||||
|
||||
it("uses the dedicated plugins search endpoint for search mode", async () => {
|
||||
vi.stubEnv("VITE_CONVEX_URL", "https://registry.example");
|
||||
const fetchMock = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
results: [
|
||||
{
|
||||
score: 5,
|
||||
package: {
|
||||
name: "code-demo",
|
||||
displayName: "Code Demo",
|
||||
family: "code-plugin",
|
||||
channel: "community",
|
||||
isOfficial: true,
|
||||
createdAt: 2,
|
||||
updatedAt: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
score: 4,
|
||||
package: {
|
||||
name: "bundle-demo",
|
||||
displayName: "Bundle Demo",
|
||||
family: "bundle-plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
|
||||
const result = await fetchPluginCatalog({
|
||||
q: "demo",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result.nextCursor).toBeNull();
|
||||
expect(result.items.map((item) => item.name)).toEqual(["code-demo", "bundle-demo"]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const url = new URL(fetchMock.mock.calls[0]?.[0] as string);
|
||||
expect(url.pathname).toBe("/api/v1/plugins/search");
|
||||
});
|
||||
});
|
||||
|
||||
+115
-10
@@ -68,9 +68,55 @@ export type PackageVersionDetail = {
|
||||
compatibility?: PackageCompatibility | null;
|
||||
capabilities?: PackageCapabilitySummary | null;
|
||||
verification?: PackageVerificationSummary | null;
|
||||
sha256hash?: string | null;
|
||||
vtAnalysis?: {
|
||||
status: string;
|
||||
verdict?: string;
|
||||
analysis?: string;
|
||||
source?: string;
|
||||
checkedAt: number;
|
||||
} | null;
|
||||
llmAnalysis?: {
|
||||
status: string;
|
||||
verdict?: string;
|
||||
confidence?: string;
|
||||
summary?: string;
|
||||
dimensions?: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
rating: string;
|
||||
detail: string;
|
||||
}>;
|
||||
guidance?: string;
|
||||
findings?: string;
|
||||
model?: string;
|
||||
checkedAt: number;
|
||||
} | null;
|
||||
staticScan?: {
|
||||
status: string;
|
||||
reasonCodes: string[];
|
||||
findings: Array<{
|
||||
code: string;
|
||||
severity: string;
|
||||
file: string;
|
||||
line: number;
|
||||
message: string;
|
||||
evidence: string;
|
||||
}>;
|
||||
summary: string;
|
||||
engineVersion: string;
|
||||
checkedAt: number;
|
||||
} | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type PluginFamily = "code-plugin" | "bundle-plugin";
|
||||
|
||||
type PluginCatalogResult = {
|
||||
items: PackageListItem[];
|
||||
nextCursor: string | null;
|
||||
};
|
||||
|
||||
function normalizeApiPath(path: string) {
|
||||
return path.startsWith("/") ? path : `/${path}`;
|
||||
}
|
||||
@@ -117,8 +163,18 @@ async function getForwardedHeaders() {
|
||||
const headers: Record<string, string> = {};
|
||||
const cookie = requestHeaders.get("cookie");
|
||||
const authorization = requestHeaders.get("authorization");
|
||||
const clientIpHeaders = [
|
||||
"cf-connecting-ip",
|
||||
"x-forwarded-for",
|
||||
"x-real-ip",
|
||||
"fly-client-ip",
|
||||
] as const;
|
||||
if (cookie) headers.cookie = cookie;
|
||||
if (authorization) headers.authorization = authorization;
|
||||
for (const headerName of clientIpHeaders) {
|
||||
const value = requestHeaders.get(headerName);
|
||||
if (value) headers[headerName] = value;
|
||||
}
|
||||
return headers;
|
||||
} catch {
|
||||
return {};
|
||||
@@ -187,6 +243,58 @@ export async function fetchPackages(params: {
|
||||
return await fetchJson<{ items: PackageListItem[]; nextCursor: string | null }>(url);
|
||||
}
|
||||
|
||||
export async function fetchPluginCatalog(params: {
|
||||
q?: string;
|
||||
cursor?: string;
|
||||
family?: PluginFamily;
|
||||
isOfficial?: boolean;
|
||||
executesCode?: boolean;
|
||||
limit?: number;
|
||||
}): Promise<PluginCatalogResult> {
|
||||
if (params.family) {
|
||||
const response = await fetchPackages({
|
||||
q: params.q,
|
||||
cursor: params.cursor,
|
||||
family: params.family,
|
||||
isOfficial: params.isOfficial,
|
||||
executesCode: params.executesCode,
|
||||
limit: params.limit,
|
||||
});
|
||||
return {
|
||||
items: "results" in response ? response.results.map((entry) => entry.package) : response.items,
|
||||
nextCursor: "results" in response ? null : response.nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
if (params.q?.trim()) {
|
||||
const url = await packageApiUrl(`${ApiRoutes.plugins}/search`);
|
||||
url.searchParams.set("q", params.q.trim());
|
||||
if (typeof params.limit === "number") url.searchParams.set("limit", String(params.limit));
|
||||
if (typeof params.isOfficial === "boolean") {
|
||||
url.searchParams.set("isOfficial", String(params.isOfficial));
|
||||
}
|
||||
if (typeof params.executesCode === "boolean") {
|
||||
url.searchParams.set("executesCode", String(params.executesCode));
|
||||
}
|
||||
const response = await fetchJson<{ results: Array<{ score: number; package: PackageListItem }> }>(url);
|
||||
return {
|
||||
items: response.results.map((entry) => entry.package),
|
||||
nextCursor: null,
|
||||
};
|
||||
}
|
||||
|
||||
const url = await packageApiUrl(ApiRoutes.plugins);
|
||||
if (params.cursor) url.searchParams.set("cursor", params.cursor);
|
||||
if (typeof params.limit === "number") url.searchParams.set("limit", String(params.limit));
|
||||
if (typeof params.isOfficial === "boolean") {
|
||||
url.searchParams.set("isOfficial", String(params.isOfficial));
|
||||
}
|
||||
if (typeof params.executesCode === "boolean") {
|
||||
url.searchParams.set("executesCode", String(params.executesCode));
|
||||
}
|
||||
return await fetchJson<PluginCatalogResult>(url);
|
||||
}
|
||||
|
||||
export async function fetchPackageDetail(name: string) {
|
||||
const url = await packageApiUrl(`${ApiRoutes.packages}/${encodeURIComponent(name)}`);
|
||||
const response = await packageFetch(url, "application/json");
|
||||
@@ -208,14 +316,11 @@ export async function fetchPackageVersion(name: string, version: string) {
|
||||
}
|
||||
|
||||
export async function fetchPackageReadme(name: string, version?: string | null) {
|
||||
const variants = ["README.md", "readme.md", "README.mdx", "readme.mdx"];
|
||||
for (const path of variants) {
|
||||
const url = await packageApiUrl(`${ApiRoutes.packages}/${encodeURIComponent(name)}/file`);
|
||||
url.searchParams.set("path", path);
|
||||
if (version) url.searchParams.set("version", version);
|
||||
const response = await packageFetch(url, "text/plain");
|
||||
if (response.ok) return await response.text();
|
||||
if (response.status !== 404) throw new Error(await response.text());
|
||||
}
|
||||
return null;
|
||||
const url = await packageApiUrl(`${ApiRoutes.packages}/${encodeURIComponent(name)}/file`);
|
||||
url.searchParams.set("path", "README.md");
|
||||
if (version) url.searchParams.set("version", version);
|
||||
const response = await packageFetch(url, "text/plain");
|
||||
if (response.ok) return await response.text();
|
||||
if (response.status === 403 || response.status === 423 || response.status === 404) return null;
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
|
||||
@@ -11,6 +11,11 @@ type UploadablePackageFile = {
|
||||
webkitRelativePath?: string;
|
||||
};
|
||||
|
||||
export type NormalizedPackageUploadFile<TFile extends UploadablePackageFile = UploadablePackageFile> = {
|
||||
file: TFile;
|
||||
path: string;
|
||||
};
|
||||
|
||||
const KNOWN_PACKAGE_ROOT_PATHS = new Set([
|
||||
'package.json',
|
||||
'openclaw.plugin.json',
|
||||
@@ -67,14 +72,20 @@ function shouldStripSharedTopLevelFolder<TFile extends UploadablePackageFile>(fi
|
||||
.some((path) => KNOWN_PACKAGE_ROOT_PATHS.has(path));
|
||||
}
|
||||
|
||||
export async function filterIgnoredPackageFiles<TFile extends UploadablePackageFile & Pick<File, "text">>(
|
||||
export function normalizePackageUploadFiles<TFile extends UploadablePackageFile>(
|
||||
files: TFile[],
|
||||
) {
|
||||
): NormalizedPackageUploadFile<TFile>[] {
|
||||
const stripTopLevelFolder = shouldStripSharedTopLevelFolder(files);
|
||||
const normalized = files.map((file) => ({
|
||||
return files.map((file) => ({
|
||||
file,
|
||||
path: getNormalizedUploadPath(file, { stripTopLevelFolder }),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function filterIgnoredPackageFiles<TFile extends UploadablePackageFile & Pick<File, "text">>(
|
||||
files: TFile[],
|
||||
) {
|
||||
const normalized = normalizePackageUploadFiles(files);
|
||||
const ig = ignore();
|
||||
ig.add(DEFAULT_PACKAGE_IGNORE_PATTERNS);
|
||||
|
||||
@@ -111,13 +122,11 @@ export async function buildPackageUploadEntries<TFile extends UploadablePackageF
|
||||
sha256: string;
|
||||
contentType?: string;
|
||||
}> = [];
|
||||
const stripTopLevelFolder = shouldStripSharedTopLevelFolder(files);
|
||||
|
||||
for (const file of files) {
|
||||
for (const { file, path } of normalizePackageUploadFiles(files)) {
|
||||
const sha256 = await options.hashFile(file);
|
||||
const uploadUrl = await options.generateUploadUrl();
|
||||
const storageId = await options.uploadFile(uploadUrl, file);
|
||||
const path = getNormalizedUploadPath(file, { stripTopLevelFolder });
|
||||
uploaded.push({
|
||||
path,
|
||||
size: file.size,
|
||||
|
||||
@@ -5,6 +5,11 @@ export type PublicUser = Pick<
|
||||
"_id" | "_creationTime" | "handle" | "name" | "displayName" | "image" | "bio"
|
||||
>;
|
||||
|
||||
export type PublicPublisher = Pick<
|
||||
Doc<"publishers">,
|
||||
"_id" | "_creationTime" | "kind" | "handle" | "displayName" | "image" | "bio" | "linkedUserId"
|
||||
>;
|
||||
|
||||
export type PublicSkill = Pick<
|
||||
Doc<"skills">,
|
||||
| "_id"
|
||||
@@ -13,6 +18,7 @@ export type PublicSkill = Pick<
|
||||
| "displayName"
|
||||
| "summary"
|
||||
| "ownerUserId"
|
||||
| "ownerPublisherId"
|
||||
| "canonicalSkillId"
|
||||
| "forkOf"
|
||||
| "latestVersionId"
|
||||
@@ -31,6 +37,7 @@ export type PublicSoul = Pick<
|
||||
| "displayName"
|
||||
| "summary"
|
||||
| "ownerUserId"
|
||||
| "ownerPublisherId"
|
||||
| "latestVersionId"
|
||||
| "tags"
|
||||
| "stats"
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Doc, Id } from "../../convex/_generated/dataModel";
|
||||
import { convexHttp } from "../convex/client";
|
||||
import type { PublicSkill, PublicUser } from "./publicUser";
|
||||
import type { PublicPublisher, PublicSkill } from "./publicUser";
|
||||
|
||||
export type SkillBySlugResult = {
|
||||
requestedSlug?: string | null;
|
||||
resolvedSlug?: string | null;
|
||||
skill: Doc<"skills"> | PublicSkill;
|
||||
latestVersion: Doc<"skillVersions"> | null;
|
||||
owner: Doc<"users"> | PublicUser | null;
|
||||
owner: PublicPublisher | null;
|
||||
pendingReview?: boolean;
|
||||
moderationInfo?: {
|
||||
isPendingScan: boolean;
|
||||
@@ -81,7 +81,10 @@ export async function fetchSkillPageData(slug: string): Promise<SkillPageLoaderD
|
||||
}
|
||||
|
||||
return {
|
||||
owner: result.owner?.handle ?? result.owner?.name ?? null,
|
||||
owner:
|
||||
result.owner?.handle ??
|
||||
result.owner?.displayName ??
|
||||
((result.owner as { name?: string | null } | null)?.name ?? null),
|
||||
displayName: result.skill.displayName ?? null,
|
||||
summary: result.skill.summary ?? null,
|
||||
version: result.latestVersion?.version ?? null,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useAuthStatus } from "./useAuthStatus";
|
||||
|
||||
const useConvexAuthMock = vi.fn();
|
||||
const useQueryMock = vi.fn();
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
useConvexAuth: () => useConvexAuthMock(),
|
||||
useQuery: (...args: unknown[]) => useQueryMock(...args),
|
||||
}));
|
||||
|
||||
function Probe() {
|
||||
const { isAuthenticated, isLoading, me } = useAuthStatus();
|
||||
return (
|
||||
<output>
|
||||
{JSON.stringify({
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
me,
|
||||
})}
|
||||
</output>
|
||||
);
|
||||
}
|
||||
|
||||
describe("useAuthStatus", () => {
|
||||
it("does not keep auth loading true when only the profile query is unresolved", () => {
|
||||
useConvexAuthMock.mockReturnValue({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
});
|
||||
useQueryMock.mockReturnValue(undefined);
|
||||
|
||||
render(<Probe />);
|
||||
|
||||
expect(screen.getByText('{"isAuthenticated":false,"isLoading":false}')).toBeTruthy();
|
||||
});
|
||||
|
||||
it("preserves authenticated session state before the profile query resolves", () => {
|
||||
useConvexAuthMock.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
useQueryMock.mockReturnValue(undefined);
|
||||
|
||||
render(<Probe />);
|
||||
|
||||
expect(screen.getByText('{"isAuthenticated":true,"isLoading":false}')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useQuery } from "convex/react";
|
||||
import { useConvexAuth, useQuery } from "convex/react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Doc } from "../../convex/_generated/dataModel";
|
||||
|
||||
export function useAuthStatus() {
|
||||
const auth = useConvexAuth();
|
||||
const me = useQuery(api.users.me) as Doc<"users"> | null | undefined;
|
||||
return {
|
||||
me,
|
||||
isLoading: me === undefined,
|
||||
isAuthenticated: Boolean(me),
|
||||
isLoading: auth.isLoading,
|
||||
isAuthenticated: auth.isAuthenticated,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,18 +13,25 @@ import { Route as UploadRouteImport } from './routes/upload'
|
||||
import { Route as StarsRouteImport } from './routes/stars'
|
||||
import { Route as SettingsRouteImport } from './routes/settings'
|
||||
import { Route as SearchRouteImport } from './routes/search'
|
||||
import { Route as PublishSkillRouteImport } from './routes/publish-skill'
|
||||
import { Route as PublishPluginRouteImport } from './routes/publish-plugin'
|
||||
import { Route as ManagementRouteImport } from './routes/management'
|
||||
import { Route as ImportRouteImport } from './routes/import'
|
||||
import { Route as DashboardRouteImport } from './routes/dashboard'
|
||||
import { Route as AdminRouteImport } from './routes/admin'
|
||||
import { Route as AboutRouteImport } from './routes/about'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as SoulsIndexRouteImport } from './routes/souls/index'
|
||||
import { Route as SkillsIndexRouteImport } from './routes/skills/index'
|
||||
import { Route as PluginsIndexRouteImport } from './routes/plugins/index'
|
||||
import { Route as PackagesIndexRouteImport } from './routes/packages/index'
|
||||
import { Route as UHandleRouteImport } from './routes/u/$handle'
|
||||
import { Route as SoulsSlugRouteImport } from './routes/souls/$slug'
|
||||
import { Route as PluginsNewRouteImport } from './routes/plugins/new'
|
||||
import { Route as PluginsNameRouteImport } from './routes/plugins/$name'
|
||||
import { Route as PackagesNewRouteImport } from './routes/packages/new'
|
||||
import { Route as PackagesNameRouteImport } from './routes/packages/$name'
|
||||
import { Route as OrgsHandleRouteImport } from './routes/orgs/$handle'
|
||||
import { Route as CliAuthRouteImport } from './routes/cli/auth'
|
||||
import { Route as OwnerSlugRouteImport } from './routes/$owner/$slug'
|
||||
|
||||
@@ -48,6 +55,16 @@ const SearchRoute = SearchRouteImport.update({
|
||||
path: '/search',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PublishSkillRoute = PublishSkillRouteImport.update({
|
||||
id: '/publish-skill',
|
||||
path: '/publish-skill',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PublishPluginRoute = PublishPluginRouteImport.update({
|
||||
id: '/publish-plugin',
|
||||
path: '/publish-plugin',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ManagementRoute = ManagementRouteImport.update({
|
||||
id: '/management',
|
||||
path: '/management',
|
||||
@@ -68,6 +85,11 @@ const AdminRoute = AdminRouteImport.update({
|
||||
path: '/admin',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AboutRoute = AboutRouteImport.update({
|
||||
id: '/about',
|
||||
path: '/about',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
@@ -83,6 +105,11 @@ const SkillsIndexRoute = SkillsIndexRouteImport.update({
|
||||
path: '/skills/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PluginsIndexRoute = PluginsIndexRouteImport.update({
|
||||
id: '/plugins/',
|
||||
path: '/plugins/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PackagesIndexRoute = PackagesIndexRouteImport.update({
|
||||
id: '/packages/',
|
||||
path: '/packages/',
|
||||
@@ -98,6 +125,16 @@ const SoulsSlugRoute = SoulsSlugRouteImport.update({
|
||||
path: '/souls/$slug',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PluginsNewRoute = PluginsNewRouteImport.update({
|
||||
id: '/plugins/new',
|
||||
path: '/plugins/new',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PluginsNameRoute = PluginsNameRouteImport.update({
|
||||
id: '/plugins/$name',
|
||||
path: '/plugins/$name',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PackagesNewRoute = PackagesNewRouteImport.update({
|
||||
id: '/packages/new',
|
||||
path: '/packages/new',
|
||||
@@ -108,6 +145,11 @@ const PackagesNameRoute = PackagesNameRouteImport.update({
|
||||
path: '/packages/$name',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const OrgsHandleRoute = OrgsHandleRouteImport.update({
|
||||
id: '/orgs/$handle',
|
||||
path: '/orgs/$handle',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const CliAuthRoute = CliAuthRouteImport.update({
|
||||
id: '/cli/auth',
|
||||
path: '/cli/auth',
|
||||
@@ -121,62 +163,83 @@ const OwnerSlugRoute = OwnerSlugRouteImport.update({
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/about': typeof AboutRoute
|
||||
'/admin': typeof AdminRoute
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/import': typeof ImportRoute
|
||||
'/management': typeof ManagementRoute
|
||||
'/publish-plugin': typeof PublishPluginRoute
|
||||
'/publish-skill': typeof PublishSkillRoute
|
||||
'/search': typeof SearchRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/stars': typeof StarsRoute
|
||||
'/upload': typeof UploadRoute
|
||||
'/$owner/$slug': typeof OwnerSlugRoute
|
||||
'/cli/auth': typeof CliAuthRoute
|
||||
'/orgs/$handle': typeof OrgsHandleRoute
|
||||
'/packages/$name': typeof PackagesNameRoute
|
||||
'/packages/new': typeof PackagesNewRoute
|
||||
'/plugins/$name': typeof PluginsNameRoute
|
||||
'/plugins/new': typeof PluginsNewRoute
|
||||
'/souls/$slug': typeof SoulsSlugRoute
|
||||
'/u/$handle': typeof UHandleRoute
|
||||
'/packages/': typeof PackagesIndexRoute
|
||||
'/plugins/': typeof PluginsIndexRoute
|
||||
'/skills/': typeof SkillsIndexRoute
|
||||
'/souls/': typeof SoulsIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/about': typeof AboutRoute
|
||||
'/admin': typeof AdminRoute
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/import': typeof ImportRoute
|
||||
'/management': typeof ManagementRoute
|
||||
'/publish-plugin': typeof PublishPluginRoute
|
||||
'/publish-skill': typeof PublishSkillRoute
|
||||
'/search': typeof SearchRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/stars': typeof StarsRoute
|
||||
'/upload': typeof UploadRoute
|
||||
'/$owner/$slug': typeof OwnerSlugRoute
|
||||
'/cli/auth': typeof CliAuthRoute
|
||||
'/orgs/$handle': typeof OrgsHandleRoute
|
||||
'/packages/$name': typeof PackagesNameRoute
|
||||
'/packages/new': typeof PackagesNewRoute
|
||||
'/plugins/$name': typeof PluginsNameRoute
|
||||
'/plugins/new': typeof PluginsNewRoute
|
||||
'/souls/$slug': typeof SoulsSlugRoute
|
||||
'/u/$handle': typeof UHandleRoute
|
||||
'/packages': typeof PackagesIndexRoute
|
||||
'/plugins': typeof PluginsIndexRoute
|
||||
'/skills': typeof SkillsIndexRoute
|
||||
'/souls': typeof SoulsIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/about': typeof AboutRoute
|
||||
'/admin': typeof AdminRoute
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/import': typeof ImportRoute
|
||||
'/management': typeof ManagementRoute
|
||||
'/publish-plugin': typeof PublishPluginRoute
|
||||
'/publish-skill': typeof PublishSkillRoute
|
||||
'/search': typeof SearchRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/stars': typeof StarsRoute
|
||||
'/upload': typeof UploadRoute
|
||||
'/$owner/$slug': typeof OwnerSlugRoute
|
||||
'/cli/auth': typeof CliAuthRoute
|
||||
'/orgs/$handle': typeof OrgsHandleRoute
|
||||
'/packages/$name': typeof PackagesNameRoute
|
||||
'/packages/new': typeof PackagesNewRoute
|
||||
'/plugins/$name': typeof PluginsNameRoute
|
||||
'/plugins/new': typeof PluginsNewRoute
|
||||
'/souls/$slug': typeof SoulsSlugRoute
|
||||
'/u/$handle': typeof UHandleRoute
|
||||
'/packages/': typeof PackagesIndexRoute
|
||||
'/plugins/': typeof PluginsIndexRoute
|
||||
'/skills/': typeof SkillsIndexRoute
|
||||
'/souls/': typeof SoulsIndexRoute
|
||||
}
|
||||
@@ -184,82 +247,110 @@ export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/about'
|
||||
| '/admin'
|
||||
| '/dashboard'
|
||||
| '/import'
|
||||
| '/management'
|
||||
| '/publish-plugin'
|
||||
| '/publish-skill'
|
||||
| '/search'
|
||||
| '/settings'
|
||||
| '/stars'
|
||||
| '/upload'
|
||||
| '/$owner/$slug'
|
||||
| '/cli/auth'
|
||||
| '/orgs/$handle'
|
||||
| '/packages/$name'
|
||||
| '/packages/new'
|
||||
| '/plugins/$name'
|
||||
| '/plugins/new'
|
||||
| '/souls/$slug'
|
||||
| '/u/$handle'
|
||||
| '/packages/'
|
||||
| '/plugins/'
|
||||
| '/skills/'
|
||||
| '/souls/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/about'
|
||||
| '/admin'
|
||||
| '/dashboard'
|
||||
| '/import'
|
||||
| '/management'
|
||||
| '/publish-plugin'
|
||||
| '/publish-skill'
|
||||
| '/search'
|
||||
| '/settings'
|
||||
| '/stars'
|
||||
| '/upload'
|
||||
| '/$owner/$slug'
|
||||
| '/cli/auth'
|
||||
| '/orgs/$handle'
|
||||
| '/packages/$name'
|
||||
| '/packages/new'
|
||||
| '/plugins/$name'
|
||||
| '/plugins/new'
|
||||
| '/souls/$slug'
|
||||
| '/u/$handle'
|
||||
| '/packages'
|
||||
| '/plugins'
|
||||
| '/skills'
|
||||
| '/souls'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/about'
|
||||
| '/admin'
|
||||
| '/dashboard'
|
||||
| '/import'
|
||||
| '/management'
|
||||
| '/publish-plugin'
|
||||
| '/publish-skill'
|
||||
| '/search'
|
||||
| '/settings'
|
||||
| '/stars'
|
||||
| '/upload'
|
||||
| '/$owner/$slug'
|
||||
| '/cli/auth'
|
||||
| '/orgs/$handle'
|
||||
| '/packages/$name'
|
||||
| '/packages/new'
|
||||
| '/plugins/$name'
|
||||
| '/plugins/new'
|
||||
| '/souls/$slug'
|
||||
| '/u/$handle'
|
||||
| '/packages/'
|
||||
| '/plugins/'
|
||||
| '/skills/'
|
||||
| '/souls/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AboutRoute: typeof AboutRoute
|
||||
AdminRoute: typeof AdminRoute
|
||||
DashboardRoute: typeof DashboardRoute
|
||||
ImportRoute: typeof ImportRoute
|
||||
ManagementRoute: typeof ManagementRoute
|
||||
PublishPluginRoute: typeof PublishPluginRoute
|
||||
PublishSkillRoute: typeof PublishSkillRoute
|
||||
SearchRoute: typeof SearchRoute
|
||||
SettingsRoute: typeof SettingsRoute
|
||||
StarsRoute: typeof StarsRoute
|
||||
UploadRoute: typeof UploadRoute
|
||||
OwnerSlugRoute: typeof OwnerSlugRoute
|
||||
CliAuthRoute: typeof CliAuthRoute
|
||||
OrgsHandleRoute: typeof OrgsHandleRoute
|
||||
PackagesNameRoute: typeof PackagesNameRoute
|
||||
PackagesNewRoute: typeof PackagesNewRoute
|
||||
PluginsNameRoute: typeof PluginsNameRoute
|
||||
PluginsNewRoute: typeof PluginsNewRoute
|
||||
SoulsSlugRoute: typeof SoulsSlugRoute
|
||||
UHandleRoute: typeof UHandleRoute
|
||||
PackagesIndexRoute: typeof PackagesIndexRoute
|
||||
PluginsIndexRoute: typeof PluginsIndexRoute
|
||||
SkillsIndexRoute: typeof SkillsIndexRoute
|
||||
SoulsIndexRoute: typeof SoulsIndexRoute
|
||||
}
|
||||
@@ -294,6 +385,20 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof SearchRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/publish-skill': {
|
||||
id: '/publish-skill'
|
||||
path: '/publish-skill'
|
||||
fullPath: '/publish-skill'
|
||||
preLoaderRoute: typeof PublishSkillRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/publish-plugin': {
|
||||
id: '/publish-plugin'
|
||||
path: '/publish-plugin'
|
||||
fullPath: '/publish-plugin'
|
||||
preLoaderRoute: typeof PublishPluginRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/management': {
|
||||
id: '/management'
|
||||
path: '/management'
|
||||
@@ -322,6 +427,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AdminRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/about': {
|
||||
id: '/about'
|
||||
path: '/about'
|
||||
fullPath: '/about'
|
||||
preLoaderRoute: typeof AboutRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
@@ -343,6 +455,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof SkillsIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/plugins/': {
|
||||
id: '/plugins/'
|
||||
path: '/plugins'
|
||||
fullPath: '/plugins/'
|
||||
preLoaderRoute: typeof PluginsIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/packages/': {
|
||||
id: '/packages/'
|
||||
path: '/packages'
|
||||
@@ -364,6 +483,20 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof SoulsSlugRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/plugins/new': {
|
||||
id: '/plugins/new'
|
||||
path: '/plugins/new'
|
||||
fullPath: '/plugins/new'
|
||||
preLoaderRoute: typeof PluginsNewRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/plugins/$name': {
|
||||
id: '/plugins/$name'
|
||||
path: '/plugins/$name'
|
||||
fullPath: '/plugins/$name'
|
||||
preLoaderRoute: typeof PluginsNameRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/packages/new': {
|
||||
id: '/packages/new'
|
||||
path: '/packages/new'
|
||||
@@ -378,6 +511,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof PackagesNameRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/orgs/$handle': {
|
||||
id: '/orgs/$handle'
|
||||
path: '/orgs/$handle'
|
||||
fullPath: '/orgs/$handle'
|
||||
preLoaderRoute: typeof OrgsHandleRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/cli/auth': {
|
||||
id: '/cli/auth'
|
||||
path: '/cli/auth'
|
||||
@@ -397,21 +537,28 @@ declare module '@tanstack/react-router' {
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AboutRoute: AboutRoute,
|
||||
AdminRoute: AdminRoute,
|
||||
DashboardRoute: DashboardRoute,
|
||||
ImportRoute: ImportRoute,
|
||||
ManagementRoute: ManagementRoute,
|
||||
PublishPluginRoute: PublishPluginRoute,
|
||||
PublishSkillRoute: PublishSkillRoute,
|
||||
SearchRoute: SearchRoute,
|
||||
SettingsRoute: SettingsRoute,
|
||||
StarsRoute: StarsRoute,
|
||||
UploadRoute: UploadRoute,
|
||||
OwnerSlugRoute: OwnerSlugRoute,
|
||||
CliAuthRoute: CliAuthRoute,
|
||||
OrgsHandleRoute: OrgsHandleRoute,
|
||||
PackagesNameRoute: PackagesNameRoute,
|
||||
PackagesNewRoute: PackagesNewRoute,
|
||||
PluginsNameRoute: PluginsNameRoute,
|
||||
PluginsNewRoute: PluginsNewRoute,
|
||||
SoulsSlugRoute: SoulsSlugRoute,
|
||||
UHandleRoute: UHandleRoute,
|
||||
PackagesIndexRoute: PackagesIndexRoute,
|
||||
PluginsIndexRoute: PluginsIndexRoute,
|
||||
SkillsIndexRoute: SkillsIndexRoute,
|
||||
SoulsIndexRoute: SoulsIndexRoute,
|
||||
}
|
||||
|
||||
@@ -6,8 +6,7 @@ import { fetchSkillPageData } from "../../lib/skillPage";
|
||||
export const Route = createFileRoute("/$owner/$slug")({
|
||||
loader: async ({ params }) => {
|
||||
const data = await fetchSkillPageData(params.slug);
|
||||
const canonicalOwner =
|
||||
data.initialData?.result?.owner?.handle ?? data.initialData?.result?.owner?.name ?? null;
|
||||
const canonicalOwner = data.initialData?.result?.owner?.handle ?? null;
|
||||
const canonicalSlug = data.initialData?.result?.resolvedSlug ?? params.slug;
|
||||
|
||||
if (canonicalOwner && (canonicalOwner !== params.owner || canonicalSlug !== params.slug)) {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router';
|
||||
import { getSiteMode, getSiteName, getSiteUrlForMode } from '../lib/site';
|
||||
|
||||
const prohibitedCategories = [
|
||||
{
|
||||
title: 'Bypass and unauthorized access',
|
||||
examples:
|
||||
'Auth bypass, account takeover, CAPTCHA bypass, Cloudflare or anti-bot evasion, rate-limit bypass, reusable session theft, live call or agent takeover.',
|
||||
},
|
||||
{
|
||||
title: 'Platform abuse and ban evasion',
|
||||
examples:
|
||||
'Stealth accounts after bans, account warming/farming, fake engagement, multi-account automation, spam posting, marketplace or social automation built to avoid detection.',
|
||||
},
|
||||
{
|
||||
title: 'Fraud and deception',
|
||||
examples:
|
||||
'Fake certificates, fake invoices, deceptive payment flows, fake social proof, scam outreach, or synthetic-identity workflows built to create accounts for fraud.',
|
||||
},
|
||||
{
|
||||
title: 'Privacy-invasive surveillance',
|
||||
examples:
|
||||
'Mass contact scraping for spam, doxxing, stalking, covert monitoring, biometric / face-matching workflows without clear consent, or buying, publishing, downloading, or operationalizing leaked data or breach dumps.',
|
||||
},
|
||||
{
|
||||
title: 'Non-consensual impersonation',
|
||||
examples:
|
||||
'Face swap, digital twins, cloned influencers, fake personas, or other identity manipulation used to impersonate or mislead.',
|
||||
},
|
||||
{
|
||||
title: 'Explicit sexual content',
|
||||
examples:
|
||||
'NSFW image, video, or text generation, especially wrappers around third-party APIs with safety checks disabled.',
|
||||
},
|
||||
{
|
||||
title: 'Hidden or misleading execution',
|
||||
examples:
|
||||
'Obfuscated install commands, `curl | sh`, undeclared secret requirements, undeclared private-key use, or remote `npx @latest` execution without reviewability.',
|
||||
},
|
||||
];
|
||||
|
||||
const recentPatterns = [
|
||||
'Create stealth seller accounts after marketplace bans.',
|
||||
'Modify Telegram pairing so unapproved users automatically receive pairing codes.',
|
||||
'Cultivate Reddit or Twitter accounts with undetectable automation.',
|
||||
'Generate professional certificates or invoices for arbitrary use.',
|
||||
'Generate NSFW content with safety checks disabled.',
|
||||
'Scrape leads, enrich contacts, and launch cold outreach at scale.',
|
||||
'Buy, publish, or download leaked data or breach dumps.',
|
||||
'Bulk-create email or social accounts with synthetic identities or CAPTCHA solving.',
|
||||
];
|
||||
|
||||
export const Route = createFileRoute('/about')({
|
||||
head: () => {
|
||||
const mode = getSiteMode();
|
||||
const siteName = getSiteName(mode);
|
||||
const siteUrl = getSiteUrlForMode(mode);
|
||||
const title = `About · ${siteName}`;
|
||||
const description =
|
||||
'What ClawHub allows, what we do not host, and the abuse patterns that lead to removal or account bans.';
|
||||
|
||||
return {
|
||||
links: [
|
||||
{
|
||||
rel: "canonical",
|
||||
href: `${siteUrl}/about`,
|
||||
},
|
||||
],
|
||||
meta: [
|
||||
{ title },
|
||||
{ name: 'description', content: description },
|
||||
{ property: 'og:title', content: title },
|
||||
{ property: 'og:description', content: description },
|
||||
{ property: 'og:type', content: 'website' },
|
||||
{ property: 'og:url', content: `${siteUrl}/about` },
|
||||
],
|
||||
};
|
||||
},
|
||||
component: AboutPage,
|
||||
});
|
||||
|
||||
function AboutPage() {
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="skill-detail-stack">
|
||||
<section className="card">
|
||||
<div className="skill-card-tags" style={{ marginBottom: 12 }}>
|
||||
<span className="tag">About</span>
|
||||
<span className="tag tag-accent">Policy</span>
|
||||
</div>
|
||||
<h1 className="section-title" style={{ marginBottom: 10 }}>
|
||||
What ClawHub Will Not Host
|
||||
</h1>
|
||||
<p className="section-subtitle" style={{ marginBottom: 14 }}>
|
||||
ClawHub is for useful agent tooling, not abuse workflows. If a skill is built to evade
|
||||
defenses, abuse platforms, scam people, invade privacy, or enable non-consensual
|
||||
behavior, it does not belong here.
|
||||
</p>
|
||||
<div className="stat">
|
||||
We moderate based on end-to-end abuse patterns, not just isolated keywords.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid" style={{ gap: 16 }}>
|
||||
{prohibitedCategories.map((category) => (
|
||||
<article key={category.title} className="card">
|
||||
<h2 className="dashboard-collection-title" style={{ marginBottom: 8 }}>
|
||||
{category.title}
|
||||
</h2>
|
||||
<p className="section-subtitle" style={{ margin: 0 }}>
|
||||
{category.examples}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h2 className="dashboard-collection-title" style={{ marginBottom: 10 }}>
|
||||
Recent patterns we are explicitly not okay with
|
||||
</h2>
|
||||
<div className="management-sublist">
|
||||
{recentPatterns.map((pattern) => (
|
||||
<div key={pattern} className="management-subitem">
|
||||
{pattern}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h2 className="dashboard-collection-title" style={{ marginBottom: 10 }}>
|
||||
Enforcement
|
||||
</h2>
|
||||
<div className="management-sublist">
|
||||
<div className="management-subitem">
|
||||
We may hide, remove, or hard-delete violating skills.
|
||||
</div>
|
||||
<div className="management-subitem">
|
||||
We may revoke tokens, soft-delete associated content, and ban repeat or severe
|
||||
offenders.
|
||||
</div>
|
||||
<div className="management-subitem">
|
||||
We do not guarantee warning-first enforcement for obvious abuse.
|
||||
</div>
|
||||
</div>
|
||||
<div className="skill-card-tags" style={{ marginTop: 16 }}>
|
||||
<Link className="btn btn-primary" to="/skills">
|
||||
Browse Skills
|
||||
</Link>
|
||||
<a
|
||||
className="btn"
|
||||
href="https://github.com/openclaw/clawhub/blob/main/docs/acceptable-usage.md"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Reviewer Doc
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+395
-38
@@ -1,23 +1,114 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "convex/react";
|
||||
import { Clock, Package, Plus, Upload } from "lucide-react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDownToLine,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
GitBranch,
|
||||
Package,
|
||||
Plug,
|
||||
ShieldCheck,
|
||||
Star,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import semver from "semver";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Doc } from "../../convex/_generated/dataModel";
|
||||
import { formatCompactStat } from "../lib/numberFormat";
|
||||
import { familyLabel } from "../lib/packageLabels";
|
||||
import type { PublicSkill } from "../lib/publicUser";
|
||||
|
||||
const emptyPluginPublishSearch = {
|
||||
ownerHandle: undefined,
|
||||
name: undefined,
|
||||
displayName: undefined,
|
||||
family: undefined,
|
||||
nextVersion: undefined,
|
||||
sourceRepo: undefined,
|
||||
} as const;
|
||||
|
||||
type DashboardSkill = PublicSkill & { pendingReview?: boolean };
|
||||
|
||||
type DashboardPackage = {
|
||||
_id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
family: "skill" | "code-plugin" | "bundle-plugin";
|
||||
channel: "official" | "community" | "private";
|
||||
isOfficial: boolean;
|
||||
runtimeId?: string | null;
|
||||
sourceRepo?: string | null;
|
||||
summary?: string | null;
|
||||
latestVersion?: string | null;
|
||||
stats: {
|
||||
downloads: number;
|
||||
installs: number;
|
||||
stars: number;
|
||||
versions: number;
|
||||
};
|
||||
verification?: {
|
||||
tier?: "structural" | "source-linked" | "provenance-verified" | "rebuild-verified";
|
||||
} | null;
|
||||
scanStatus?: "clean" | "suspicious" | "malicious" | "pending" | "not-run";
|
||||
pendingReview?: boolean;
|
||||
latestRelease: {
|
||||
version: string;
|
||||
createdAt: number;
|
||||
vtStatus: string | null;
|
||||
llmStatus: string | null;
|
||||
staticScanStatus: "clean" | "suspicious" | "malicious" | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/dashboard")({
|
||||
component: Dashboard,
|
||||
});
|
||||
|
||||
function Dashboard() {
|
||||
const me = useQuery(api.users.me) as Doc<"users"> | null | undefined;
|
||||
const publishers = useQuery(api.publishers.listMine) as
|
||||
| Array<{
|
||||
publisher: {
|
||||
_id: string;
|
||||
handle: string;
|
||||
displayName: string;
|
||||
kind: "user" | "org";
|
||||
};
|
||||
role: "owner" | "admin" | "publisher";
|
||||
}>
|
||||
| undefined;
|
||||
const [selectedPublisherId, setSelectedPublisherId] = useState<string>("");
|
||||
const selectedPublisher =
|
||||
publishers?.find((entry) => entry.publisher._id === selectedPublisherId) ?? null;
|
||||
|
||||
const mySkills = useQuery(
|
||||
api.skills.list,
|
||||
me?._id ? { ownerUserId: me._id, limit: 100 } : "skip",
|
||||
selectedPublisher?.publisher.kind === "user" && me?._id
|
||||
? { ownerUserId: me._id, limit: 100 }
|
||||
: selectedPublisherId
|
||||
? { ownerPublisherId: selectedPublisherId as Doc<"publishers">["_id"], limit: 100 }
|
||||
: me?._id
|
||||
? { ownerUserId: me._id, limit: 100 }
|
||||
: "skip",
|
||||
) as DashboardSkill[] | undefined;
|
||||
const myPackages = useQuery(
|
||||
api.packages.list,
|
||||
selectedPublisherId
|
||||
? { ownerPublisherId: selectedPublisherId as Doc<"publishers">["_id"], limit: 100 }
|
||||
: me?._id
|
||||
? { ownerUserId: me._id, limit: 100 }
|
||||
: "skip",
|
||||
) as DashboardPackage[] | undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPublisherId) return;
|
||||
const personal = publishers?.find((entry) => entry.publisher.kind === "user") ?? publishers?.[0];
|
||||
if (personal?.publisher._id) {
|
||||
setSelectedPublisherId(personal.publisher._id);
|
||||
}
|
||||
}, [publishers, selectedPublisherId]);
|
||||
|
||||
if (!me) {
|
||||
return (
|
||||
@@ -28,46 +119,134 @@ function Dashboard() {
|
||||
}
|
||||
|
||||
const skills = mySkills ?? [];
|
||||
const ownerHandle = me.handle ?? me.name ?? me.displayName ?? me._id;
|
||||
const packages = myPackages ?? [];
|
||||
const ownerHandle =
|
||||
selectedPublisher?.publisher.handle ?? me.handle ?? me.name ?? me.displayName ?? me._id;
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="dashboard-header">
|
||||
<h1 className="section-title" style={{ margin: 0 }}>
|
||||
My Skills
|
||||
</h1>
|
||||
<Link to="/upload" search={{ updateSlug: undefined }} className="btn btn-primary">
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
Upload New Skill
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{skills.length === 0 ? (
|
||||
<div className="card dashboard-empty">
|
||||
<Package className="dashboard-empty-icon" aria-hidden="true" />
|
||||
<h2>No skills yet</h2>
|
||||
<p>Upload your first skill to share it with the community.</p>
|
||||
<Link to="/upload" search={{ updateSlug: undefined }} className="btn btn-primary">
|
||||
<div style={{ display: "grid", gap: "6px" }}>
|
||||
<h1 className="section-title" style={{ margin: 0 }}>
|
||||
Publisher Dashboard
|
||||
</h1>
|
||||
<p className="section-subtitle" style={{ margin: 0 }}>
|
||||
Owner-only view for skills and plugins, including security scans and verification.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
{publishers && publishers.length > 0 ? (
|
||||
<select
|
||||
className="input"
|
||||
value={selectedPublisherId}
|
||||
onChange={(event) => setSelectedPublisherId(event.target.value)}
|
||||
>
|
||||
{publishers.map((entry) => (
|
||||
<option key={entry.publisher._id} value={entry.publisher._id}>
|
||||
@{entry.publisher.handle} · {entry.role}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
<Link to="/publish-skill" search={{ updateSlug: undefined }} className="btn btn-primary">
|
||||
<Upload className="h-4 w-4" aria-hidden="true" />
|
||||
Upload a Skill
|
||||
Publish Skill
|
||||
</Link>
|
||||
<Link
|
||||
to="/publish-plugin"
|
||||
search={{ ...emptyPluginPublishSearch, ownerHandle }}
|
||||
className="btn"
|
||||
>
|
||||
<Plug className="h-4 w-4" aria-hidden="true" />
|
||||
Publish Plugin
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="dashboard-grid">
|
||||
{skills.map((skill) => (
|
||||
<SkillCard key={skill._id} skill={skill} ownerHandle={ownerHandle} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="card dashboard-owner-panel">
|
||||
<div className="dashboard-owner-grid">
|
||||
<section className="dashboard-collection-block">
|
||||
<div className="dashboard-section-header">
|
||||
<div>
|
||||
<h2 className="dashboard-collection-title">Publisher Skills</h2>
|
||||
<p className="section-subtitle" style={{ margin: "6px 0 0" }}>
|
||||
Hidden skill versions remain visible here while checks are pending.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{skills.length === 0 ? (
|
||||
<div className="dashboard-inline-empty">
|
||||
<div className="dashboard-inline-empty-copy">
|
||||
<strong>No skills yet.</strong> Publish your first skill to share it with the community.
|
||||
</div>
|
||||
<Link to="/publish-skill" search={{ updateSlug: undefined }} className="btn btn-primary">
|
||||
<Upload className="h-4 w-4" aria-hidden="true" />
|
||||
Publish Skill
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="dashboard-list">
|
||||
<div className="dashboard-list-header">
|
||||
<span>Skill</span>
|
||||
<span>Summary</span>
|
||||
<span>Status</span>
|
||||
<span>Actions</span>
|
||||
</div>
|
||||
{skills.map((skill) => (
|
||||
<SkillRow key={skill._id} skill={skill} ownerHandle={ownerHandle} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="dashboard-collection-block">
|
||||
<div className="dashboard-section-header">
|
||||
<div>
|
||||
<h2 className="dashboard-collection-title">Publisher Plugins</h2>
|
||||
<p className="section-subtitle" style={{ margin: "6px 0 0" }}>
|
||||
Owner-only package view with VirusTotal, static scan, and verification state.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{packages.length === 0 ? (
|
||||
<div className="dashboard-inline-empty">
|
||||
<div className="dashboard-inline-empty-copy">
|
||||
<strong>No plugins yet.</strong> Publish your first plugin release to validate and distribute it.
|
||||
</div>
|
||||
<Link
|
||||
to="/publish-plugin"
|
||||
search={{ ...emptyPluginPublishSearch, ownerHandle }}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
<Plug className="h-4 w-4" aria-hidden="true" />
|
||||
Publish Plugin
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="dashboard-list">
|
||||
<div className="dashboard-list-header">
|
||||
<span>Plugin</span>
|
||||
<span>Summary</span>
|
||||
<span>Status</span>
|
||||
<span>Actions</span>
|
||||
</div>
|
||||
{packages.map((pkg) => (
|
||||
<PackageRow key={pkg._id} pkg={pkg} ownerHandle={ownerHandle} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillCard({ skill, ownerHandle }: { skill: DashboardSkill; ownerHandle: string | null }) {
|
||||
function SkillRow({ skill, ownerHandle }: { skill: DashboardSkill; ownerHandle: string | null }) {
|
||||
return (
|
||||
<div className="dashboard-skill-card">
|
||||
<div className="dashboard-skill-info">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" }}>
|
||||
<div className="dashboard-list-row">
|
||||
<div className="dashboard-list-primary">
|
||||
<div className="dashboard-list-title">
|
||||
<Link
|
||||
to="/$owner/$slug"
|
||||
params={{ owner: ownerHandle ?? "unknown", slug: skill.slug }}
|
||||
@@ -75,25 +254,44 @@ function SkillCard({ skill, ownerHandle }: { skill: DashboardSkill; ownerHandle:
|
||||
>
|
||||
{skill.displayName}
|
||||
</Link>
|
||||
<span className="dashboard-skill-slug">/{skill.slug}</span>
|
||||
<span className="dashboard-list-id">/{skill.slug}</span>
|
||||
{skill.pendingReview ? (
|
||||
<span className="tag tag-pending">
|
||||
<Clock className="h-3 w-3" aria-hidden="true" />
|
||||
Scanning
|
||||
Pending checks
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{skill.summary && <p className="dashboard-skill-description">{skill.summary}</p>}
|
||||
<div className="dashboard-skill-stats">
|
||||
<div className="dashboard-inline-metrics">
|
||||
<span>
|
||||
<Package size={13} aria-hidden="true" /> {formatCompactStat(skill.stats.downloads)}
|
||||
<ArrowDownToLine size={13} aria-hidden="true" /> {formatCompactStat(skill.stats.downloads)}
|
||||
</span>
|
||||
<span>
|
||||
<Star size={13} aria-hidden="true" /> {formatCompactStat(skill.stats.stars)}
|
||||
</span>
|
||||
<span>
|
||||
<Package size={13} aria-hidden="true" /> {skill.stats.versions}
|
||||
</span>
|
||||
<span>★ {formatCompactStat(skill.stats.stars)}</span>
|
||||
<span>{skill.stats.versions} v</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dashboard-skill-actions">
|
||||
<Link to="/upload" search={{ updateSlug: skill.slug }} className="btn btn-sm">
|
||||
<div className="dashboard-list-summary">{skill.summary ?? "No summary provided."}</div>
|
||||
<div className="dashboard-list-status">
|
||||
{skill.pendingReview ? (
|
||||
<>
|
||||
<span className="dashboard-inline-status-item">
|
||||
<ShieldCheck size={13} aria-hidden="true" />
|
||||
VT pending
|
||||
</span>
|
||||
<span className="dashboard-inline-status-note">
|
||||
Hidden until verification checks finish.
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="dashboard-inline-status-note">Visible</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="dashboard-row-actions">
|
||||
<Link to="/publish-skill" search={{ updateSlug: skill.slug }} className="btn btn-sm">
|
||||
<Upload className="h-3 w-3" aria-hidden="true" />
|
||||
New Version
|
||||
</Link>
|
||||
@@ -108,3 +306,162 @@ function SkillCard({ skill, ownerHandle }: { skill: DashboardSkill; ownerHandle:
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function scanStatusLabel(status: string | null | undefined) {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return "Pending scan";
|
||||
case "clean":
|
||||
return "Scan clean";
|
||||
case "suspicious":
|
||||
return "Suspicious";
|
||||
case "malicious":
|
||||
return "Blocked";
|
||||
case "not-run":
|
||||
return "Scan not run";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function releaseStatusLabel(
|
||||
label: string,
|
||||
status: string | null | undefined,
|
||||
emptyLabel = "not started",
|
||||
) {
|
||||
return `${label}: ${status?.trim() ? status : emptyLabel}`;
|
||||
}
|
||||
|
||||
function PackageStatusTag({
|
||||
label,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
tone: "default" | "pending" | "warning" | "danger" | "success";
|
||||
}) {
|
||||
const className =
|
||||
tone === "pending"
|
||||
? "tag tag-pending"
|
||||
: tone === "warning"
|
||||
? "tag dashboard-tag-warning"
|
||||
: tone === "danger"
|
||||
? "tag dashboard-tag-danger"
|
||||
: tone === "success"
|
||||
? "tag dashboard-tag-success"
|
||||
: "tag";
|
||||
return <span className={className}>{label}</span>;
|
||||
}
|
||||
|
||||
function PackageRow({ pkg, ownerHandle }: { pkg: DashboardPackage; ownerHandle: string }) {
|
||||
const scanLabel = scanStatusLabel(pkg.scanStatus);
|
||||
const nextVersion = pkg.latestVersion ? semver.inc(pkg.latestVersion, "patch") : null;
|
||||
const sourceLabel = pkg.sourceRepo?.replace(/^https?:\/\/github\.com\//, "").replace(/\.git$/, "");
|
||||
const scanTone =
|
||||
pkg.scanStatus === "pending"
|
||||
? "pending"
|
||||
: pkg.scanStatus === "suspicious"
|
||||
? "warning"
|
||||
: pkg.scanStatus === "malicious"
|
||||
? "danger"
|
||||
: pkg.scanStatus === "clean"
|
||||
? "success"
|
||||
: "default";
|
||||
const staticTone =
|
||||
pkg.latestRelease?.staticScanStatus === "suspicious"
|
||||
? "warning"
|
||||
: pkg.latestRelease?.staticScanStatus === "malicious"
|
||||
? "danger"
|
||||
: pkg.latestRelease?.staticScanStatus === "clean"
|
||||
? "success"
|
||||
: "default";
|
||||
|
||||
return (
|
||||
<div className="dashboard-list-row">
|
||||
<div className="dashboard-list-primary">
|
||||
<div className="dashboard-list-title">
|
||||
<Link to="/plugins/$name" params={{ name: pkg.name }} className="dashboard-skill-name">
|
||||
{pkg.displayName}
|
||||
</Link>
|
||||
<span className="dashboard-list-id">{pkg.name}</span>
|
||||
</div>
|
||||
<div className="dashboard-inline-tags">
|
||||
<PackageStatusTag label={familyLabel(pkg.family)} tone="default" />
|
||||
<PackageStatusTag label={pkg.channel} tone="default" />
|
||||
{scanLabel ? <PackageStatusTag label={scanLabel} tone={scanTone} /> : null}
|
||||
{pkg.verification?.tier ? (
|
||||
<PackageStatusTag label={pkg.verification.tier} tone="default" />
|
||||
) : null}
|
||||
{pkg.latestRelease?.staticScanStatus ? (
|
||||
<PackageStatusTag
|
||||
label={`Static ${pkg.latestRelease.staticScanStatus}`}
|
||||
tone={staticTone}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="dashboard-inline-metrics">
|
||||
<span>
|
||||
<ArrowDownToLine size={13} aria-hidden="true" /> {formatCompactStat(pkg.stats.downloads)}
|
||||
</span>
|
||||
<span>
|
||||
<Star size={13} aria-hidden="true" /> {formatCompactStat(pkg.stats.stars)}
|
||||
</span>
|
||||
<span>
|
||||
<Package size={13} aria-hidden="true" /> {pkg.stats.versions}
|
||||
</span>
|
||||
<span>
|
||||
<GitBranch size={13} aria-hidden="true" /> {pkg.latestVersion ?? "No tag"}
|
||||
</span>
|
||||
{pkg.runtimeId ? (
|
||||
<span>
|
||||
<Plug size={13} aria-hidden="true" /> {pkg.runtimeId}
|
||||
</span>
|
||||
) : null}
|
||||
{sourceLabel ? (
|
||||
<span>
|
||||
<ShieldCheck size={13} aria-hidden="true" /> {sourceLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="dashboard-list-summary">{pkg.summary ?? "No summary provided."}</div>
|
||||
<div className="dashboard-list-status">
|
||||
<span className="dashboard-inline-status-item">
|
||||
<ShieldCheck size={13} aria-hidden="true" />{" "}
|
||||
{releaseStatusLabel(
|
||||
"VT",
|
||||
pkg.latestRelease?.vtStatus,
|
||||
pkg.scanStatus === "pending" ? "pending" : "unknown",
|
||||
)}
|
||||
</span>
|
||||
<span className="dashboard-inline-status-item">
|
||||
<CheckCircle2 size={13} aria-hidden="true" />{" "}
|
||||
{releaseStatusLabel("LLM", pkg.latestRelease?.llmStatus)}
|
||||
</span>
|
||||
<span className="dashboard-inline-status-item">
|
||||
<AlertTriangle size={13} aria-hidden="true" />{" "}
|
||||
{releaseStatusLabel("Static", pkg.latestRelease?.staticScanStatus)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="dashboard-row-actions">
|
||||
<Link
|
||||
to="/publish-plugin"
|
||||
search={{
|
||||
ownerHandle,
|
||||
name: pkg.name,
|
||||
displayName: pkg.displayName,
|
||||
family: pkg.family === "bundle-plugin" ? "bundle-plugin" : "code-plugin",
|
||||
nextVersion: nextVersion ?? undefined,
|
||||
sourceRepo: pkg.sourceRepo ?? undefined,
|
||||
}}
|
||||
className="btn btn-sm"
|
||||
>
|
||||
<Upload className="h-3 w-3" aria-hidden="true" />
|
||||
New Release
|
||||
</Link>
|
||||
<Link to="/plugins/$name" params={{ name: pkg.name }} className="btn btn-ghost btn-sm">
|
||||
View
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+18
-1
@@ -1,4 +1,4 @@
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useAction, useQuery } from "convex/react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
@@ -226,6 +226,23 @@ export function ImportGitHub() {
|
||||
<div className="upload-kicker">GitHub import</div>
|
||||
<h1 className="upload-title">Import from GitHub</h1>
|
||||
<p className="upload-subtitle">Public repos only. Detects SKILL.md automatically.</p>
|
||||
<div className="tag tag-accent" style={{ marginTop: 12, width: "fit-content" }}>
|
||||
Skill-only import. Plugins are not supported here. Use{" "}
|
||||
<Link
|
||||
to="/publish-plugin"
|
||||
search={{
|
||||
ownerHandle: undefined,
|
||||
name: undefined,
|
||||
displayName: undefined,
|
||||
family: undefined,
|
||||
nextVersion: undefined,
|
||||
sourceRepo: undefined,
|
||||
}}
|
||||
>
|
||||
Publish Plugin
|
||||
</Link>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
<div className="upload-badge">
|
||||
<div>Public only</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { SoulStatsTripletLine } from "../components/SoulStats";
|
||||
import { UserBadge } from "../components/UserBadge";
|
||||
import { convexHttp } from "../convex/client";
|
||||
import { getSkillBadges } from "../lib/badges";
|
||||
import type { PublicSkill, PublicSoul, PublicUser } from "../lib/publicUser";
|
||||
import type { PublicPublisher, PublicSkill, PublicSoul } from "../lib/publicUser";
|
||||
import { getSiteMode } from "../lib/site";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
@@ -26,7 +26,7 @@ function SkillsHome() {
|
||||
type SkillPageEntry = {
|
||||
skill: PublicSkill;
|
||||
ownerHandle?: string | null;
|
||||
owner?: PublicUser | null;
|
||||
owner?: PublicPublisher | null;
|
||||
latestVersion?: unknown;
|
||||
};
|
||||
|
||||
@@ -69,8 +69,8 @@ function SkillsHome() {
|
||||
vectors. No gatekeeping, just signal.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 12, marginTop: 20 }}>
|
||||
<Link to="/upload" search={{ updateSlug: undefined }} className="btn btn-primary">
|
||||
Publish a skill
|
||||
<Link to="/publish-skill" search={{ updateSlug: undefined }} className="btn btn-primary">
|
||||
Publish Skill
|
||||
</Link>
|
||||
<Link
|
||||
to="/skills"
|
||||
@@ -207,8 +207,8 @@ function OnlyCrabsHome() {
|
||||
public place.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 12, marginTop: 20 }}>
|
||||
<Link to="/upload" search={{ updateSlug: undefined }} className="btn btn-primary">
|
||||
Publish a soul
|
||||
<Link to="/publish-skill" search={{ updateSlug: undefined }} className="btn btn-primary">
|
||||
Publish Soul
|
||||
</Link>
|
||||
<Link
|
||||
to="/souls"
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "convex/react";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import type { PublicPublisher, PublicSkill } from "../../lib/publicUser";
|
||||
import { SkillCard } from "../../components/SkillCard";
|
||||
import { getSkillBadges } from "../../lib/badges";
|
||||
import { SkillStatsTripletLine } from "../../components/SkillStats";
|
||||
|
||||
export const Route = createFileRoute("/orgs/$handle")({
|
||||
component: OrgProfile,
|
||||
});
|
||||
|
||||
function OrgProfile() {
|
||||
const { handle } = Route.useParams();
|
||||
const publisher = useQuery(api.publishers.getByHandle, { handle }) as
|
||||
| PublicPublisher
|
||||
| null
|
||||
| undefined;
|
||||
const members = useQuery(api.publishers.listMembers, { publisherHandle: handle }) as
|
||||
| {
|
||||
publisher: PublicPublisher | null;
|
||||
members: Array<{
|
||||
role: "owner" | "admin" | "publisher";
|
||||
user: {
|
||||
_id: string;
|
||||
handle: string | null;
|
||||
displayName: string | null;
|
||||
image: string | null;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
const skills = useQuery(
|
||||
api.skills.list,
|
||||
publisher ? { ownerPublisherId: publisher._id, limit: 50 } : "skip",
|
||||
) as PublicSkill[] | undefined;
|
||||
|
||||
if (publisher === undefined) {
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="card">Loading org…</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!publisher || publisher.kind !== "org") {
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="card">Organization not found.</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="card settings-profile" style={{ marginBottom: 22 }}>
|
||||
<div className="settings-avatar" aria-hidden="true">
|
||||
{publisher.image ? (
|
||||
<img src={publisher.image} alt="" />
|
||||
) : (
|
||||
<span>{publisher.displayName.charAt(0).toUpperCase()}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="settings-profile-body">
|
||||
<div className="settings-name">{publisher.displayName}</div>
|
||||
<div className="settings-handle">@{publisher.handle}</div>
|
||||
{publisher.bio ? <div className="section-subtitle">{publisher.bio}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="section-title" style={{ fontSize: "1.3rem" }}>
|
||||
Published
|
||||
</h2>
|
||||
{(skills ?? []).length ? (
|
||||
<div className="grid" style={{ marginBottom: 18 }}>
|
||||
{(skills ?? []).map((skill) => (
|
||||
<SkillCard
|
||||
key={skill._id}
|
||||
skill={skill}
|
||||
href={`/${encodeURIComponent(publisher.handle)}/${encodeURIComponent(skill.slug)}`}
|
||||
badge={getSkillBadges(skill)}
|
||||
summaryFallback="Agent-ready skill pack."
|
||||
meta={
|
||||
<div className="stat">
|
||||
<SkillStatsTripletLine stats={skill.stats} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="card">No published skills yet.</div>
|
||||
)}
|
||||
|
||||
<h2 className="section-title" style={{ fontSize: "1.3rem" }}>
|
||||
Members
|
||||
</h2>
|
||||
{(members?.members ?? []).length ? (
|
||||
<div style={{ display: "grid", gap: 10 }}>
|
||||
{members?.members.map((entry) => (
|
||||
<div key={`${entry.user._id}:${entry.role}`} className="card">
|
||||
<strong>{entry.user.displayName ?? entry.user.handle ?? "User"}</strong>
|
||||
<div className="section-subtitle" style={{ margin: "6px 0 0" }}>
|
||||
{entry.user.handle ? (
|
||||
<Link to="/u/$handle" params={{ handle: entry.user.handle }}>
|
||||
@{entry.user.handle}
|
||||
</Link>
|
||||
) : (
|
||||
"user"
|
||||
)}{" "}
|
||||
· {entry.role}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="card">No members listed.</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,150 +1,7 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import {
|
||||
fetchPackageDetail,
|
||||
fetchPackageReadme,
|
||||
fetchPackageVersion,
|
||||
getPackageDownloadPath,
|
||||
type PackageDetailResponse,
|
||||
type PackageVersionDetail,
|
||||
} from "../../lib/packageApi";
|
||||
import { familyLabel, packageCapabilityLabel } from "../../lib/packageLabels";
|
||||
|
||||
type PackageDetailLoaderData = {
|
||||
detail: PackageDetailResponse;
|
||||
version: PackageVersionDetail | null;
|
||||
readme: string | null;
|
||||
};
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/packages/$name")({
|
||||
loader: async ({ params }): Promise<PackageDetailLoaderData> => {
|
||||
const detail = await fetchPackageDetail(params.name);
|
||||
const version =
|
||||
detail.package?.latestVersion
|
||||
? await fetchPackageVersion(params.name, detail.package.latestVersion)
|
||||
: null;
|
||||
const readme = await fetchPackageReadme(params.name, detail.package?.latestVersion);
|
||||
return { detail, version, readme };
|
||||
beforeLoad: ({ params }) => {
|
||||
throw redirect({ to: "/plugins/$name", params });
|
||||
},
|
||||
head: ({ params, loaderData }) => ({
|
||||
meta: [
|
||||
{
|
||||
title: loaderData?.detail.package?.displayName
|
||||
? `${loaderData.detail.package.displayName} · Packages`
|
||||
: params.name,
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
content: loaderData?.detail.package?.summary ?? `Package ${params.name}`,
|
||||
},
|
||||
],
|
||||
}),
|
||||
component: PackageDetailRoute,
|
||||
});
|
||||
|
||||
function PackageDetailRoute() {
|
||||
const { name } = Route.useParams();
|
||||
const { detail, version, readme } = Route.useLoaderData() as PackageDetailLoaderData;
|
||||
|
||||
if (!detail.package) {
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="card">Package not found.</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const pkg = detail.package;
|
||||
const latestRelease = version?.version ?? null;
|
||||
const installSnippet =
|
||||
pkg.family === "code-plugin"
|
||||
? `openclaw plugins install clawhub:${pkg.name}`
|
||||
: pkg.family === "bundle-plugin"
|
||||
? `openclaw bundles install clawhub:${pkg.name}`
|
||||
: `openclaw skills install ${pkg.name}`;
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="skill-detail-stack">
|
||||
<section className="card">
|
||||
<div className="skill-card-tags" style={{ marginBottom: 12 }}>
|
||||
<span className="tag">{familyLabel(pkg.family)}</span>
|
||||
<span className={`tag ${pkg.capabilities?.executesCode ? "tag-accent" : ""}`}>
|
||||
{packageCapabilityLabel(pkg.family, pkg.capabilities?.executesCode)}
|
||||
</span>
|
||||
<span className="tag">{pkg.channel}</span>
|
||||
{pkg.isOfficial ? <span className="tag">Official</span> : null}
|
||||
{pkg.verification?.tier ? <span className="tag">{pkg.verification.tier}</span> : null}
|
||||
</div>
|
||||
<h1 className="section-title" style={{ marginBottom: 8 }}>
|
||||
{pkg.displayName}
|
||||
</h1>
|
||||
<p className="section-subtitle" style={{ marginBottom: 12 }}>
|
||||
{pkg.summary ?? "No summary provided."}
|
||||
</p>
|
||||
{pkg.family === "code-plugin" && !pkg.isOfficial ? (
|
||||
<div className="tag tag-accent" style={{ marginBottom: 12 }}>
|
||||
Community code plugin. Review compatibility and verification before install.
|
||||
</div>
|
||||
) : null}
|
||||
<div className="skills-row-slug" style={{ marginBottom: 12 }}>
|
||||
{pkg.name}
|
||||
{pkg.runtimeId ? ` · runtime id ${pkg.runtimeId}` : ""}
|
||||
</div>
|
||||
<details className="bundle-details" open>
|
||||
<summary>Install</summary>
|
||||
<pre>
|
||||
<code>{installSnippet}</code>
|
||||
</pre>
|
||||
</details>
|
||||
<details className="bundle-details" open>
|
||||
<summary>Latest Release</summary>
|
||||
<div style={{ display: "grid", gap: 8 }}>
|
||||
<div>{pkg.latestVersion ? `Version ${pkg.latestVersion}` : "No latest tag"}</div>
|
||||
{pkg.latestVersion ? (
|
||||
<div>
|
||||
<a href={getPackageDownloadPath(name, pkg.latestVersion)}>Download zip</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
{latestRelease ? (
|
||||
<details className="bundle-details" open>
|
||||
<summary>Compatibility</summary>
|
||||
<pre>
|
||||
<code>{JSON.stringify(latestRelease.compatibility ?? pkg.compatibility ?? {}, null, 2)}</code>
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
{latestRelease ? (
|
||||
<details className="bundle-details" open>
|
||||
<summary>Capabilities</summary>
|
||||
<pre>
|
||||
<code>{JSON.stringify(latestRelease.capabilities ?? pkg.capabilities ?? {}, null, 2)}</code>
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
<details className="bundle-details" open>
|
||||
<summary>Verification</summary>
|
||||
<pre>
|
||||
<code>{JSON.stringify(latestRelease?.verification ?? pkg.verification ?? {}, null, 2)}</code>
|
||||
</pre>
|
||||
</details>
|
||||
<details className="bundle-details" open>
|
||||
<summary>Tags</summary>
|
||||
<pre>
|
||||
<code>{JSON.stringify(pkg.tags, null, 2)}</code>
|
||||
</pre>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
{readme ? (
|
||||
<section className="card">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{readme}</ReactMarkdown>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,246 +1,7 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchPackages, type PackageListItem } from "../../lib/packageApi";
|
||||
import { familyLabel, packageCapabilityLabel } from "../../lib/packageLabels";
|
||||
|
||||
type PackageSearchState = {
|
||||
q?: string;
|
||||
cursor?: string;
|
||||
family?: "skill" | "code-plugin" | "bundle-plugin";
|
||||
official?: boolean;
|
||||
executesCode?: boolean;
|
||||
};
|
||||
|
||||
type PackagesLoaderData = {
|
||||
items: PackageListItem[];
|
||||
nextCursor: string | null;
|
||||
};
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/packages/")({
|
||||
validateSearch: (search): PackageSearchState => ({
|
||||
q: typeof search.q === "string" && search.q.trim() ? search.q.trim() : undefined,
|
||||
cursor: typeof search.cursor === "string" && search.cursor ? search.cursor : undefined,
|
||||
family:
|
||||
search.family === "skill" || search.family === "code-plugin" || search.family === "bundle-plugin"
|
||||
? search.family
|
||||
: undefined,
|
||||
official:
|
||||
search.official === true || search.official === "true" || search.official === "1"
|
||||
? true
|
||||
: undefined,
|
||||
executesCode:
|
||||
search.executesCode === true ||
|
||||
search.executesCode === "true" ||
|
||||
search.executesCode === "1"
|
||||
? true
|
||||
: undefined,
|
||||
}),
|
||||
loaderDeps: ({ search }) => search,
|
||||
loader: async ({ deps }) => {
|
||||
const data = await fetchPackages({
|
||||
q: deps.q,
|
||||
cursor: deps.q ? undefined : deps.cursor,
|
||||
family: deps.family,
|
||||
isOfficial: deps.official,
|
||||
executesCode: deps.executesCode,
|
||||
limit: 50,
|
||||
});
|
||||
const items = "results" in data ? data.results.map((entry) => entry.package) : data.items;
|
||||
return {
|
||||
items,
|
||||
nextCursor: "results" in data ? null : data.nextCursor,
|
||||
} satisfies PackagesLoaderData;
|
||||
beforeLoad: ({ search }) => {
|
||||
throw redirect({ to: "/plugins", search });
|
||||
},
|
||||
component: PackagesIndex,
|
||||
});
|
||||
|
||||
export function PackagesIndex() {
|
||||
const search = Route.useSearch();
|
||||
const navigate = Route.useNavigate();
|
||||
const { items, nextCursor } = Route.useLoaderData() as PackagesLoaderData;
|
||||
const [query, setQuery] = useState(search.q ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
setQuery(search.q ?? "");
|
||||
}, [search.q]);
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<header className="skills-header-top">
|
||||
<h1 className="section-title" style={{ marginBottom: 8 }}>
|
||||
Packages
|
||||
</h1>
|
||||
<p className="section-subtitle" style={{ marginBottom: 0 }}>
|
||||
Unified OpenClaw catalog: skills, code plugins, bundle plugins.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="card" style={{ display: "grid", gap: 12, marginBottom: 18 }}>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
q: query.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
style={{ display: "grid", gap: 12 }}
|
||||
>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Search packages"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
|
||||
<select
|
||||
className="input"
|
||||
value={search.family ?? ""}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value as PackageSearchState["family"] | "";
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
family: value || undefined,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="">All families</option>
|
||||
<option value="skill">Skills</option>
|
||||
<option value="code-plugin">Code plugins</option>
|
||||
<option value="bundle-plugin">Bundle plugins</option>
|
||||
</select>
|
||||
<label className="tag" style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={search.official ?? false}
|
||||
onChange={(event) => {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
official: event.target.checked || undefined,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
Official only
|
||||
</label>
|
||||
<label className="tag" style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={search.executesCode ?? false}
|
||||
onChange={(event) => {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
executesCode: event.target.checked || undefined,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
Executes code
|
||||
</label>
|
||||
<Link className="btn" to="/upload" search={{ updateSlug: undefined }}>
|
||||
Publish Skill
|
||||
</Link>
|
||||
<Link className="btn" to="/packages/new">
|
||||
Publish Plugin
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="card">No packages match that filter.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid">
|
||||
{items.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
to="/packages/$name"
|
||||
params={{ name: item.name }}
|
||||
className="skill-card"
|
||||
>
|
||||
<div className="skill-card-tags">
|
||||
<span className="tag">{familyLabel(item.family)}</span>
|
||||
<span className={`tag ${item.executesCode ? "tag-accent" : ""}`}>
|
||||
{packageCapabilityLabel(item.family, item.executesCode)}
|
||||
</span>
|
||||
{item.isOfficial ? <span className="tag">Official</span> : null}
|
||||
{item.verificationTier ? <span className="tag">{item.verificationTier}</span> : null}
|
||||
</div>
|
||||
<div className="skill-card-title">{item.displayName}</div>
|
||||
<div className="skills-row-slug">{item.name}</div>
|
||||
<div className="skill-card-summary">
|
||||
{item.summary ?? "No summary provided."}
|
||||
</div>
|
||||
<div className="skill-card-footer skill-card-footer-rows">
|
||||
<div className="stat">Channel: {item.channel}</div>
|
||||
<div className="stat">
|
||||
{item.ownerHandle ? `by ${item.ownerHandle}` : "community package"}
|
||||
</div>
|
||||
<div className="stat">
|
||||
{item.latestVersion ? `v${item.latestVersion}` : "No releases yet"}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{!search.q && (search.cursor || nextCursor) ? (
|
||||
<div
|
||||
className="card"
|
||||
style={{ display: "flex", gap: 12, flexWrap: "wrap", justifyContent: "space-between", marginTop: 18 }}
|
||||
>
|
||||
<div className="section-subtitle" style={{ margin: 0 }}>
|
||||
Browsing {items.length} package{items.length === 1 ? "" : "s"} per page.
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
|
||||
{search.cursor ? (
|
||||
<button
|
||||
className="btn"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
First page
|
||||
</button>
|
||||
) : null}
|
||||
{nextCursor ? (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: nextCursor,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
Next page
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
+14
-235
@@ -1,238 +1,17 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useAction, useMutation } from "convex/react";
|
||||
import { startTransition, useMemo, useState } from "react";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import { expandDroppedItems, expandFilesWithReport } from "../../lib/uploadFiles";
|
||||
import { buildPackageUploadEntries, filterIgnoredPackageFiles } from "../../lib/packageUpload";
|
||||
import { useAuthStatus } from "../../lib/useAuthStatus";
|
||||
import { formatBytes, formatPublishError, hashFile, uploadFile } from "../upload/-utils";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/packages/new")({
|
||||
component: PublishPackageRoute,
|
||||
});
|
||||
|
||||
const apiRefs = api as unknown as {
|
||||
packages: {
|
||||
publishRelease: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
function PublishPackageRoute() {
|
||||
const { isAuthenticated } = useAuthStatus();
|
||||
const generateUploadUrl = useMutation(api.uploads.generateUploadUrl);
|
||||
const publishRelease = useAction(apiRefs.packages.publishRelease as never) as unknown as (
|
||||
args: { payload: unknown },
|
||||
) => Promise<unknown>;
|
||||
const [family, setFamily] = useState<"code-plugin" | "bundle-plugin">("code-plugin");
|
||||
const [name, setName] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [version, setVersion] = useState("0.1.0");
|
||||
const [changelog, setChangelog] = useState("");
|
||||
const [sourceRepo, setSourceRepo] = useState("");
|
||||
const [sourceCommit, setSourceCommit] = useState("");
|
||||
const [sourceRef, setSourceRef] = useState("");
|
||||
const [sourcePath, setSourcePath] = useState(".");
|
||||
const [bundleFormat, setBundleFormat] = useState("");
|
||||
const [hostTargets, setHostTargets] = useState("");
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [ignoredPaths, setIgnoredPaths] = useState<string[]>([]);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const totalBytes = useMemo(() => files.reduce((sum, file) => sum + file.size, 0), [files]);
|
||||
|
||||
const onPickFiles = async (selected: File[]) => {
|
||||
const expanded = await expandFilesWithReport(selected, {
|
||||
includeBinaryArchiveFiles: true,
|
||||
beforeLoad: () => {
|
||||
throw redirect({
|
||||
to: "/publish-plugin",
|
||||
search: {
|
||||
ownerHandle: undefined,
|
||||
name: undefined,
|
||||
displayName: undefined,
|
||||
family: undefined,
|
||||
nextVersion: undefined,
|
||||
sourceRepo: undefined,
|
||||
},
|
||||
});
|
||||
const filtered = await filterIgnoredPackageFiles(expanded.files);
|
||||
const nextIgnoredPaths = [...new Set([...expanded.ignoredMacJunkPaths, ...filtered.ignoredPaths])];
|
||||
setFiles(filtered.files);
|
||||
setIgnoredPaths(nextIgnoredPaths);
|
||||
setError(null);
|
||||
|
||||
const packageJson = filtered.files.find((file) => file.name.toLowerCase().endsWith("package.json"));
|
||||
if (!packageJson) return;
|
||||
try {
|
||||
const text = await packageJson.text();
|
||||
const parsed = JSON.parse(text) as Record<string, unknown>;
|
||||
if (typeof parsed.name === "string") setName(parsed.name);
|
||||
if (typeof parsed.displayName === "string") setDisplayName(parsed.displayName);
|
||||
if (typeof parsed.version === "string") setVersion(parsed.version);
|
||||
} catch {
|
||||
// ignore invalid package.json during form-prefill
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<header className="skills-header-top">
|
||||
<h1 className="section-title" style={{ marginBottom: 8 }}>
|
||||
Publish Plugin
|
||||
</h1>
|
||||
<p className="section-subtitle" style={{ marginBottom: 0 }}>
|
||||
Upload a native code plugin or bundle plugin release.
|
||||
</p>
|
||||
</header>
|
||||
<div className="card" style={{ display: "grid", gap: 12 }}>
|
||||
{!isAuthenticated ? <div>Log in to publish packages.</div> : null}
|
||||
<select className="input" value={family} onChange={(event) => setFamily(event.target.value as never)}>
|
||||
<option value="code-plugin">Code plugin</option>
|
||||
<option value="bundle-plugin">Bundle plugin</option>
|
||||
</select>
|
||||
<input className="input" placeholder="Package name" value={name} onChange={(event) => setName(event.target.value)} />
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Display name"
|
||||
value={displayName}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
/>
|
||||
<input className="input" placeholder="Version" value={version} onChange={(event) => setVersion(event.target.value)} />
|
||||
<textarea
|
||||
className="input"
|
||||
placeholder="Changelog"
|
||||
rows={4}
|
||||
value={changelog}
|
||||
onChange={(event) => setChangelog(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Source repo (owner/repo)"
|
||||
value={sourceRepo}
|
||||
onChange={(event) => setSourceRepo(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Source commit"
|
||||
value={sourceCommit}
|
||||
onChange={(event) => setSourceCommit(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Source ref (tag or branch)"
|
||||
value={sourceRef}
|
||||
onChange={(event) => setSourceRef(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Source path"
|
||||
value={sourcePath}
|
||||
onChange={(event) => setSourcePath(event.target.value)}
|
||||
/>
|
||||
{family === "bundle-plugin" ? (
|
||||
<>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Bundle format"
|
||||
value={bundleFormat}
|
||||
onChange={(event) => setBundleFormat(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Host targets (comma separated)"
|
||||
value={hostTargets}
|
||||
onChange={(event) => setHostTargets(event.target.value)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<input
|
||||
className="input"
|
||||
type="file"
|
||||
multiple
|
||||
// @ts-expect-error non-standard directory picker
|
||||
webkitdirectory=""
|
||||
onChange={(event) => {
|
||||
const selected = Array.from(event.target.files ?? []);
|
||||
void onPickFiles(selected);
|
||||
}}
|
||||
/>
|
||||
<div className="tag">{files.length} files · {formatBytes(totalBytes)}</div>
|
||||
{ignoredPaths.length > 0 ? <div className="tag">Ignored {ignoredPaths.length} files via ignore rules.</div> : null}
|
||||
<button
|
||||
className="btn"
|
||||
type="button"
|
||||
disabled={
|
||||
!isAuthenticated ||
|
||||
!name.trim() ||
|
||||
!version.trim() ||
|
||||
files.length === 0 ||
|
||||
Boolean(status) ||
|
||||
(family === "code-plugin" && (!sourceRepo.trim() || !sourceCommit.trim()))
|
||||
}
|
||||
onClick={() => {
|
||||
startTransition(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
setStatus("Uploading files…");
|
||||
setError(null);
|
||||
const uploaded = await buildPackageUploadEntries(files, {
|
||||
generateUploadUrl,
|
||||
hashFile,
|
||||
uploadFile,
|
||||
});
|
||||
setStatus("Publishing release…");
|
||||
await publishRelease({
|
||||
payload: {
|
||||
name: name.trim(),
|
||||
displayName: displayName.trim() || undefined,
|
||||
family,
|
||||
version: version.trim(),
|
||||
changelog: changelog.trim(),
|
||||
...(sourceRepo.trim() && sourceCommit.trim()
|
||||
? {
|
||||
source: {
|
||||
kind: "github" as const,
|
||||
repo: sourceRepo.trim(),
|
||||
url: sourceRepo.trim().startsWith("http")
|
||||
? sourceRepo.trim()
|
||||
: `https://github.com/${sourceRepo.trim().replace(/^\/+|\/+$/g, "")}`,
|
||||
ref: sourceRef.trim() || sourceCommit.trim(),
|
||||
commit: sourceCommit.trim(),
|
||||
path: sourcePath.trim() || ".",
|
||||
importedAt: Date.now(),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(family === "bundle-plugin"
|
||||
? {
|
||||
bundle: {
|
||||
format: bundleFormat.trim() || undefined,
|
||||
hostTargets: hostTargets
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
files: uploaded,
|
||||
},
|
||||
});
|
||||
setStatus("Published.");
|
||||
} catch (publishError) {
|
||||
setError(formatPublishError(publishError));
|
||||
setStatus(null);
|
||||
}
|
||||
})();
|
||||
});
|
||||
}}
|
||||
>
|
||||
{status ?? "Publish"}
|
||||
</button>
|
||||
{error ? <div className="tag tag-accent">{error}</div> : null}
|
||||
</div>
|
||||
<div
|
||||
className="card"
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
void (async () => {
|
||||
const dropped = await expandDroppedItems(event.dataTransfer.items);
|
||||
await onPickFiles(dropped);
|
||||
})();
|
||||
}}
|
||||
>
|
||||
Drop a plugin folder, zip, or tgz here.
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { SecurityScanResults } from "../../components/SkillSecurityScanResults";
|
||||
import {
|
||||
fetchPackageDetail,
|
||||
fetchPackageReadme,
|
||||
fetchPackageVersion,
|
||||
getPackageDownloadPath,
|
||||
type PackageDetailResponse,
|
||||
type PackageVersionDetail,
|
||||
} from "../../lib/packageApi";
|
||||
import { familyLabel, packageCapabilityLabel } from "../../lib/packageLabels";
|
||||
|
||||
type PluginDetailLoaderData = {
|
||||
detail: PackageDetailResponse;
|
||||
version: PackageVersionDetail | null;
|
||||
readme: string | null;
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/plugins/$name")({
|
||||
loader: async ({ params }): Promise<PluginDetailLoaderData> => {
|
||||
const readmePromise = fetchPackageReadme(params.name);
|
||||
const detail = await fetchPackageDetail(params.name);
|
||||
const versionPromise = detail.package?.latestVersion
|
||||
? fetchPackageVersion(params.name, detail.package.latestVersion)
|
||||
: Promise.resolve(null);
|
||||
const [version, readme] = await Promise.all([versionPromise, readmePromise]);
|
||||
return { detail, version, readme };
|
||||
},
|
||||
head: ({ params, loaderData }) => ({
|
||||
meta: [
|
||||
{
|
||||
title: loaderData?.detail.package?.displayName
|
||||
? `${loaderData.detail.package.displayName} · Plugins`
|
||||
: params.name,
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
content: loaderData?.detail.package?.summary ?? `Plugin ${params.name}`,
|
||||
},
|
||||
],
|
||||
}),
|
||||
component: PluginDetailRoute,
|
||||
});
|
||||
|
||||
function VerifiedBadge() {
|
||||
return (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 6, color: "#3b82f6" }}>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="Verified publisher"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<path
|
||||
d="M8 0L9.79 1.52L12.12 1.21L12.93 3.41L15.01 4.58L14.42 6.84L15.56 8.82L14.12 10.5L14.12 12.82L11.86 13.41L10.34 15.27L8 14.58L5.66 15.27L4.14 13.41L1.88 12.82L1.88 10.5L0.44 8.82L1.58 6.84L0.99 4.58L3.07 3.41L3.88 1.21L6.21 1.52L8 0Z"
|
||||
fill="#3b82f6"
|
||||
/>
|
||||
<path
|
||||
d="M5.5 8L7 9.5L10.5 6"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
Verified
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginDetailRoute() {
|
||||
const { name } = Route.useParams();
|
||||
const { detail, version, readme } = Route.useLoaderData() as PluginDetailLoaderData;
|
||||
|
||||
if (!detail.package) {
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="card">Plugin not found.</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const pkg = detail.package;
|
||||
const latestRelease = version?.version ?? null;
|
||||
const installSnippet =
|
||||
pkg.family === "code-plugin"
|
||||
? `openclaw plugins install clawhub:${pkg.name}`
|
||||
: pkg.family === "bundle-plugin"
|
||||
? `openclaw bundles install clawhub:${pkg.name}`
|
||||
: `openclaw skills install ${pkg.name}`;
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="skill-detail-stack">
|
||||
<section className="card">
|
||||
<div className="skill-card-tags" style={{ marginBottom: 12 }}>
|
||||
<span className="tag">{familyLabel(pkg.family)}</span>
|
||||
{pkg.capabilities?.executesCode ? (
|
||||
<span className="tag tag-accent">
|
||||
{packageCapabilityLabel(pkg.family, pkg.capabilities.executesCode)}
|
||||
</span>
|
||||
) : null}
|
||||
{pkg.isOfficial ? (
|
||||
<span className="tag" style={{ background: "rgba(59, 130, 246, 0.15)", color: "#3b82f6" }}>
|
||||
<VerifiedBadge />
|
||||
</span>
|
||||
) : null}
|
||||
{pkg.verification?.tier ? <span className="tag">{pkg.verification.tier}</span> : null}
|
||||
</div>
|
||||
<h1 className="section-title" style={{ marginBottom: 8 }}>
|
||||
{pkg.displayName}
|
||||
</h1>
|
||||
<p className="section-subtitle" style={{ marginBottom: 12 }}>
|
||||
{pkg.summary ?? "No summary provided."}
|
||||
</p>
|
||||
{pkg.family === "code-plugin" && !pkg.isOfficial ? (
|
||||
<div className="tag tag-accent" style={{ marginBottom: 12 }}>
|
||||
Community code plugin. Review compatibility and verification before install.
|
||||
</div>
|
||||
) : null}
|
||||
<div className="skills-row-slug" style={{ marginBottom: 12 }}>
|
||||
{pkg.name}
|
||||
{pkg.runtimeId ? ` · runtime id ${pkg.runtimeId}` : ""}
|
||||
</div>
|
||||
<details className="bundle-details" open>
|
||||
<summary>Install</summary>
|
||||
<pre>
|
||||
<code>{installSnippet}</code>
|
||||
</pre>
|
||||
</details>
|
||||
<details className="bundle-details" open>
|
||||
<summary>Latest Release</summary>
|
||||
<div style={{ display: "grid", gap: 8 }}>
|
||||
<div>{pkg.latestVersion ? `Version ${pkg.latestVersion}` : "No latest tag"}</div>
|
||||
{pkg.latestVersion ? (
|
||||
<div>
|
||||
<a href={getPackageDownloadPath(name, pkg.latestVersion)}>Download zip</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
{latestRelease ? (
|
||||
<details className="bundle-details" open>
|
||||
<summary>Compatibility</summary>
|
||||
<pre>
|
||||
<code>{JSON.stringify(latestRelease.compatibility ?? pkg.compatibility ?? {}, null, 2)}</code>
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
{latestRelease ? (
|
||||
<details className="bundle-details" open>
|
||||
<summary>Capabilities</summary>
|
||||
<pre>
|
||||
<code>{JSON.stringify(latestRelease.capabilities ?? pkg.capabilities ?? {}, null, 2)}</code>
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
{latestRelease ? (
|
||||
<SecurityScanResults
|
||||
sha256hash={latestRelease.sha256hash ?? undefined}
|
||||
vtAnalysis={latestRelease.vtAnalysis ?? undefined}
|
||||
llmAnalysis={latestRelease.llmAnalysis ?? undefined}
|
||||
staticFindings={latestRelease.staticScan?.findings ?? []}
|
||||
/>
|
||||
) : null}
|
||||
<details className="bundle-details" open>
|
||||
<summary>Verification</summary>
|
||||
<pre>
|
||||
<code>{JSON.stringify(latestRelease?.verification ?? pkg.verification ?? {}, null, 2)}</code>
|
||||
</pre>
|
||||
</details>
|
||||
<details className="bundle-details" open>
|
||||
<summary>Tags</summary>
|
||||
<pre>
|
||||
<code>{JSON.stringify(pkg.tags, null, 2)}</code>
|
||||
</pre>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
{readme ? (
|
||||
<section className="card">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{readme}</ReactMarkdown>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchPluginCatalog, type PackageListItem } from "../../lib/packageApi";
|
||||
import { familyLabel } from "../../lib/packageLabels";
|
||||
|
||||
type PluginSearchState = {
|
||||
q?: string;
|
||||
cursor?: string;
|
||||
family?: "code-plugin" | "bundle-plugin";
|
||||
verified?: boolean;
|
||||
executesCode?: boolean;
|
||||
};
|
||||
|
||||
type PluginsLoaderData = {
|
||||
items: PackageListItem[];
|
||||
nextCursor: string | null;
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/plugins/")({
|
||||
validateSearch: (search): PluginSearchState => ({
|
||||
q: typeof search.q === "string" && search.q.trim() ? search.q.trim() : undefined,
|
||||
cursor: typeof search.cursor === "string" && search.cursor ? search.cursor : undefined,
|
||||
family:
|
||||
search.family === "code-plugin" || search.family === "bundle-plugin"
|
||||
? search.family
|
||||
: undefined,
|
||||
verified:
|
||||
search.verified === true || search.verified === "true" || search.verified === "1"
|
||||
? true
|
||||
: undefined,
|
||||
executesCode:
|
||||
search.executesCode === true ||
|
||||
search.executesCode === "true" ||
|
||||
search.executesCode === "1"
|
||||
? true
|
||||
: undefined,
|
||||
}),
|
||||
loaderDeps: ({ search }) => search,
|
||||
loader: async ({ deps }) => {
|
||||
const data = await fetchPluginCatalog({
|
||||
q: deps.q,
|
||||
cursor: deps.q ? undefined : deps.cursor,
|
||||
family: deps.family,
|
||||
isOfficial: deps.verified,
|
||||
executesCode: deps.executesCode,
|
||||
limit: 50,
|
||||
});
|
||||
return {
|
||||
items: data.items,
|
||||
nextCursor: data.nextCursor,
|
||||
} satisfies PluginsLoaderData;
|
||||
},
|
||||
component: PluginsIndex,
|
||||
});
|
||||
|
||||
function VerifiedBadge() {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="Verified publisher"
|
||||
style={{ display: "inline-block", verticalAlign: "middle", flexShrink: 0 }}
|
||||
>
|
||||
<path
|
||||
d="M8 0L9.79 1.52L12.12 1.21L12.93 3.41L15.01 4.58L14.42 6.84L15.56 8.82L14.12 10.5L14.12 12.82L11.86 13.41L10.34 15.27L8 14.58L5.66 15.27L4.14 13.41L1.88 12.82L1.88 10.5L0.44 8.82L1.58 6.84L0.99 4.58L3.07 3.41L3.88 1.21L6.21 1.52L8 0Z"
|
||||
fill="#3b82f6"
|
||||
/>
|
||||
<path
|
||||
d="M5.5 8L7 9.5L10.5 6"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PluginsIndex() {
|
||||
const search = Route.useSearch();
|
||||
const navigate = Route.useNavigate();
|
||||
const { items, nextCursor } = Route.useLoaderData() as PluginsLoaderData;
|
||||
const [query, setQuery] = useState(search.q ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
setQuery(search.q ?? "");
|
||||
}, [search.q]);
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<header className="skills-header-top">
|
||||
<h1 className="section-title" style={{ marginBottom: 8 }}>
|
||||
Plugins
|
||||
</h1>
|
||||
<p className="section-subtitle" style={{ marginBottom: 0 }}>
|
||||
Browse the plugin catalog.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form
|
||||
className="skills-toolbar"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
q: query.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="skills-search">
|
||||
<input
|
||||
className="skills-search-input"
|
||||
placeholder="Search plugins…"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="skills-toolbar-row">
|
||||
<select
|
||||
className="skills-sort"
|
||||
value={search.family ?? ""}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value as PluginSearchState["family"] | "";
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
q: query.trim() || undefined,
|
||||
family: value || undefined,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
aria-label="Filter by type"
|
||||
>
|
||||
<option value="">All plugins</option>
|
||||
<option value="code-plugin">Code plugins</option>
|
||||
<option value="bundle-plugin">Bundle plugins</option>
|
||||
</select>
|
||||
<button
|
||||
className="search-filter-button"
|
||||
type="button"
|
||||
aria-pressed={search.verified ?? false}
|
||||
onClick={() => {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
q: query.trim() || undefined,
|
||||
verified: prev.verified ? undefined : true,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
Verified
|
||||
</button>
|
||||
<button
|
||||
className="search-filter-button"
|
||||
type="button"
|
||||
aria-pressed={search.executesCode ?? false}
|
||||
onClick={() => {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
q: query.trim() || undefined,
|
||||
executesCode: prev.executesCode ? undefined : true,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
Executes code
|
||||
</button>
|
||||
<Link
|
||||
className="btn btn-primary"
|
||||
to="/publish-plugin"
|
||||
search={{
|
||||
ownerHandle: undefined,
|
||||
name: undefined,
|
||||
displayName: undefined,
|
||||
family: undefined,
|
||||
nextVersion: undefined,
|
||||
sourceRepo: undefined,
|
||||
}}
|
||||
>
|
||||
Publish Plugin
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="card">No plugins match that filter.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid">
|
||||
{items.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
to="/plugins/$name"
|
||||
params={{ name: item.name }}
|
||||
className="card skill-card"
|
||||
>
|
||||
<div className="skill-card-tags">
|
||||
<span className="tag tag-compact">{familyLabel(item.family)}</span>
|
||||
{item.isOfficial ? (
|
||||
<span className="tag tag-compact tag-accent">
|
||||
<VerifiedBadge /> Verified
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<h3 className="skill-card-title">{item.displayName}</h3>
|
||||
<p className="skill-card-summary">
|
||||
{item.summary ?? "No summary provided."}
|
||||
</p>
|
||||
<div className="skill-card-footer" style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span className="stat">
|
||||
{item.ownerHandle ? `by ${item.ownerHandle}` : "community"}
|
||||
</span>
|
||||
{item.latestVersion ? (
|
||||
<span className="stat">v{item.latestVersion}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{!search.q && (search.cursor || nextCursor) ? (
|
||||
<div
|
||||
style={{ display: "flex", gap: 12, justifyContent: "center", marginTop: 22 }}
|
||||
>
|
||||
{search.cursor ? (
|
||||
<button
|
||||
className="btn"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
First page
|
||||
</button>
|
||||
) : null}
|
||||
{nextCursor ? (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: nextCursor,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
Next page
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/plugins/new")({
|
||||
validateSearch: (search) => ({
|
||||
ownerHandle: typeof search.ownerHandle === "string" ? search.ownerHandle : undefined,
|
||||
name: typeof search.name === "string" ? search.name : undefined,
|
||||
displayName: typeof search.displayName === "string" ? search.displayName : undefined,
|
||||
family:
|
||||
search.family === "code-plugin" || search.family === "bundle-plugin"
|
||||
? search.family
|
||||
: undefined,
|
||||
nextVersion: typeof search.nextVersion === "string" ? search.nextVersion : undefined,
|
||||
sourceRepo: typeof search.sourceRepo === "string" ? search.sourceRepo : undefined,
|
||||
}),
|
||||
beforeLoad: ({ search }) => {
|
||||
throw redirect({
|
||||
to: "/publish-plugin",
|
||||
search,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,607 @@
|
||||
import { createFileRoute, useSearch } from "@tanstack/react-router";
|
||||
import { Package } from "lucide-react";
|
||||
import { useAction, useMutation, useQuery } from "convex/react";
|
||||
import { startTransition, useEffect, useMemo, useRef, useState } from "react";
|
||||
import semver from "semver";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import {
|
||||
MAX_PUBLISH_FILE_BYTES,
|
||||
MAX_PUBLISH_TOTAL_BYTES,
|
||||
} from "../../convex/lib/publishLimits";
|
||||
import {
|
||||
buildPackageUploadEntries,
|
||||
filterIgnoredPackageFiles,
|
||||
normalizePackageUploadFiles,
|
||||
} from "../lib/packageUpload";
|
||||
import { expandDroppedItems, expandFilesWithReport } from "../lib/uploadFiles";
|
||||
import { useAuthStatus } from "../lib/useAuthStatus";
|
||||
import { formatBytes, formatPublishError, hashFile, uploadFile } from "./upload/-utils";
|
||||
|
||||
export const Route = createFileRoute("/publish-plugin")({
|
||||
validateSearch: (search) => ({
|
||||
ownerHandle: typeof search.ownerHandle === "string" ? search.ownerHandle : undefined,
|
||||
name: typeof search.name === "string" ? search.name : undefined,
|
||||
displayName: typeof search.displayName === "string" ? search.displayName : undefined,
|
||||
family:
|
||||
search.family === "code-plugin" || search.family === "bundle-plugin"
|
||||
? search.family
|
||||
: undefined,
|
||||
nextVersion: typeof search.nextVersion === "string" ? search.nextVersion : undefined,
|
||||
sourceRepo: typeof search.sourceRepo === "string" ? search.sourceRepo : undefined,
|
||||
}),
|
||||
component: PublishPluginRoute,
|
||||
});
|
||||
|
||||
const apiRefs = api as unknown as {
|
||||
packages: {
|
||||
publishRelease: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
function PublishPluginRoute() {
|
||||
const search = useSearch({ from: "/publish-plugin" });
|
||||
const { isAuthenticated } = useAuthStatus();
|
||||
const publishers = useQuery(api.publishers.listMine) as
|
||||
| Array<{
|
||||
publisher: {
|
||||
_id: string;
|
||||
handle: string;
|
||||
displayName: string;
|
||||
kind: "user" | "org";
|
||||
};
|
||||
role: "owner" | "admin" | "publisher";
|
||||
}>
|
||||
| undefined;
|
||||
const generateUploadUrl = useMutation(api.uploads.generateUploadUrl);
|
||||
const publishRelease = useAction(apiRefs.packages.publishRelease as never) as unknown as (
|
||||
args: { payload: unknown },
|
||||
) => Promise<unknown>;
|
||||
const [family, setFamily] = useState<"code-plugin" | "bundle-plugin">(
|
||||
search.family === "bundle-plugin" ? "bundle-plugin" : "code-plugin",
|
||||
);
|
||||
const [name, setName] = useState(search.name ?? "");
|
||||
const [displayName, setDisplayName] = useState(search.displayName ?? "");
|
||||
const [ownerHandle, setOwnerHandle] = useState(search.ownerHandle ?? "");
|
||||
const [version, setVersion] = useState(search.nextVersion ?? "0.1.0");
|
||||
const [changelog, setChangelog] = useState("");
|
||||
const [sourceRepo, setSourceRepo] = useState(search.sourceRepo ?? "");
|
||||
const [sourceCommit, setSourceCommit] = useState("");
|
||||
const [sourceRef, setSourceRef] = useState("");
|
||||
const [sourcePath, setSourcePath] = useState(".");
|
||||
const [bundleFormat, setBundleFormat] = useState("");
|
||||
const [hostTargets, setHostTargets] = useState("");
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [ignoredPaths, setIgnoredPaths] = useState<string[]>([]);
|
||||
const [detectedPrefillFields, setDetectedPrefillFields] = useState<string[]>([]);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const archiveInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const directoryInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const setDirectoryInputRef = (node: HTMLInputElement | null) => {
|
||||
directoryInputRef.current = node;
|
||||
if (node) {
|
||||
node.setAttribute("webkitdirectory", "");
|
||||
node.setAttribute("directory", "");
|
||||
}
|
||||
};
|
||||
|
||||
const totalBytes = useMemo(() => files.reduce((sum, file) => sum + file.size, 0), [files]);
|
||||
const normalizedPaths = useMemo(
|
||||
() => normalizePackageUploadFiles(files).map((entry) => entry.path),
|
||||
[files],
|
||||
);
|
||||
const normalizedPathSet = useMemo(
|
||||
() => new Set(normalizedPaths.map((path) => path.toLowerCase())),
|
||||
[normalizedPaths],
|
||||
);
|
||||
const oversizedFiles = useMemo(
|
||||
() => files.filter((file) => file.size > MAX_PUBLISH_FILE_BYTES),
|
||||
[files],
|
||||
);
|
||||
const oversizedFileNames = useMemo(
|
||||
() => oversizedFiles.slice(0, 3).map((file) => file.name),
|
||||
[oversizedFiles],
|
||||
);
|
||||
const validationError =
|
||||
oversizedFiles.length > 0
|
||||
? `Each file must be 10MB or smaller: ${oversizedFileNames.join(", ")}`
|
||||
: totalBytes > MAX_PUBLISH_TOTAL_BYTES
|
||||
? "Total file size exceeds 50MB."
|
||||
: null;
|
||||
const isMetadataLocked = files.length === 0;
|
||||
const isSubmitting = status !== null;
|
||||
const metadataDisabled = isMetadataLocked || isSubmitting;
|
||||
|
||||
const onPickFiles = async (selected: File[]) => {
|
||||
const expanded = await expandFilesWithReport(selected, {
|
||||
includeBinaryArchiveFiles: true,
|
||||
});
|
||||
const filtered = await filterIgnoredPackageFiles(expanded.files);
|
||||
const normalized = normalizePackageUploadFiles(filtered.files);
|
||||
const nextIgnoredPaths = [...new Set([...expanded.ignoredMacJunkPaths, ...filtered.ignoredPaths])];
|
||||
setFiles(filtered.files);
|
||||
setIgnoredPaths(nextIgnoredPaths);
|
||||
setError(null);
|
||||
const prefill = await derivePluginPrefill(normalized);
|
||||
setDetectedPrefillFields(listPrefilledFields(prefill));
|
||||
if (prefill.family) setFamily(prefill.family);
|
||||
if (prefill.name) setName(prefill.name);
|
||||
if (prefill.displayName) setDisplayName(prefill.displayName);
|
||||
if (prefill.version) setVersion(prefill.version);
|
||||
if (prefill.sourceRepo) setSourceRepo(prefill.sourceRepo);
|
||||
if (prefill.bundleFormat) setBundleFormat(prefill.bundleFormat);
|
||||
if (prefill.hostTargets) setHostTargets(prefill.hostTargets);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (ownerHandle) return;
|
||||
const personal = publishers?.find((entry) => entry.publisher.kind === "user") ?? publishers?.[0];
|
||||
if (personal?.publisher.handle) {
|
||||
setOwnerHandle(personal.publisher.handle);
|
||||
}
|
||||
}, [ownerHandle, publishers]);
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<header className="skills-header-top">
|
||||
<h1 className="section-title" style={{ marginBottom: 8 }}>
|
||||
{search.name ? "Publish Plugin Release" : "Publish Plugin"}
|
||||
</h1>
|
||||
<p className="section-subtitle" style={{ marginBottom: 0 }}>
|
||||
Publish a native code plugin or bundle plugin release.
|
||||
</p>
|
||||
<p className="section-subtitle" style={{ marginBottom: 0 }}>
|
||||
New releases stay private until automated security checks and verification finish.
|
||||
</p>
|
||||
{search.name ? (
|
||||
<p className="section-subtitle" style={{ marginBottom: 0 }}>
|
||||
Prefilled for {search.displayName ?? search.name}
|
||||
{search.nextVersion && semver.valid(search.nextVersion) ? ` · suggested ${search.nextVersion}` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<div className="card upload-panel">
|
||||
<div
|
||||
className={`upload-dropzone${isDragging ? " is-dragging" : ""}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(event) => {
|
||||
if ((event.target as HTMLElement | null)?.closest("button")) return;
|
||||
archiveInputRef.current?.click();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
archiveInputRef.current?.click();
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
setIsDragging(false);
|
||||
void (async () => {
|
||||
const dropped = event.dataTransfer.items?.length
|
||||
? await expandDroppedItems(event.dataTransfer.items)
|
||||
: Array.from(event.dataTransfer.files);
|
||||
await onPickFiles(dropped);
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={archiveInputRef}
|
||||
className="upload-file-input"
|
||||
type="file"
|
||||
multiple
|
||||
accept=".zip,.tgz,.tar.gz,application/zip,application/gzip,application/x-gzip,application/x-tar"
|
||||
onChange={(event) => {
|
||||
const selected = Array.from(event.target.files ?? []);
|
||||
void onPickFiles(selected);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={setDirectoryInputRef}
|
||||
className="upload-file-input"
|
||||
type="file"
|
||||
multiple
|
||||
onChange={(event) => {
|
||||
const selected = Array.from(event.target.files ?? []);
|
||||
void onPickFiles(selected);
|
||||
}}
|
||||
/>
|
||||
<div className="plugin-dropzone-art" aria-hidden="true">
|
||||
<Package size={28} />
|
||||
</div>
|
||||
<div className="upload-dropzone-copy">
|
||||
<div className="upload-dropzone-title-row">
|
||||
<strong>Upload plugin code first</strong>
|
||||
<span className="upload-dropzone-count">
|
||||
{files.length} files · {formatBytes(totalBytes)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="upload-dropzone-hint">
|
||||
Drag a folder, zip, or tgz here. We inspect the package to unlock and prefill the rest
|
||||
of the form.
|
||||
</span>
|
||||
<div className="plugin-dropzone-actions">
|
||||
<button
|
||||
className="btn upload-picker-btn"
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
archiveInputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
Browse files
|
||||
</button>
|
||||
<button
|
||||
className="btn upload-picker-btn plugin-dropzone-secondary"
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
directoryInputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
Choose folder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`plugin-upload-summary${isMetadataLocked ? "" : " is-ready"}`}>
|
||||
{normalizedPaths.length === 0 ? (
|
||||
<div className="stat">No plugin package selected yet.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="plugin-upload-summary-row">
|
||||
<strong>Package detected</strong>
|
||||
<span className="upload-dropzone-count">
|
||||
{files.length} files · {formatBytes(totalBytes)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="plugin-upload-summary-copy">
|
||||
{detectedPrefillFields.length > 0
|
||||
? `Autofilled ${detectedPrefillFields.join(", ")}.`
|
||||
: "Package files were detected. Review and fill the release details below."}
|
||||
</div>
|
||||
<div className="plugin-upload-summary-tags">
|
||||
{normalizedPathSet.has("package.json") ? <span className="tag">Package manifest</span> : null}
|
||||
{normalizedPathSet.has("openclaw.plugin.json") ? (
|
||||
<span className="tag">Plugin manifest</span>
|
||||
) : null}
|
||||
{normalizedPathSet.has("openclaw.bundle.json") ? (
|
||||
<span className="tag">Bundle manifest</span>
|
||||
) : null}
|
||||
{normalizedPathSet.has("readme.md") || normalizedPathSet.has("readme.mdx") ? (
|
||||
<span className="tag">README</span>
|
||||
) : null}
|
||||
{ignoredPaths.length > 0 ? (
|
||||
<span className="tag">Ignored {ignoredPaths.length} files</span>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{validationError ? <div className="tag tag-accent">{validationError}</div> : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`card plugin-publish-form${isMetadataLocked ? " is-locked" : ""}`}
|
||||
style={{ display: "grid", gap: 12 }}
|
||||
aria-disabled={isMetadataLocked}
|
||||
>
|
||||
{!isAuthenticated ? <div>Log in to publish plugins.</div> : null}
|
||||
<div className={`plugin-publish-lock-note${isMetadataLocked ? "" : " is-ready"}`}>
|
||||
{isMetadataLocked
|
||||
? "Upload plugin code to detect the package shape and unlock the release form."
|
||||
: "Metadata detected and prefilled. Review it, then fill any missing release details."}
|
||||
</div>
|
||||
<select
|
||||
className="input"
|
||||
value={family}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setFamily(event.target.value as never)}
|
||||
>
|
||||
<option value="code-plugin">Code plugin</option>
|
||||
<option value="bundle-plugin">Bundle plugin</option>
|
||||
</select>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Plugin name"
|
||||
value={name}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Display name"
|
||||
value={displayName}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
/>
|
||||
<select
|
||||
className="input"
|
||||
value={ownerHandle}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setOwnerHandle(event.target.value)}
|
||||
>
|
||||
{(publishers ?? []).map((entry) => (
|
||||
<option key={entry.publisher._id} value={entry.publisher.handle}>
|
||||
@{entry.publisher.handle} · {entry.publisher.displayName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Version"
|
||||
value={version}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setVersion(event.target.value)}
|
||||
/>
|
||||
<textarea
|
||||
className="input"
|
||||
placeholder="Changelog"
|
||||
rows={4}
|
||||
value={changelog}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setChangelog(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Source repo (owner/repo)"
|
||||
value={sourceRepo}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setSourceRepo(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Source commit"
|
||||
value={sourceCommit}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setSourceCommit(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Source ref (tag or branch)"
|
||||
value={sourceRef}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setSourceRef(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Source path"
|
||||
value={sourcePath}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setSourcePath(event.target.value)}
|
||||
/>
|
||||
{family === "bundle-plugin" ? (
|
||||
<>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Bundle format"
|
||||
value={bundleFormat}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setBundleFormat(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Host targets (comma separated)"
|
||||
value={hostTargets}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setHostTargets(event.target.value)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<button
|
||||
className="btn"
|
||||
type="button"
|
||||
disabled={
|
||||
!isAuthenticated ||
|
||||
isMetadataLocked ||
|
||||
!name.trim() ||
|
||||
!version.trim() ||
|
||||
files.length === 0 ||
|
||||
Boolean(validationError) ||
|
||||
isSubmitting ||
|
||||
(family === "code-plugin" && (!sourceRepo.trim() || !sourceCommit.trim()))
|
||||
}
|
||||
onClick={() => {
|
||||
startTransition(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
setStatus("Uploading files…");
|
||||
setError(null);
|
||||
const uploaded = await buildPackageUploadEntries(files, {
|
||||
generateUploadUrl,
|
||||
hashFile,
|
||||
uploadFile,
|
||||
});
|
||||
setStatus("Publishing release…");
|
||||
await publishRelease({
|
||||
payload: {
|
||||
name: name.trim(),
|
||||
displayName: displayName.trim() || undefined,
|
||||
ownerHandle: ownerHandle || undefined,
|
||||
family,
|
||||
version: version.trim(),
|
||||
changelog: changelog.trim(),
|
||||
...(sourceRepo.trim() && sourceCommit.trim()
|
||||
? {
|
||||
source: {
|
||||
kind: "github" as const,
|
||||
repo: sourceRepo.trim(),
|
||||
url: sourceRepo.trim().startsWith("http")
|
||||
? sourceRepo.trim()
|
||||
: `https://github.com/${sourceRepo.trim().replace(/^\/+|\/+$/g, "")}`,
|
||||
ref: sourceRef.trim() || sourceCommit.trim(),
|
||||
commit: sourceCommit.trim(),
|
||||
path: sourcePath.trim() || ".",
|
||||
importedAt: Date.now(),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(family === "bundle-plugin"
|
||||
? {
|
||||
bundle: {
|
||||
format: bundleFormat.trim() || undefined,
|
||||
hostTargets: hostTargets
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
files: uploaded,
|
||||
},
|
||||
});
|
||||
setStatus("Published. Pending security checks and verification before public listing.");
|
||||
} catch (publishError) {
|
||||
setError(formatPublishError(publishError));
|
||||
setStatus(null);
|
||||
}
|
||||
})();
|
||||
});
|
||||
}}
|
||||
>
|
||||
{status ?? "Publish"}
|
||||
</button>
|
||||
{error ? <div className="tag tag-accent">{error}</div> : null}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
type PluginPublishPrefill = {
|
||||
family?: "code-plugin" | "bundle-plugin";
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
version?: string;
|
||||
sourceRepo?: string;
|
||||
bundleFormat?: string;
|
||||
hostTargets?: string;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function getString(value: unknown) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function getStringList(value: unknown) {
|
||||
if (Array.isArray(value)) return value.map(getString).filter(Boolean) as string[];
|
||||
if (typeof value === "string") {
|
||||
return value
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function readJsonUploadFile(
|
||||
files: Array<{ file: File; path: string }>,
|
||||
expectedPath: string,
|
||||
): Promise<JsonRecord | null> {
|
||||
const normalizedExpectedPath = expectedPath.toLowerCase();
|
||||
const expectedFileName = normalizedExpectedPath.split("/").at(-1);
|
||||
const entry =
|
||||
files.find((file) => file.path.toLowerCase() === normalizedExpectedPath) ??
|
||||
files.find((file) => file.path.toLowerCase().endsWith(`/${normalizedExpectedPath}`)) ??
|
||||
files.find((file) => {
|
||||
const normalizedPath = file.path.toLowerCase();
|
||||
return expectedFileName ? normalizedPath.split("/").at(-1) === expectedFileName : false;
|
||||
});
|
||||
if (!entry) return null;
|
||||
try {
|
||||
const parsed = JSON.parse((await entry.file.text()).replace(/^\uFEFF/, "")) as unknown;
|
||||
return isRecord(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGitHubRepo(value: string) {
|
||||
const trimmed = value
|
||||
.trim()
|
||||
.replace(/^git\+/, "")
|
||||
.replace(/\.git$/i, "")
|
||||
.replace(/^git@github\.com:/i, "https://github.com/");
|
||||
if (!trimmed) return undefined;
|
||||
|
||||
const shorthand = trimmed.match(/^([a-z0-9_.-]+)\/([a-z0-9_.-]+)$/i);
|
||||
if (shorthand) return `${shorthand[1]}/${shorthand[2]}`;
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (url.hostname !== "github.com" && url.hostname !== "www.github.com") return undefined;
|
||||
const [owner, repo] = url.pathname.replace(/^\/+|\/+$/g, "").split("/");
|
||||
if (!owner || !repo) return undefined;
|
||||
return `${owner}/${repo}`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function extractSourceRepo(packageJson: JsonRecord | null) {
|
||||
if (!packageJson) return undefined;
|
||||
const repository = packageJson.repository;
|
||||
if (typeof repository === "string") return normalizeGitHubRepo(repository);
|
||||
if (isRecord(repository) && typeof repository.url === "string") {
|
||||
return normalizeGitHubRepo(repository.url);
|
||||
}
|
||||
if (typeof packageJson.homepage === "string") return normalizeGitHubRepo(packageJson.homepage);
|
||||
if (isRecord(packageJson.bugs) && typeof packageJson.bugs.url === "string") {
|
||||
return normalizeGitHubRepo(packageJson.bugs.url);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function derivePluginPrefill(
|
||||
files: Array<{ file: File; path: string }>,
|
||||
): Promise<PluginPublishPrefill> {
|
||||
const packageJson = await readJsonUploadFile(files, "package.json");
|
||||
const pluginManifest = await readJsonUploadFile(files, "openclaw.plugin.json");
|
||||
const bundleManifest = await readJsonUploadFile(files, "openclaw.bundle.json");
|
||||
const openclaw = isRecord(packageJson?.openclaw) ? packageJson.openclaw : undefined;
|
||||
const hostTargets = bundleManifest
|
||||
? [...new Set([...getStringList(bundleManifest.hostTargets), ...getStringList(openclaw?.hostTargets)])]
|
||||
: [];
|
||||
|
||||
return {
|
||||
family: pluginManifest ? "code-plugin" : bundleManifest ? "bundle-plugin" : undefined,
|
||||
name: getString(packageJson?.name) ?? getString(pluginManifest?.id) ?? getString(bundleManifest?.id),
|
||||
displayName:
|
||||
getString(packageJson?.displayName) ??
|
||||
getString(pluginManifest?.name) ??
|
||||
getString(bundleManifest?.name),
|
||||
version: getString(packageJson?.version),
|
||||
sourceRepo: extractSourceRepo(packageJson),
|
||||
bundleFormat: getString(bundleManifest?.format) ?? getString(openclaw?.bundleFormat),
|
||||
hostTargets: hostTargets.length > 0 ? hostTargets.join(", ") : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function listPrefilledFields(prefill: PluginPublishPrefill) {
|
||||
const fields: string[] = [];
|
||||
if (prefill.family) fields.push("package type");
|
||||
if (prefill.name) fields.push("plugin name");
|
||||
if (prefill.displayName) fields.push("display name");
|
||||
if (prefill.version) fields.push("version");
|
||||
if (prefill.sourceRepo) fields.push("source repo");
|
||||
if (prefill.bundleFormat) fields.push("bundle format");
|
||||
if (prefill.hostTargets) fields.push("host targets");
|
||||
return fields;
|
||||
}
|
||||
@@ -0,0 +1,672 @@
|
||||
import { createFileRoute, useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import {
|
||||
PLATFORM_SKILL_LICENSE,
|
||||
PLATFORM_SKILL_LICENSE_NAME,
|
||||
PLATFORM_SKILL_LICENSE_SUMMARY,
|
||||
} from "clawhub-schema";
|
||||
import { useAction, useMutation, useQuery } from "convex/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import semver from "semver";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import {
|
||||
MAX_PUBLISH_FILE_BYTES,
|
||||
MAX_PUBLISH_TOTAL_BYTES,
|
||||
} from "../../convex/lib/publishLimits";
|
||||
import { getSiteMode } from "../lib/site";
|
||||
import { getPublicSlugCollision } from "../lib/slugCollision";
|
||||
import { expandDroppedItems, expandFilesWithReport } from "../lib/uploadFiles";
|
||||
import { useAuthStatus } from "../lib/useAuthStatus";
|
||||
import {
|
||||
formatBytes,
|
||||
formatPublishError,
|
||||
hashFile,
|
||||
isTextFile,
|
||||
readText,
|
||||
uploadFile,
|
||||
} from "./upload/-utils";
|
||||
|
||||
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
|
||||
export const Route = createFileRoute("/publish-skill")({
|
||||
validateSearch: (search) => ({
|
||||
updateSlug: typeof search.updateSlug === "string" ? search.updateSlug : undefined,
|
||||
}),
|
||||
component: Upload,
|
||||
});
|
||||
|
||||
export function Upload() {
|
||||
const { isAuthenticated, me } = useAuthStatus();
|
||||
const { updateSlug } = useSearch({ from: "/publish-skill" });
|
||||
const siteMode = getSiteMode();
|
||||
const isSoulMode = siteMode === "souls";
|
||||
const requiredFileLabel = isSoulMode ? "SOUL.md" : "SKILL.md";
|
||||
const contentLabel = isSoulMode ? "soul" : "skill";
|
||||
|
||||
const generateUploadUrl = useMutation(api.uploads.generateUploadUrl);
|
||||
const publishVersion = useAction(
|
||||
isSoulMode ? api.souls.publishVersion : api.skills.publishVersion,
|
||||
);
|
||||
const generateChangelogPreview = useAction(
|
||||
isSoulMode ? api.souls.generateChangelogPreview : api.skills.generateChangelogPreview,
|
||||
);
|
||||
const existingSkill = useQuery(
|
||||
api.skills.getBySlug,
|
||||
!isSoulMode && updateSlug ? { slug: updateSlug } : "skip",
|
||||
);
|
||||
const existingSoul = useQuery(
|
||||
api.souls.getBySlug,
|
||||
isSoulMode && updateSlug ? { slug: updateSlug } : "skip",
|
||||
);
|
||||
const existing = (isSoulMode ? existingSoul : existingSkill) as
|
||||
| {
|
||||
skill?: { slug: string; displayName: string };
|
||||
soul?: { slug: string; displayName: string };
|
||||
latestVersion?: { version: string };
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
const [hasAttempted, setHasAttempted] = useState(false);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [ignoredMacJunkPaths, setIgnoredMacJunkPaths] = useState<string[]>([]);
|
||||
const [slug, setSlug] = useState(updateSlug ?? "");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [version, setVersion] = useState("1.0.0");
|
||||
const [tags, setTags] = useState("latest");
|
||||
const [acceptedLicenseTerms, setAcceptedLicenseTerms] = useState(false);
|
||||
const [changelog, setChangelog] = useState("");
|
||||
const [changelogStatus, setChangelogStatus] = useState<"idle" | "loading" | "ready" | "error">(
|
||||
"idle",
|
||||
);
|
||||
const [changelogSource, setChangelogSource] = useState<"auto" | "user" | null>(null);
|
||||
const changelogTouchedRef = useRef(false);
|
||||
const changelogRequestRef = useRef(0);
|
||||
const changelogKeyRef = useRef<string | null>(null);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const isSubmitting = status !== null;
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const publisherMemberships = useQuery(api.publishers.listMine) as
|
||||
| Array<{
|
||||
publisher: {
|
||||
_id: string;
|
||||
handle: string;
|
||||
displayName: string;
|
||||
kind: "user" | "org";
|
||||
};
|
||||
role: "owner" | "admin" | "publisher";
|
||||
}>
|
||||
| undefined;
|
||||
const [ownerHandle, setOwnerHandle] = useState("");
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const setFileInputRef = (node: HTMLInputElement | null) => {
|
||||
fileInputRef.current = node;
|
||||
if (node) {
|
||||
node.setAttribute("webkitdirectory", "");
|
||||
node.setAttribute("directory", "");
|
||||
}
|
||||
};
|
||||
const validationRef = useRef<HTMLDivElement | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const totalBytes = useMemo(() => files.reduce((sum, file) => sum + file.size, 0), [files]);
|
||||
const stripRoot = useMemo(() => {
|
||||
if (files.length === 0) return null;
|
||||
const paths = files.map((file) => (file.webkitRelativePath || file.name).replace(/^\.\//, ""));
|
||||
if (!paths.every((path) => path.includes("/"))) return null;
|
||||
const firstSegment = paths[0]?.split("/")[0];
|
||||
if (!firstSegment) return null;
|
||||
if (!paths.every((path) => path.startsWith(`${firstSegment}/`))) return null;
|
||||
return firstSegment;
|
||||
}, [files]);
|
||||
const normalizedPaths = useMemo(
|
||||
() =>
|
||||
files.map((file) => {
|
||||
const raw = (file.webkitRelativePath || file.name).replace(/^\.\//, "");
|
||||
if (stripRoot && raw.startsWith(`${stripRoot}/`)) {
|
||||
return raw.slice(stripRoot.length + 1);
|
||||
}
|
||||
return raw;
|
||||
}),
|
||||
[files, stripRoot],
|
||||
);
|
||||
const hasRequiredFile = useMemo(
|
||||
() =>
|
||||
normalizedPaths.some((path) => {
|
||||
const lower = path.trim().toLowerCase();
|
||||
return isSoulMode ? lower === "soul.md" : lower === "skill.md" || lower === "skills.md";
|
||||
}),
|
||||
[isSoulMode, normalizedPaths],
|
||||
);
|
||||
const sizeLabel = totalBytes ? formatBytes(totalBytes) : "0 B";
|
||||
const oversizedFiles = useMemo(
|
||||
() => files.filter((file) => file.size > MAX_PUBLISH_FILE_BYTES),
|
||||
[files],
|
||||
);
|
||||
const oversizedFileNames = useMemo(
|
||||
() => oversizedFiles.slice(0, 3).map((file) => file.name),
|
||||
[oversizedFiles],
|
||||
);
|
||||
const ignoredMacJunkNote = useMemo(() => {
|
||||
if (ignoredMacJunkPaths.length === 0) return null;
|
||||
const labels = Array.from(
|
||||
new Set(ignoredMacJunkPaths.map((path) => path.split("/").at(-1) ?? path)),
|
||||
).slice(0, 3);
|
||||
const suffix = ignoredMacJunkPaths.length > 3 ? ", ..." : "";
|
||||
const count = ignoredMacJunkPaths.length;
|
||||
return `Ignored ${count} macOS junk file${count === 1 ? "" : "s"} (${labels.join(", ")}${suffix})`;
|
||||
}, [ignoredMacJunkPaths]);
|
||||
const trimmedSlug = slug.trim();
|
||||
const trimmedName = displayName.trim();
|
||||
const trimmedChangelog = changelog.trim();
|
||||
const slugAvailability = useQuery(
|
||||
api.skills.checkSlugAvailability,
|
||||
!isSoulMode && isAuthenticated && trimmedSlug && SLUG_PATTERN.test(trimmedSlug)
|
||||
? { slug: trimmedSlug.toLowerCase() }
|
||||
: "skip",
|
||||
) as
|
||||
| {
|
||||
available: boolean;
|
||||
reason: "available" | "taken" | "reserved";
|
||||
message: string | null;
|
||||
url: string | null;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
const slugCollision = useMemo(
|
||||
() =>
|
||||
getPublicSlugCollision({
|
||||
isSoulMode,
|
||||
slug: trimmedSlug,
|
||||
result: slugAvailability,
|
||||
}),
|
||||
[isSoulMode, slugAvailability, trimmedSlug],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!existing?.latestVersion || (!existing?.skill && !existing?.soul)) return;
|
||||
const name = existing.skill?.displayName ?? existing.soul?.displayName;
|
||||
const nextSlug = existing.skill?.slug ?? existing.soul?.slug;
|
||||
if (nextSlug) setSlug(nextSlug);
|
||||
if (name) setDisplayName(name);
|
||||
const nextVersion = semver.inc(existing.latestVersion.version, "patch");
|
||||
if (nextVersion) setVersion(nextVersion);
|
||||
}, [existing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (ownerHandle) return;
|
||||
const personalPublisher = publisherMemberships?.find((entry) => entry.publisher.kind === "user");
|
||||
if (personalPublisher?.publisher.handle) {
|
||||
setOwnerHandle(personalPublisher.publisher.handle);
|
||||
}
|
||||
}, [ownerHandle, publisherMemberships]);
|
||||
|
||||
useEffect(() => {
|
||||
if (changelogTouchedRef.current) return;
|
||||
if (trimmedChangelog) return;
|
||||
if (!trimmedSlug || !SLUG_PATTERN.test(trimmedSlug)) return;
|
||||
if (!semver.valid(version)) return;
|
||||
if (!hasRequiredFile) return;
|
||||
if (files.length === 0) return;
|
||||
|
||||
const requiredIndex = normalizedPaths.findIndex((path) => {
|
||||
const lower = path.trim().toLowerCase();
|
||||
return isSoulMode ? lower === "soul.md" : lower === "skill.md" || lower === "skills.md";
|
||||
});
|
||||
if (requiredIndex < 0) return;
|
||||
|
||||
const requiredFile = files[requiredIndex];
|
||||
if (!requiredFile) return;
|
||||
|
||||
const key = `${trimmedSlug}:${version}:${requiredFile.size}:${requiredFile.lastModified}:${normalizedPaths.length}`;
|
||||
if (changelogKeyRef.current === key) return;
|
||||
changelogKeyRef.current = key;
|
||||
|
||||
const requestId = ++changelogRequestRef.current;
|
||||
setChangelogStatus("loading");
|
||||
|
||||
void readText(requiredFile)
|
||||
.then((text) => {
|
||||
if (changelogRequestRef.current !== requestId) return null;
|
||||
return generateChangelogPreview({
|
||||
slug: trimmedSlug,
|
||||
version,
|
||||
readmeText: text.slice(0, 20_000),
|
||||
filePaths: normalizedPaths,
|
||||
});
|
||||
})
|
||||
.then((result) => {
|
||||
if (!result) return;
|
||||
if (changelogRequestRef.current !== requestId) return;
|
||||
setChangelog(result.changelog);
|
||||
setChangelogSource("auto");
|
||||
setChangelogStatus("ready");
|
||||
})
|
||||
.catch(() => {
|
||||
if (changelogRequestRef.current !== requestId) return;
|
||||
setChangelogStatus("error");
|
||||
});
|
||||
}, [
|
||||
files,
|
||||
generateChangelogPreview,
|
||||
hasRequiredFile,
|
||||
isSoulMode,
|
||||
normalizedPaths,
|
||||
trimmedChangelog,
|
||||
trimmedSlug,
|
||||
version,
|
||||
]);
|
||||
const parsedTags = useMemo(
|
||||
() =>
|
||||
tags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean),
|
||||
[tags],
|
||||
);
|
||||
const validation = useMemo(() => {
|
||||
const issues: string[] = [];
|
||||
if (!trimmedSlug) {
|
||||
issues.push("Slug is required.");
|
||||
} else if (!SLUG_PATTERN.test(trimmedSlug)) {
|
||||
issues.push("Slug must be lowercase and use dashes only.");
|
||||
}
|
||||
if (!trimmedName) {
|
||||
issues.push("Display name is required.");
|
||||
}
|
||||
if (!semver.valid(version)) {
|
||||
issues.push("Version must be valid semver (e.g. 1.0.0).");
|
||||
}
|
||||
if (parsedTags.length === 0) {
|
||||
issues.push("At least one tag is required.");
|
||||
}
|
||||
if (!isSoulMode && !acceptedLicenseTerms) {
|
||||
issues.push("Accept the MIT-0 license terms to publish this skill.");
|
||||
}
|
||||
if (files.length === 0) {
|
||||
issues.push("Add at least one file.");
|
||||
}
|
||||
if (!hasRequiredFile) {
|
||||
issues.push(`${requiredFileLabel} is required.`);
|
||||
}
|
||||
const invalidFiles = files.filter((file) => !isTextFile(file));
|
||||
if (invalidFiles.length > 0) {
|
||||
issues.push(
|
||||
`Remove non-text files: ${invalidFiles
|
||||
.slice(0, 3)
|
||||
.map((file) => file.name)
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (oversizedFiles.length > 0) {
|
||||
issues.push(`Each file must be 10MB or smaller: ${oversizedFileNames.join(", ")}`);
|
||||
}
|
||||
if (totalBytes > MAX_PUBLISH_TOTAL_BYTES) {
|
||||
issues.push("Total file size exceeds 50MB.");
|
||||
}
|
||||
if (slugCollision) {
|
||||
issues.push(slugCollision.message);
|
||||
}
|
||||
return {
|
||||
issues,
|
||||
ready: issues.length === 0,
|
||||
};
|
||||
}, [
|
||||
trimmedSlug,
|
||||
trimmedName,
|
||||
version,
|
||||
parsedTags.length,
|
||||
acceptedLicenseTerms,
|
||||
files,
|
||||
hasRequiredFile,
|
||||
isSoulMode,
|
||||
totalBytes,
|
||||
oversizedFiles.length,
|
||||
oversizedFileNames,
|
||||
requiredFileLabel,
|
||||
slugCollision,
|
||||
]);
|
||||
|
||||
// webkitdirectory/directory attributes are set via the ref callback (setFileInputRef)
|
||||
// to ensure they persist across hydration and re-renders (#58)
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="card">Sign in to publish a {contentLabel}.</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
async function applyExpandedFiles(selected: File[]) {
|
||||
const report = await expandFilesWithReport(selected);
|
||||
setFiles(report.files);
|
||||
setIgnoredMacJunkPaths(report.ignoredMacJunkPaths);
|
||||
}
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setHasAttempted(true);
|
||||
if (!validation.ready) {
|
||||
if (validationRef.current && "scrollIntoView" in validationRef.current) {
|
||||
validationRef.current.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (slugCollision) {
|
||||
setError(slugCollision.message);
|
||||
return;
|
||||
}
|
||||
if (!isSoulMode && !acceptedLicenseTerms) {
|
||||
setError("Accept the MIT-0 license terms to publish this skill.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
if (oversizedFiles.length > 0) {
|
||||
setError(`Each file must be 10MB or smaller: ${oversizedFileNames.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
if (totalBytes > MAX_PUBLISH_TOTAL_BYTES) {
|
||||
setError("Total size exceeds 50MB per version.");
|
||||
return;
|
||||
}
|
||||
if (!hasRequiredFile) {
|
||||
setError(`${requiredFileLabel} is required.`);
|
||||
return;
|
||||
}
|
||||
setStatus("Uploading files…");
|
||||
|
||||
const uploaded = [] as Array<{
|
||||
path: string;
|
||||
size: number;
|
||||
storageId: string;
|
||||
sha256: string;
|
||||
contentType?: string;
|
||||
}>;
|
||||
|
||||
for (const file of files) {
|
||||
const uploadUrl = await generateUploadUrl();
|
||||
const rawPath = (file.webkitRelativePath || file.name).replace(/^\.\//, "");
|
||||
const path =
|
||||
stripRoot && rawPath.startsWith(`${stripRoot}/`)
|
||||
? rawPath.slice(stripRoot.length + 1)
|
||||
: rawPath;
|
||||
const sha256 = await hashFile(file);
|
||||
const storageId = await uploadFile(uploadUrl, file);
|
||||
uploaded.push({
|
||||
path,
|
||||
size: file.size,
|
||||
storageId,
|
||||
sha256,
|
||||
contentType: file.type || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
setStatus("Publishing…");
|
||||
try {
|
||||
const result = await publishVersion({
|
||||
ownerHandle: isSoulMode ? undefined : ownerHandle || undefined,
|
||||
slug: trimmedSlug,
|
||||
displayName: trimmedName,
|
||||
version,
|
||||
changelog: trimmedChangelog,
|
||||
acceptLicenseTerms: isSoulMode ? undefined : acceptedLicenseTerms,
|
||||
tags: parsedTags,
|
||||
files: uploaded,
|
||||
});
|
||||
setStatus(null);
|
||||
setError(null);
|
||||
setHasAttempted(false);
|
||||
setChangelogSource("user");
|
||||
if (result) {
|
||||
const ownerParam =
|
||||
ownerHandle || me?.handle || (me?._id ? String(me._id) : "unknown");
|
||||
void navigate({
|
||||
to: isSoulMode ? "/souls/$slug" : "/$owner/$slug",
|
||||
params: isSoulMode ? { slug: trimmedSlug } : { owner: ownerParam, slug: trimmedSlug },
|
||||
});
|
||||
}
|
||||
} catch (publishError) {
|
||||
setStatus(null);
|
||||
setError(formatPublishError(publishError));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="section upload-page">
|
||||
<header className="upload-page-header">
|
||||
<div>
|
||||
<h1 className="upload-page-title">Publish a {contentLabel}</h1>
|
||||
<p className="upload-page-subtitle">
|
||||
Drop a folder with {requiredFileLabel} and text files. We will handle the rest.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form onSubmit={handleSubmit} className="upload-grid">
|
||||
<div className="card upload-panel">
|
||||
<label className="form-label" htmlFor="slug">
|
||||
Slug
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
id="slug"
|
||||
value={slug}
|
||||
onChange={(event) => setSlug(event.target.value)}
|
||||
placeholder={`${contentLabel}-name`}
|
||||
/>
|
||||
|
||||
<label className="form-label" htmlFor="displayName">
|
||||
Display name
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
placeholder={`My ${contentLabel}`}
|
||||
/>
|
||||
|
||||
{!isSoulMode ? (
|
||||
<>
|
||||
<label className="form-label" htmlFor="ownerHandle">
|
||||
Owner
|
||||
</label>
|
||||
<select
|
||||
className="form-input"
|
||||
id="ownerHandle"
|
||||
value={ownerHandle}
|
||||
onChange={(event) => setOwnerHandle(event.target.value)}
|
||||
>
|
||||
{(publisherMemberships ?? []).map((entry) => (
|
||||
<option key={entry.publisher._id} value={entry.publisher.handle}>
|
||||
@{entry.publisher.handle} · {entry.publisher.displayName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<label className="form-label" htmlFor="version">
|
||||
Version
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
id="version"
|
||||
value={version}
|
||||
onChange={(event) => setVersion(event.target.value)}
|
||||
placeholder="1.0.0"
|
||||
/>
|
||||
|
||||
<label className="form-label" htmlFor="tags">
|
||||
Tags
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
id="tags"
|
||||
value={tags}
|
||||
onChange={(event) => setTags(event.target.value)}
|
||||
placeholder="latest, stable"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card upload-panel">
|
||||
<label
|
||||
className={`upload-dropzone${isDragging ? " is-dragging" : ""}`}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
setIsDragging(false);
|
||||
const items = event.dataTransfer.items;
|
||||
void (async () => {
|
||||
const dropped = items?.length
|
||||
? await expandDroppedItems(items)
|
||||
: Array.from(event.dataTransfer.files);
|
||||
await applyExpandedFiles(dropped);
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={setFileInputRef}
|
||||
className="upload-file-input"
|
||||
id="upload-files"
|
||||
data-testid="upload-input"
|
||||
type="file"
|
||||
multiple
|
||||
onChange={(event) => {
|
||||
const picked = Array.from(event.target.files ?? []);
|
||||
void applyExpandedFiles(picked);
|
||||
}}
|
||||
/>
|
||||
<div className="upload-dropzone-copy">
|
||||
<div className="upload-dropzone-title-row">
|
||||
<strong>Drop a folder</strong>
|
||||
<span className="upload-dropzone-count">
|
||||
{files.length} files · {sizeLabel}
|
||||
</span>
|
||||
</div>
|
||||
<span className="upload-dropzone-hint">
|
||||
We keep folder paths and flatten the outer wrapper automatically.
|
||||
</span>
|
||||
<button
|
||||
className="btn upload-picker-btn"
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
Choose folder
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div className="upload-file-list">
|
||||
{files.length === 0 ? (
|
||||
<div className="stat">No files selected.</div>
|
||||
) : (
|
||||
normalizedPaths.map((path) => (
|
||||
<div key={path} className="upload-file-row">
|
||||
<span>{path}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{ignoredMacJunkNote ? <div className="stat">{ignoredMacJunkNote}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="card upload-panel" ref={validationRef}>
|
||||
<h2 className="upload-panel-title">Validation</h2>
|
||||
{validation.issues.length === 0 ? (
|
||||
<div className="stat">All checks passed.</div>
|
||||
) : (
|
||||
<ul className="validation-list">
|
||||
{validation.issues.map((issue) => (
|
||||
<li key={issue}>{issue}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{slugCollision?.url ? (
|
||||
<div className="stat">
|
||||
Existing skill:{" "}
|
||||
<a href={slugCollision.url} className="upload-link">
|
||||
{slugCollision.url}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="card upload-panel">
|
||||
{!isSoulMode ? (
|
||||
<>
|
||||
<h2 className="upload-panel-title">License</h2>
|
||||
<div className="upload-license-card">
|
||||
<div className="upload-license-pill">
|
||||
{PLATFORM_SKILL_LICENSE} · {PLATFORM_SKILL_LICENSE_NAME}
|
||||
</div>
|
||||
<p className="upload-license-copy">
|
||||
All skills published on ClawHub are licensed under {PLATFORM_SKILL_LICENSE}.{" "}
|
||||
{PLATFORM_SKILL_LICENSE_SUMMARY}
|
||||
</p>
|
||||
<label className="upload-license-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={acceptedLicenseTerms}
|
||||
onChange={(event) => setAcceptedLicenseTerms(event.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
I have the rights to this skill and agree to publish it under{" "}
|
||||
{PLATFORM_SKILL_LICENSE}.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<label className="form-label" htmlFor="changelog">
|
||||
Changelog
|
||||
</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
id="changelog"
|
||||
rows={6}
|
||||
value={changelog}
|
||||
onChange={(event) => {
|
||||
changelogTouchedRef.current = true;
|
||||
setChangelogSource("user");
|
||||
setChangelog(event.target.value);
|
||||
}}
|
||||
placeholder={`Describe what changed in this ${contentLabel}...`}
|
||||
/>
|
||||
{changelogStatus === "loading" ? <div className="stat">Generating changelog…</div> : null}
|
||||
{changelogStatus === "error" ? (
|
||||
<div className="stat">Could not auto-generate changelog.</div>
|
||||
) : null}
|
||||
{changelogSource === "auto" && changelog ? (
|
||||
<div className="stat">Auto-generated changelog (edit as needed).</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="upload-submit-row">
|
||||
<div className="upload-submit-notes">
|
||||
{error ? (
|
||||
<div className="error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{status ? <div className="stat">{status}</div> : null}
|
||||
{hasAttempted && !validation.ready ? (
|
||||
<div className="stat">Fix validation issues to continue.</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary upload-submit-btn"
|
||||
type="submit"
|
||||
disabled={!validation.ready || isSubmitting}
|
||||
>
|
||||
Publish {contentLabel}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user