Compare commits

...
Author SHA1 Message Date
Vincent Koc 48d0fc91f3 fix(packages): keep plugin api separate from gateway version 2026-03-23 09:08:49 -07:00
LukeandPeter Steinberger fefb2340a8 feat: redesign plugins page and skills list view (#1166)
* feat: redesign plugins page and skills list view

Redesigned the plugins page with a cleaner toolbar (pill search,
toggle filter buttons) and simplified card layout. Added a proper
table-style list view for skills with skill name, version, summary,
and author avatar columns. Also polished the sort dropdown with a
chevron indicator, added card shadows for better separation, and
tightened up the theme toggle and sign-in button.

* feat: add publisher org ownership

* feat: migrate legacy publisher handles to orgs

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-03-23 03:44:17 -07:00
Peter Steinberger 22287558b9 feat: migrate legacy publisher handles to orgs 2026-03-23 03:39:28 -07:00
Peter Steinberger f6ce8f9e1e feat: add publisher org ownership 2026-03-23 03:27:08 -07:00
Peter Steinberger b5cdee50a9 feat(packages): support shared publisher owners 2026-03-23 02:14:38 -07:00
Vincent Koc b5fdee1c13 feat(plugins): clarify pending verification and add backfill 2026-03-23 02:07:05 -07:00
Vincent Koc 2530beaf51 fix(plugins): hide pending and malicious packages 2026-03-23 02:04:00 -07:00
Vincent Koc c627b202f7 fix(plugins): add async malware scan parity 2026-03-23 02:01:04 -07:00
Vincent Koc 5cbeb54c8e fix(upload): cap published skill and plugin file sizes 2026-03-23 01:57:59 -07:00
Vincent Koc eb8136b7fb fix(ui): keep query when filtering plugins 2026-03-23 01:47:31 -07:00
Peter Steinberger a166c95eb0 fix: avoid skill scans on verified plugin browse 2026-03-23 01:14:40 -07:00
scoootscooob 86dc196e9b fix: tolerate stale auth on public package reads (#1157) 2026-03-23 01:06:32 -07:00
Peter Steinberger 649c14a43f feat: rename Packages to Plugins, filter out skills, add verified badge
- Rename /packages route to /plugins with /packages redirecting
- Nav links now say "Plugins" and point to /plugins
- Plugins page only shows code-plugin and bundle-plugin (no skills)
- "Official only" → "Verified only"; blue checkmark badge for verified publishers
- Compact card footer: "by author · v1.2.3" inline with verified badge
- Remove duplicate Skill/Skill tag bubbles
- Update tests to match new routes and behavior
2026-03-23 01:02:15 -07:00
Peter Steinberger 7cbf0434b0 ci: wait for vercel git deploy status 2026-03-23 00:58:21 -07:00
Peter Steinberger 76944f0e32 chore: prepare 0.9.0 release 2026-03-23 00:45:56 -07:00
Peter Steinberger 6ead1da08b fix: use read rate limits for package files 2026-03-23 00:44:31 -07:00
Val Alexander 22413f4ff8 Revert "chore: update .gitignore and enhance Convex documentation in AGENTS.md and CLAUDE.md"
This reverts commit 3326c235da.
2026-03-22 20:17:58 -05:00
84 changed files with 7140 additions and 981 deletions
+33 -18
View File
@@ -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:
-4
View File
@@ -24,7 +24,3 @@ coverage
playwright-report
test-results
.playwright
skills/
docs/
convex/
skills-lock.json
-8
View File
@@ -75,11 +75,3 @@
- **32K document limit per query.** Split `.collect()` calls by a partition field (e.g., one day at a time instead of a 7-day range). See `rebuildTrendingLeaderboardAction` in `convex/leaderboards.ts` for an example.
- **Common mistakes**: `.filter().collect()` without an index; `ctx.db.get()` on large docs in a loop for list views; while loops that paginate the whole table to find filtered results.
- **Before writing or reviewing Convex queries, check deployment health.** Run `bunx convex insights` to check for OCC conflicts, `bytesReadLimit`, and `documentsReadLimit` errors. Run `bunx convex logs --failure` to see individual error messages and stack traces. This helps identify which functions are causing bandwidth issues so you can prioritize fixes.
<!-- convex-ai-start -->
This project uses [Convex](https://convex.dev) as its backend.
When working on Convex code, **always read `convex/_generated/ai/guidelines.md` first** for important guidelines on how to correctly use Convex APIs and patterns. The file contains rules that override what you may have learned about Convex from training data.
Convex agent skills for common tasks can be installed by running `npx convex ai-files install`.
<!-- convex-ai-end -->
+15 -1
View File
@@ -1,10 +1,24 @@
# Changelog
## 0.9.0 - Unreleased
## 0.9.0 - 2026-03-23
### Added
- Packages/Plugins: add a first-class OpenClaw package registry across the web app, CLI, and HTTP API. ClawHub now supports package browse/search/detail/version/file/download flows plus `clawhub package explore`, `clawhub package inspect`, and `clawhub package publish` for `skill`, `code-plugin`, and `bundle-plugin` packages. (#1093)
- Packages/Install: package downloads now ship install-ready archives with a `package/` root, support nested files like `dist/index.js`, and work directly with OpenClaw plugin install flows.
- Skills/Web: server-render public skill pages and OG assets for faster first loads, cleaner sharing previews, and better cache behavior.
### Changed
- Browse/Search: rebuild public browse/search around denormalized digests, one-shot HTTP fetches, and deterministic cursors so the homepage and `/skills` are faster, more cacheable, and less likely to hit stale-tab or pagination dead ends.
- Search: default skill search to relevance, keep load-more retryable after fetch failures, and tighten package/skill catalog query paths to reduce inconsistent results under load.
### Fixed
- Packages/Auth: authenticated owners can now list, search, inspect, download, and read files from their own private packages instead of private packages being direct-URL-only. (#1093)
- Packages/API: stabilize package latest-version pointers, cursor pagination, publish outputs, fallback release resolution, and app-origin auth handling so package publish/search/install flows stay reliable.
- Visibility/API: prevent skills owned by deleted/banned users from showing up in public detail pages, browse/search results, or version API routes.
- Skills/API: sanitize public skill and soul version/file reads so hidden or invalid version data does not leak through direct API access.
- Skills/Web: keep Monaco compare layout toggles reliable while defaulting narrow screens to inline mode (#828) (thanks @geoffrey-xiao).
## 0.8.0 - 2026-03-13
-8
View File
@@ -34,11 +34,3 @@
- Tests use `._handler` to call mutation handlers directly with mock `db` objects.
- Mock `db` objects MUST include `normalizeId: vi.fn()` for trigger wrapper compatibility.
<!-- convex-ai-start -->
This project uses [Convex](https://convex.dev) as its backend.
When working on Convex code, **always read `convex/_generated/ai/guidelines.md` first** for important guidelines on how to correctly use Convex APIs and patterns. The file contains rules that override what you may have learned about Convex from training data.
Convex agent skills for common tasks can be installed by running `npx convex ai-files install`.
<!-- convex-ai-end -->
+1 -1
View File
@@ -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).
+8
View File
@@ -67,6 +67,8 @@ 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_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 +85,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";
@@ -175,6 +179,8 @@ declare const fullApi: ApiFromModules<{
"lib/packageRegistry": typeof lib_packageRegistry;
"lib/packageSearchDigest": typeof lib_packageSearchDigest;
"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 +197,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;
+3
View File
@@ -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
+108 -34
View File
@@ -17,10 +17,18 @@ 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 LatestPackageRelease = Pick<
Doc<"packageReleases">,
@@ -33,7 +41,9 @@ type LatestPackageRelease = Pick<
| "capabilities"
| "verification"
| "distTags"
>;
> & {
scanStatus?: Doc<"packages">["scanStatus"];
};
function toPackageLatestVersionSummary(
release: LatestPackageRelease | null,
@@ -92,6 +102,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 +119,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,16 +147,89 @@ 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;
}
}
@@ -197,6 +277,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 +291,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 +333,12 @@ 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 syncPackageSearchDigestsForOwnerPublisherId(ctx, ownerPublisherId);
await syncSkillSearchDigestsForOwnerPublisherId(ctx, ownerPublisherId);
});
export const mutation = customMutation(rawMutation, customCtx(triggers.wrapDB));
export const internalMutation = customMutation(rawInternalMutation, customCtx(triggers.wrapDB));
export { query, internalQuery, action, internalAction, httpAction };
+223
View File
@@ -214,6 +214,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([
{
@@ -2345,6 +2382,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;
@@ -2650,6 +2708,69 @@ describe("httpApiV1 handlers", () => {
);
});
it("package file uses read rate limiting", 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(response.headers.get("RateLimit-Limit")).toBeTruthy();
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
key: expect.stringMatching(/^ip:/),
limit: 120,
}),
);
});
it("package download uses a package/ root without registry metadata", async () => {
const runMutation = vi.fn().mockResolvedValue(okRate());
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
@@ -2784,6 +2905,100 @@ describe("httpApiV1 handlers", () => {
expect(await response.text()).toBe("Missing stored file: dist/index.js");
});
it("blocks 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: [],
};
}
return null;
});
const response = await __handlers.packagesGetRouterV1Handler(
makeCtx({ runQuery, runMutation, storage: { get: vi.fn() } }),
new Request("https://example.com/api/v1/packages/demo-plugin/download"),
);
expect(response.status).toBe(423);
expect(await response.text()).toContain("pending a security scan");
});
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>) => {
@@ -2861,6 +3076,7 @@ describe("httpApiV1 handlers", () => {
},
body: JSON.stringify({
name: "demo-plugin",
ownerHandle: "openclaw",
family: "bundle-plugin",
version: "1.0.0",
changelog: "init",
@@ -2886,6 +3102,13 @@ describe("httpApiV1 handlers", () => {
limit: 120,
}),
);
expect(runAction).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
actorUserId: "users:1",
payload: expect.objectContaining({ ownerHandle: "openclaw" }),
}),
);
});
it("multipart package publish ignores macOS junk files", async () => {
+51 -5
View File
@@ -5,6 +5,7 @@ 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 { 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 +59,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 +125,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 +138,27 @@ function toVisibleRelease(release: ReleaseLike | null) {
return release;
}
function getReleaseSecurityBlock(release: ReleaseLike) {
if (
release.vtAnalysis?.status === "malicious" ||
release.verification?.scanStatus === "malicious" ||
release.staticScan?.status === "malicious"
) {
return {
status: 403,
message: "Blocked: this package release has been flagged as malicious and cannot be downloaded.",
};
}
const vtStatus = release.vtAnalysis?.status?.trim().toLowerCase();
if (release.sha256hash && (!vtStatus || vtStatus === "pending")) {
return {
status: 423,
message: "This package release is pending a security scan by VirusTotal. Please try again in a few minutes.",
};
}
return null;
}
async function resolvePackageTags(
ctx: ActionCtx,
tags: Record<string, Id<"packageReleases">>,
@@ -340,6 +377,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 +397,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 +427,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("");
@@ -408,7 +450,7 @@ async function listPackages(ctx: ActionCtx, request: Request, family?: PackageLi
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");
@@ -570,7 +612,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);
@@ -687,13 +729,13 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
const segments = getPathSegments(request, "/api/v1/packages/");
if (segments.length === 0) return text("Not found", 404);
const rateKind = segments[1] === "file" || segments[1] === "download" ? "download" : "read";
const rateKind = segments[1] === "download" ? "download" : "read";
const rate = await applyRateLimit(ctx, request, rateKind);
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 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");
@@ -775,7 +817,7 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
}
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,
@@ -958,6 +1000,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 file = release.files.find((entry) => entry.path === path);
if (!file) return text("File not found", 404, rate.headers);
if (!isTextFile(file.path, file.contentType)) {
@@ -993,6 +1037,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);
+4
View File
@@ -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);
+46 -1
View File
@@ -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;
+59
View File
@@ -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({
+13 -2
View File
@@ -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();
+6
View File
@@ -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"];
};
+26
View File
@@ -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,
+28
View File
@@ -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");
});
});
+19
View File
@@ -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`;
}
+304
View File
@@ -0,0 +1,304 @@
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 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;
if (user.personalPublisherId) {
const publisher = await ctx.db.get(user.personalPublisherId);
if (isPublisherActive(publisher)) return publisher;
}
try {
const publisher = await getPersonalPublisherForUser(ctx, params.ownerUserId);
if (isPublisherActive(publisher)) return publisher;
} catch (error) {
if (!isMissingPublisherTableError(error)) throw error;
}
return synthesizePersonalPublisher(user);
}
+18 -7
View File
@@ -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,
+7 -5
View File
@@ -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",
});
});
+19 -8
View File
@@ -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,
};
+42
View File
@@ -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,
});
}
+194
View File
@@ -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"}'
+503 -13
View File
@@ -78,7 +78,9 @@ const listVersionsHandler = (
const insertReleaseInternalHandler = (
insertReleaseInternal as unknown as WrappedHandler<
{
userId: string;
actorUserId: string;
ownerUserId: string;
ownerPublisherId?: string;
name: string;
displayName: string;
family: "skill" | "code-plugin" | "bundle-plugin";
@@ -100,6 +102,7 @@ const insertReleaseInternalHandler = (
compatibility?: unknown;
capabilities?: unknown;
verification?: unknown;
staticScan?: unknown;
extractedPackageJson?: unknown;
extractedPluginManifest?: unknown;
normalizedBundleManifest?: unknown;
@@ -148,7 +151,7 @@ const publishPackageHandler = (
const publishPackageForUserInternalHandler = (
publishPackageForUserInternal as unknown as WrappedHandler<
{
userId: string;
actorUserId: string;
payload: unknown;
},
unknown
@@ -182,6 +185,7 @@ function makeDigest(
isOfficial: false,
summary: `${name} summary`,
ownerUserId: "users:owner",
ownerPublisherId: undefined,
ownerHandle: "owner",
createdAt: 1,
updatedAt: 1,
@@ -204,6 +208,7 @@ function makePackageDoc(overrides: Partial<Record<string, unknown>> = {}) {
channel: "community",
isOfficial: false,
ownerUserId: "users:owner",
ownerPublisherId: undefined,
tags: {},
latestReleaseId: "packageReleases:demo-1",
latestVersionSummary: { version: "1.0.0" },
@@ -235,6 +240,7 @@ function makeDigestCtx(options: {
isDone: boolean;
continueCursor: string;
}>;
publisherMemberships?: Record<string, "owner" | "admin" | "publisher">;
}) {
const pageByTable = new Map<
string,
@@ -302,6 +308,38 @@ function makeDigestCtx(options: {
ctx: {
db: {
query: vi.fn((table: string) => {
if (table === "publisherMembers") {
return {
withIndex: vi.fn(
(
_indexName: string,
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
) => {
let publisherId = "";
const queryBuilder = {
eq: (field: string, value: string) => {
if (field === "publisherId") publisherId = value;
return queryBuilder;
},
};
builder?.(queryBuilder);
const role = options.publisherMemberships?.[publisherId];
return {
unique: vi.fn().mockResolvedValue(
role
? {
_id: `publisherMembers:${publisherId}`,
publisherId,
userId: "users:member",
role,
}
: null,
),
};
},
),
};
}
if (table !== "packageSearchDigest" && table !== "packageCapabilitySearchDigest") {
throw new Error(`Unexpected table ${table}`);
}
@@ -318,6 +356,7 @@ function makeDigestCtx(options: {
function makeInsertReleaseCtx(
existing: Record<string, unknown> | null,
priorReleases: Array<Record<string, unknown>> = [],
recordsById: Record<string, Record<string, unknown>> = {},
) {
const patch = vi.fn();
const insert = vi
@@ -328,7 +367,8 @@ function makeInsertReleaseCtx(
insert,
db: {
get: vi.fn(async (id: string) => {
if (id === "users:owner") return { _id: id, trustedPublisher: false };
if (id in recordsById) return recordsById[id];
if (id === "users:owner") return { _id: id, role: "user", trustedPublisher: false };
return null;
}),
query: vi.fn((table: string) => {
@@ -369,10 +409,13 @@ function makePackageCtx(options: {
latestRelease?: Record<string, unknown> | null;
versionRelease?: Record<string, unknown> | null;
versionsPage?: { page: Array<Record<string, unknown>>; isDone: boolean; continueCursor: string };
ownerPublisher?: Record<string, unknown> | null;
viewerMembershipRole?: "owner" | "admin" | "publisher" | null;
}) {
const pkg = options.pkg ?? makePackageDoc();
const latestRelease = options.latestRelease ?? makeReleaseDoc();
const versionRelease = options.versionRelease ?? latestRelease;
const ownerPublisher = options.ownerPublisher ?? null;
const versionsPage = options.versionsPage ?? {
page: [latestRelease].filter(Boolean),
isDone: true,
@@ -386,6 +429,7 @@ function makePackageCtx(options: {
db: {
get: vi.fn(async (id: string) => {
if (pkg && id === pkg.ownerUserId) return { _id: id, handle: "owner" };
if (ownerPublisher && pkg && id === pkg.ownerPublisherId) return ownerPublisher;
if (pkg && id === pkg.latestReleaseId) return latestRelease;
return null;
}),
@@ -426,6 +470,22 @@ function makePackageCtx(options: {
}),
};
}
if (table === "publisherMembers") {
return {
withIndex: vi.fn(() => ({
unique: vi.fn().mockResolvedValue(
options.viewerMembershipRole
? {
_id: "publisherMembers:1",
publisherId: pkg?.ownerPublisherId,
userId: "users:member",
role: options.viewerMembershipRole,
}
: null,
),
})),
};
}
throw new Error(`Unexpected table ${table}`);
}),
},
@@ -640,6 +700,35 @@ describe("packages public queries", () => {
expect(indexNames).toEqual(["by_active_channel_updated"]);
});
it("allows org collaborators to list their private packages", async () => {
const { ctx } = makeDigestCtx({
pages: [
{
page: [
makeDigest("secret-plugin", {
channel: "private",
ownerUserId: "users:owner",
ownerPublisherId: "publishers:org",
}),
makeDigest("public-plugin"),
],
isDone: true,
continueCursor: "",
},
],
publisherMemberships: {
"publishers:org": "publisher",
},
});
const result = await listPageForViewerInternalHandler(ctx, {
paginationOpts: { cursor: null, numItems: 10 },
viewerUserId: "users:member",
});
expect(result.page.map((entry) => entry.name)).toEqual(["secret-plugin", "public-plugin"]);
});
it("applies isOfficial filtering even with family and channel set", async () => {
const { ctx } = makeDigestCtx({
pages: [
@@ -787,6 +876,49 @@ describe("packages public queries", () => {
expect(result.map((entry) => entry.package.name)).toEqual(["secret-tools"]);
});
it("allows org collaborators to search their private packages", async () => {
const { ctx } = makeDigestCtx({
capabilityPages: [
{
page: [
makeDigest("secret-tools", {
channel: "private",
ownerUserId: "users:owner",
ownerPublisherId: "publishers:org",
executesCode: true,
capabilityTags: ["tools"],
capabilityTag: "tools",
}),
makeDigest("other-secret-tools", {
channel: "private",
ownerUserId: "users:other",
ownerPublisherId: "publishers:other",
executesCode: true,
capabilityTags: ["tools"],
capabilityTag: "tools",
}),
],
isDone: true,
continueCursor: "",
},
],
publisherMemberships: {
"publishers:org": "publisher",
},
});
const result = await searchForViewerInternalHandler(ctx, {
query: "secret",
executesCode: true,
capabilityTag: "tools",
channel: "private",
limit: 10,
viewerUserId: "users:member",
});
expect(result.map((entry) => entry.package.name)).toEqual(["secret-tools"]);
});
it("uses the executesCode index for filtered public listings", async () => {
const { ctx, indexNames, tableNames } = makeDigestCtx({
pages: [
@@ -1017,6 +1149,45 @@ describe("packages public queries", () => {
expect(version?.version.version).toBe("1.0.0");
});
it("allows org collaborators to read org-owned private packages", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:member" as never);
const { ctx } = makePackageCtx({
pkg: makePackageDoc({
channel: "private",
ownerUserId: "users:owner",
ownerPublisherId: "publishers:org",
}),
ownerPublisher: {
_id: "publishers:org",
_creationTime: 1,
kind: "org",
handle: "acme",
displayName: "Acme",
linkedUserId: undefined,
},
viewerMembershipRole: "publisher",
});
const detail = await getByNameHandler(ctx, {
name: "demo-plugin",
});
expect(detail?.package.name).toBe("demo-plugin");
});
it("treats auth resolution failures as anonymous for public package detail", async () => {
vi.mocked(getAuthUserId).mockRejectedValue(new Error("stale session"));
const { ctx } = makePackageCtx({
pkg: makePackageDoc({ channel: "community" }),
});
const detail = await getByNameHandler(ctx, {
name: "demo-plugin",
});
expect(detail?.package.name).toBe("demo-plugin");
});
it("does not expose a soft-deleted latest release as latestVersion", async () => {
const { ctx } = makePackageCtx({
latestRelease: makeReleaseDoc({ softDeletedAt: 10 }),
@@ -1101,7 +1272,8 @@ describe("packages public queries", () => {
await expect(
insertReleaseInternalHandler(ctx, {
userId: "users:owner",
actorUserId: "users:owner",
ownerUserId: "users:owner",
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
@@ -1120,7 +1292,8 @@ describe("packages public queries", () => {
await expect(
insertReleaseInternalHandler(ctx, {
userId: "users:owner",
actorUserId: "users:owner",
ownerUserId: "users:owner",
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
@@ -1149,7 +1322,8 @@ describe("packages public queries", () => {
});
await insertReleaseInternalHandler(ctx, {
userId: "users:owner",
actorUserId: "users:owner",
ownerUserId: "users:owner",
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
@@ -1171,6 +1345,159 @@ describe("packages public queries", () => {
);
});
it("lets admins publish package releases on behalf of another owner", async () => {
const ctx = makeInsertReleaseCtx(
makePackageDoc({
ownerUserId: "users:openclaw",
channel: "official",
isOfficial: true,
stats: { downloads: 0, installs: 0, stars: 0, versions: 1 },
}),
[],
{
"users:admin": { _id: "users:admin", role: "admin", trustedPublisher: false },
"users:openclaw": { _id: "users:openclaw", role: "user", trustedPublisher: true },
},
);
await insertReleaseInternalHandler(ctx, {
actorUserId: "users:admin",
ownerUserId: "users:openclaw",
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
version: "1.1.0",
changelog: "promote",
tags: ["latest"],
summary: "demo",
files: [],
integritySha256: "abc123",
channel: "official",
});
expect(ctx.insert).toHaveBeenCalledWith(
"packageReleases",
expect.objectContaining({
createdBy: "users:admin",
}),
);
});
it("rejects non-admin publishes on behalf of another owner", async () => {
const ctx = makeInsertReleaseCtx(
makePackageDoc({
ownerUserId: "users:openclaw",
stats: { downloads: 0, installs: 0, stars: 0, versions: 1 },
}),
[],
{
"users:owner": { _id: "users:owner", role: "user", trustedPublisher: false },
"users:openclaw": { _id: "users:openclaw", role: "user", trustedPublisher: true },
},
);
await expect(
insertReleaseInternalHandler(ctx, {
actorUserId: "users:owner",
ownerUserId: "users:openclaw",
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
version: "1.1.0",
changelog: "promote",
tags: ["latest"],
summary: "demo",
files: [],
integritySha256: "abc123",
}),
).rejects.toThrow("Forbidden");
});
it("rejects publishing the same package name across different publishers", async () => {
const ctx = makeInsertReleaseCtx(
makePackageDoc({
ownerUserId: "users:owner",
ownerPublisherId: undefined,
stats: { downloads: 0, installs: 0, stars: 0, versions: 1 },
}),
[],
{
"users:owner": { _id: "users:owner", role: "user", trustedPublisher: false },
"publishers:org": {
_id: "publishers:org",
kind: "org",
handle: "acme",
displayName: "Acme",
trustedPublisher: false,
},
},
);
await expect(
insertReleaseInternalHandler(ctx, {
actorUserId: "users:owner",
ownerUserId: "users:owner",
ownerPublisherId: "publishers:org",
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
version: "1.1.0",
changelog: "org release",
tags: ["latest"],
summary: "demo",
files: [],
integritySha256: "abc123",
}),
).rejects.toThrow("Package already exists and belongs to another publisher");
});
it("treats a legacy personal package as the same personal publisher", async () => {
const ctx = makeInsertReleaseCtx(
makePackageDoc({
ownerUserId: "users:owner",
ownerPublisherId: undefined,
stats: { downloads: 0, installs: 0, stars: 0, versions: 1 },
}),
[],
{
"users:owner": { _id: "users:owner", role: "user", trustedPublisher: false },
"publishers:owner": {
_id: "publishers:owner",
kind: "user",
handle: "owner",
displayName: "Owner",
linkedUserId: "users:owner",
trustedPublisher: false,
},
},
);
await expect(
insertReleaseInternalHandler(ctx, {
actorUserId: "users:owner",
ownerUserId: "users:owner",
ownerPublisherId: "publishers:owner",
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
version: "1.1.0",
changelog: "personal release",
tags: ["latest"],
summary: "demo",
files: [],
integritySha256: "abc123",
}),
).resolves.toMatchObject({ ok: true, packageId: "packages:demo" });
expect(ctx.patch).toHaveBeenCalledWith(
"packages:demo",
expect.objectContaining({
ownerUserId: "users:owner",
ownerPublisherId: "publishers:owner",
}),
);
});
it("does not overwrite capability search fields for non-latest releases", async () => {
const ctx = makeInsertReleaseCtx(
makePackageDoc({
@@ -1183,7 +1510,8 @@ describe("packages public queries", () => {
);
await insertReleaseInternalHandler(ctx, {
userId: "users:owner",
actorUserId: "users:owner",
ownerUserId: "users:owner",
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
@@ -1216,7 +1544,8 @@ describe("packages public queries", () => {
);
await insertReleaseInternalHandler(ctx, {
userId: "users:owner",
actorUserId: "users:owner",
ownerUserId: "users:owner",
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
@@ -1248,7 +1577,8 @@ describe("packages public queries", () => {
);
await insertReleaseInternalHandler(ctx, {
userId: "users:owner",
actorUserId: "users:owner",
ownerUserId: "users:owner",
name: "demo-plugin",
displayName: "Demo Plugin",
family: "bundle-plugin",
@@ -1285,7 +1615,8 @@ describe("packages public queries", () => {
);
await insertReleaseInternalHandler(ctx, {
userId: "users:owner",
actorUserId: "users:owner",
ownerUserId: "users:owner",
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
@@ -1313,7 +1644,8 @@ describe("packages public queries", () => {
);
await insertReleaseInternalHandler(ctx, {
userId: "users:owner",
actorUserId: "users:owner",
ownerUserId: "users:owner",
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
@@ -1323,12 +1655,27 @@ describe("packages public queries", () => {
summary: "demo",
files: [],
integritySha256: "abc123",
verification: {
tier: "source-linked",
scope: "artifact-only",
scanStatus: "suspicious",
},
staticScan: {
status: "suspicious",
reasonCodes: ["suspicious.dynamic_code_execution"],
findings: [],
summary: "Detected: suspicious.dynamic_code_execution",
engineVersion: "test",
checkedAt: 123,
},
});
expect(ctx.insert).toHaveBeenCalledWith(
"packageReleases",
expect.objectContaining({
distTags: ["beta", "latest"],
verification: expect.objectContaining({ scanStatus: "suspicious" }),
staticScan: expect.objectContaining({ status: "suspicious" }),
}),
);
expect(ctx.patch).toHaveBeenCalledWith(
@@ -1343,7 +1690,7 @@ describe("packages public queries", () => {
it("validates package publish payloads inside the action path", async () => {
await expect(
publishPackageForUserInternalHandler({} as never, {
userId: "users:owner",
actorUserId: "users:owner",
payload: {
name: "demo-plugin",
family: "bundle-plugin",
@@ -1359,7 +1706,7 @@ describe("packages public queries", () => {
it("rejects skill publishes on the package endpoint", async () => {
await expect(
publishPackageForUserInternalHandler({} as never, {
userId: "users:owner",
actorUserId: "users:owner",
payload: {
name: "demo-skill",
family: "skill",
@@ -1371,6 +1718,149 @@ describe("packages public queries", () => {
).rejects.toThrow("Skill packages must use the skills publish flow");
});
it("scans plugin publishes and forwards scan status to insertReleaseInternal", async () => {
const runMutation = vi.fn(async (_ref: unknown, args: unknown) => args);
const ctx = {
runQuery: vi
.fn()
.mockResolvedValueOnce({
_id: "users:owner",
githubCreatedAt: Date.now() - 20 * 24 * 60 * 60 * 1000,
})
.mockResolvedValueOnce(null),
runMutation,
scheduler: {
runAfter: vi.fn(),
},
storage: {
get: vi.fn(async (storageId: string) => {
const files = new Map<string, string>([
[
"storage:package",
JSON.stringify({
name: "demo-plugin",
openclaw: {
extensions: ["./dist/index.js"],
compat: { pluginApi: "^1.0.0" },
build: { openclawVersion: "2026.3.14" },
configSchema: { type: "object" },
},
}),
],
["storage:manifest", JSON.stringify({ id: "demo.plugin", tools: [{ name: "demoTool" }] })],
["storage:code", "import { execSync } from 'node:child_process';\nexecSync('curl http://x');\n"],
]);
const content = files.get(storageId);
return content ? new Blob([content]) : null;
}),
},
};
const result = (await publishPackageForUserInternalHandler(ctx as never, {
actorUserId: "users:owner",
payload: {
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
version: "1.0.0",
changelog: "init",
source: {
kind: "github",
url: "https://github.com/openclaw/demo-plugin",
repo: "openclaw/demo-plugin",
ref: "refs/tags/v1.0.0",
commit: "abc123",
path: ".",
importedAt: Date.now(),
},
files: [
{
path: "package.json",
size: 1,
storageId: "storage:package",
sha256: "package",
contentType: "application/json",
},
{
path: "openclaw.plugin.json",
size: 1,
storageId: "storage:manifest",
sha256: "manifest",
contentType: "application/json",
},
{
path: "dist/index.js",
size: 1,
storageId: "storage:code",
sha256: "code",
contentType: "application/javascript",
},
],
},
})) as Record<string, unknown>;
expect(runMutation).toHaveBeenCalled();
expect(result.verification).toEqual(expect.objectContaining({ scanStatus: "pending" }));
expect(result.staticScan).toEqual(
expect.objectContaining({
status: "suspicious",
reasonCodes: expect.arrayContaining(["suspicious.dangerous_exec"]),
}),
);
});
it("hides pending-scan packages from public reads", async () => {
vi.mocked(getAuthUserId).mockResolvedValue(null);
const ctx = {
db: {
get: vi.fn(async (id: string) => {
if (id === "packageReleases:demo-1") return makeReleaseDoc({ version: "1.0.0" });
if (id === "users:owner") return { _id: "users:owner", handle: "owner" };
return null;
}),
query: vi.fn((table: string) => {
if (table !== "packages") throw new Error(`Unexpected table ${table}`);
return {
withIndex: vi.fn(() => ({
unique: vi.fn().mockResolvedValue(makePackageDoc({ scanStatus: "pending" })),
})),
};
}),
},
};
const result = await getByNameHandler(ctx as never, { name: "demo-plugin" });
expect(result).toBeNull();
});
it("keeps pending-scan packages visible to the owner", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
const result = await getByNameHandler(
{
db: {
get: vi.fn(async (id: string) => {
if (id === "packageReleases:demo-1") return makeReleaseDoc({ version: "1.0.0" });
if (id === "users:owner") return { _id: "users:owner", handle: "owner" };
return null;
}),
query: vi.fn((table: string) => {
if (table !== "packages") throw new Error(`Unexpected table ${table}`);
return {
withIndex: vi.fn(() => ({
unique: vi.fn().mockResolvedValue(
makePackageDoc({ ownerUserId: "users:owner", scanStatus: "pending" }),
),
})),
};
}),
},
} as never,
{ name: "demo-plugin" },
);
expect(result?.package?.name).toBe("demo-plugin");
});
it("requires auth inside the public publish action", async () => {
await expect(
publishPackageHandler({ runQuery: vi.fn(), runMutation: vi.fn() } as never, {
+483 -51
View File
@@ -13,7 +13,7 @@ import { action, internalAction, internalMutation, internalQuery, query } from "
import type { Doc, Id } from "./_generated/dataModel";
import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server";
import { requireGitHubAccountAge } from "./lib/githubAccount";
import { assertModerator, requireUserFromAction } from "./lib/access";
import { assertAdmin, assertModerator, requireUserFromAction } from "./lib/access";
import {
assertPackageVersion,
ensurePluginNameMatchesPackage,
@@ -25,18 +25,31 @@ import {
readOptionalTextFile,
summarizePackageForSearch,
} from "./lib/packageRegistry";
import { toPublicUser } from "./lib/public";
import {
findOversizedPublishFile,
getPublishFileSizeError,
getPublishTotalSizeError,
MAX_PUBLISH_TOTAL_BYTES,
} from "./lib/publishLimits";
import { getOwnerPublisher, getPublisherMembership } from "./lib/publishers";
import { toPublicPublisher } from "./lib/public";
import { runStaticPublishScan } from "./lib/staticPublishScan";
import { hashSkillFiles } from "./lib/skills";
const MAX_PACKAGE_BYTES = 50 * 1024 * 1024;
const MAX_PACKAGE_SCAN_DOCUMENTS = 30_000;
const MAX_PUBLIC_LIST_SCAN_PAGES = 200;
const MAX_SEARCH_PAGE_SIZE = 200;
const MAX_SEARCH_SCAN_PAGES = 200;
const internalRefs = internal as unknown as {
llmEval: {
evaluatePackageReleaseWithLlm: unknown;
};
packages: {
backfillPackageReleaseScansInternal: unknown;
insertReleaseInternal: unknown;
getByNameForViewerInternal: unknown;
getPackageByIdInternal: unknown;
getPackageReleaseScanBackfillBatchInternal: unknown;
listVersionsForViewerInternal: unknown;
getVersionByNameForViewerInternal: unknown;
publishPackageForUserInternal: unknown;
@@ -44,6 +57,16 @@ const internalRefs = internal as unknown as {
skills: {
getSkillBySlugInternal: unknown;
};
users: {
getByIdInternal: unknown;
getByHandleInternal: unknown;
};
publishers: {
resolvePublishTargetForUserInternal: unknown;
};
vt: {
scanPackageReleaseWithVirusTotal: unknown;
};
};
type DbReaderCtx = Pick<QueryCtx | MutationCtx, "db">;
type PublicPackageListItem = {
@@ -73,14 +96,17 @@ type PackageDigestLike = Pick<
| "channel"
| "isOfficial"
| "ownerUserId"
| "ownerPublisherId"
| "summary"
| "ownerHandle"
| "ownerKind"
| "createdAt"
| "updatedAt"
| "latestVersion"
| "capabilityTags"
| "executesCode"
| "verificationTier"
| "scanStatus"
| "softDeletedAt"
> & {
capabilityTag?: string;
@@ -109,6 +135,36 @@ async function runMutationRef<T>(
return (await ctx.runMutation(ref as never, args as never)) as T;
}
async function runActionRef<T>(
ctx: { runAction: (ref: never, args: never) => Promise<unknown> },
ref: unknown,
args: unknown,
): Promise<T> {
return (await ctx.runAction(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);
}
function toPackageScanStatus(status: string | undefined): Doc<"packages">["scanStatus"] {
switch (status) {
case "clean":
case "suspicious":
case "malicious":
case "pending":
case "not-run":
return status;
default:
return undefined;
}
}
type PublicPackageDoc = {
_id: Id<"packages">;
name: string;
@@ -124,10 +180,38 @@ type PublicPackageDoc = {
compatibility?: Doc<"packages">["compatibility"];
capabilities?: Doc<"packages">["capabilities"];
verification?: Doc<"packages">["verification"];
scanStatus?: Doc<"packages">["scanStatus"];
createdAt: number;
updatedAt: number;
};
function isPackageBlockedFromPublic(scanStatus: Doc<"packages">["scanStatus"]) {
return scanStatus === "pending" || scanStatus === "malicious";
}
async function viewerCanAccessPackageOwner(
ctx: DbReaderCtx,
digest: Pick<PackageDigestLike, "ownerUserId" | "ownerPublisherId">,
viewerUserId: Id<"users"> | undefined,
membershipCache?: Map<string, Promise<boolean>>,
) {
if (!viewerUserId) return false;
if (digest.ownerUserId === viewerUserId) return true;
if (!digest.ownerPublisherId) return false;
const cacheKey = String(digest.ownerPublisherId);
const cached = membershipCache?.get(cacheKey);
if (cached) return await cached;
const membershipPromise = getPublisherMembership(
ctx,
digest.ownerPublisherId,
viewerUserId,
).then(Boolean);
membershipCache?.set(cacheKey, membershipPromise);
return await membershipPromise;
}
function toPublicPackage(
pkg: Doc<"packages"> | null | undefined,
latestRelease?: Pick<Doc<"packageReleases">, "version" | "softDeletedAt"> | null,
@@ -154,6 +238,7 @@ function toPublicPackage(
compatibility: pkg.compatibility,
capabilities: pkg.capabilities,
verification: pkg.verification,
scanStatus: pkg.scanStatus,
createdAt: pkg.createdAt,
updatedAt: pkg.updatedAt,
};
@@ -222,6 +307,15 @@ function decodePublicPageCursor(raw: string | null | undefined): PublicPageCurso
}
}
async function getOptionalViewerUserId(ctx: Parameters<typeof getAuthUserId>[0]) {
try {
return (await getAuthUserId(ctx)) ?? undefined;
} catch {
// Public package reads should degrade to anonymous when session resolution fails.
return undefined;
}
}
function packageSearchScore(digest: PackageDigestLike, queryText: string) {
const needle = queryText.toLowerCase();
const normalized = digest.normalizedName.toLowerCase();
@@ -478,20 +572,27 @@ async function getReadablePackageByName(
const normalizedName = normalizePackageName(name);
const pkg = await getPackageByNormalizedName(ctx, normalizedName);
if (!pkg || pkg.softDeletedAt) return null;
if (pkg.channel === "private" && pkg.ownerUserId !== viewerUserId) return null;
const isPrivilegedViewer = await viewerCanAccessPackageOwner(ctx, pkg, viewerUserId);
if (pkg.channel === "private" && !isPrivilegedViewer) return null;
if (isPackageBlockedFromPublic(pkg.scanStatus) && !isPrivilegedViewer) return null;
return pkg;
}
export const getByName = query({
args: { name: v.string() },
handler: async (ctx, args) => {
const viewerUserId = (await getAuthUserId(ctx)) ?? undefined;
const viewerUserId = await getOptionalViewerUserId(ctx);
const pkg = await getReadablePackageByName(ctx, args.name, viewerUserId);
if (!pkg) return null;
const latestRelease = pkg.latestReleaseId ? await ctx.db.get(pkg.latestReleaseId) : null;
const publicPackage = toPublicPackage(pkg, latestRelease);
if (!publicPackage) return null;
const owner = toPublicUser(await ctx.db.get(pkg.ownerUserId));
const owner = toPublicPublisher(
await getOwnerPublisher(ctx, {
ownerPublisherId: pkg.ownerPublisherId,
ownerUserId: pkg.ownerUserId,
}),
);
return {
package: publicPackage,
latestRelease: latestRelease && !latestRelease.softDeletedAt ? latestRelease : null,
@@ -511,7 +612,12 @@ export const getByNameForViewerInternal = internalQuery({
const latestRelease = pkg.latestReleaseId ? await ctx.db.get(pkg.latestReleaseId) : null;
const publicPackage = toPublicPackage(pkg, latestRelease);
if (!publicPackage) return null;
const owner = toPublicUser(await ctx.db.get(pkg.ownerUserId));
const owner = toPublicPublisher(
await getOwnerPublisher(ctx, {
ownerPublisherId: pkg.ownerPublisherId,
ownerUserId: pkg.ownerUserId,
}),
);
return {
package: publicPackage,
latestRelease: latestRelease && !latestRelease.softDeletedAt ? latestRelease : null,
@@ -526,7 +632,7 @@ export const listVersions = query({
paginationOpts: paginationOptsValidator,
},
handler: async (ctx, args) => {
const viewerUserId = (await getAuthUserId(ctx)) ?? undefined;
const viewerUserId = await getOptionalViewerUserId(ctx);
const pkg = await getReadablePackageByName(ctx, args.name, viewerUserId);
if (!pkg) return { page: [], isDone: true, continueCursor: "" };
return await ctx.db
@@ -564,7 +670,7 @@ export const getVersionByName = query({
version: v.string(),
},
handler: async (ctx, args) => {
const viewerUserId = (await getAuthUserId(ctx)) ?? undefined;
const viewerUserId = await getOptionalViewerUserId(ctx);
const pkg = await getReadablePackageByName(ctx, args.name, viewerUserId);
if (!pkg) return null;
const publicPackage = toPublicPackage(pkg);
@@ -653,8 +759,19 @@ async function listPackagePageImpl(
return { page: [], isDone: true, continueCursor: "" };
}
const viewerUserId = args.viewerUserId;
const canViewPackage = (digest: PackageDigestLike) =>
digest.channel !== "private" || digest.ownerUserId === viewerUserId;
const membershipCache = new Map<string, Promise<boolean>>();
const canViewPackage = async (digest: PackageDigestLike) => {
const isPrivilegedViewer = await viewerCanAccessPackageOwner(
ctx,
digest,
viewerUserId,
membershipCache,
);
return (
(digest.channel !== "private" || isPrivilegedViewer) &&
(!isPackageBlockedFromPublic(digest.scanStatus) || isPrivilegedViewer)
);
};
const targetCount = args.paginationOpts.numItems;
const collected: PublicPackageListItem[] = [];
const decodedCursor = decodePublicPageCursor(args.paginationOpts.cursor);
@@ -698,7 +815,7 @@ async function listPackagePageImpl(
} = await builder.order("desc").paginate({ cursor: pageCursor, numItems: effectivePageSize });
for (let index = offset; index < page.page.length; index += 1) {
const digest = page.page[index] as PackageDigestLike;
if (!canViewPackage(digest)) continue;
if (!(await canViewPackage(digest))) continue;
if (channel && digest.channel !== channel) continue;
if (typeof isOfficial === "boolean" && digest.isOfficial !== isOfficial) {
continue;
@@ -791,8 +908,19 @@ async function searchPackagesImpl(
if (args.channel === "private" && !args.viewerUserId) return [];
const targetCount = Math.max(1, Math.min(args.limit ?? 20, 100));
const viewerUserId = args.viewerUserId;
const canViewPackage = (digest: PackageDigestLike) =>
digest.channel !== "private" || digest.ownerUserId === viewerUserId;
const membershipCache = new Map<string, Promise<boolean>>();
const canViewPackage = async (digest: PackageDigestLike) => {
const isPrivilegedViewer = await viewerCanAccessPackageOwner(
ctx,
digest,
viewerUserId,
membershipCache,
);
return (
(digest.channel !== "private" || isPrivilegedViewer) &&
(!isPackageBlockedFromPublic(digest.scanStatus) || isPrivilegedViewer)
);
};
const builder = args.capabilityTag
? buildPackageCapabilityDigestQuery(ctx, {
capabilityTag: args.capabilityTag,
@@ -826,7 +954,7 @@ async function searchPackagesImpl(
continueCursor: string;
} = await builder.order("desc").paginate({ cursor, numItems: effectivePageSize });
for (const digest of page.page) {
if (!canViewPackage(digest)) continue;
if (!(await canViewPackage(digest))) continue;
if (args.channel && digest.channel !== args.channel) continue;
if (typeof args.isOfficial === "boolean" && digest.isOfficial !== args.isOfficial) {
continue;
@@ -922,6 +1050,13 @@ export const getReleaseByIdInternal = internalQuery({
},
});
export const getPackageByIdInternal = internalQuery({
args: { packageId: v.id("packages") },
handler: async (ctx, args) => {
return await ctx.db.get(args.packageId);
},
});
export const getReleaseByPackageAndVersionInternal = internalQuery({
args: {
packageId: v.id("packages"),
@@ -949,9 +1084,53 @@ export const getReleasesByIdsInternal = internalQuery({
},
});
export const getPackageReleaseScanBackfillBatchInternal = internalQuery({
args: {
cursor: v.optional(v.number()),
batchSize: v.optional(v.number()),
},
handler: async (ctx, args) => {
const batchSize = Math.max(1, Math.min(args.batchSize ?? 50, 200));
const cursor = args.cursor ?? 0;
const releases = await ctx.db
.query("packageReleases")
.withIndex("by_creation_time", (q) => q.gt("_creationTime", cursor))
.order("asc")
.take(batchSize * 3);
const results: Array<{ releaseId: Id<"packageReleases">; packageId: Id<"packages"> }> = [];
let nextCursor = cursor;
for (const release of releases) {
nextCursor = release._creationTime;
if (results.length >= batchSize) break;
if (release.softDeletedAt) continue;
const pkg = await ctx.db.get(release.packageId);
if (!pkg || pkg.softDeletedAt || pkg.family === "skill") continue;
const needsVt = !release.sha256hash || !release.vtAnalysis;
const needsLlm = !release.llmAnalysis || release.llmAnalysis.status === "error";
if (!needsVt && !needsLlm) continue;
results.push({
releaseId: release._id,
packageId: release.packageId,
});
}
return {
releases: results,
nextCursor,
done: releases.length < batchSize * 3,
};
},
});
async function publishPackageImpl(
ctx: Parameters<typeof requireGitHubAccountAge>[0] & Pick<ActionCtx, "storage">,
userId: Id<"users">,
ctx: Parameters<typeof requireGitHubAccountAge>[0] & Pick<ActionCtx, "storage" | "scheduler">,
actorUserId: Id<"users">,
rawPayload: unknown,
) {
const payload = parseArk(
@@ -962,16 +1141,30 @@ async function publishPackageImpl(
if (payload.family === "skill") {
throw new ConvexError("Skill packages must use the skills publish flow");
}
await requireGitHubAccountAge(ctx, userId);
await requireGitHubAccountAge(ctx, actorUserId);
const ownerTarget = await runQueryRef<{
publisherId: Id<"publishers">;
linkedUserId?: Id<"users">;
} | null>(ctx, internalRefs.publishers.resolvePublishTargetForUserInternal, {
actorUserId,
ownerHandle: payload.ownerHandle,
minimumRole: "publisher",
});
const ownerUserId = ownerTarget?.linkedUserId ?? actorUserId;
const ownerPublisherId = ownerTarget?.publisherId;
const family = payload.family;
const name = normalizePackageName(payload.name);
const version = assertPackageVersion(family, payload.version);
const displayName = payload.displayName?.trim() || name;
const files = normalizePublishFiles(payload.files as never);
const oversizedFile = findOversizedPublishFile(files);
if (oversizedFile) {
throw new ConvexError(getPublishFileSizeError(oversizedFile.path));
}
const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
if (totalBytes > MAX_PACKAGE_BYTES) {
throw new ConvexError("Package exceeds 50MB limit");
if (totalBytes > MAX_PUBLISH_TOTAL_BYTES) {
throw new ConvexError(getPublishTotalSizeError("package"));
}
const existingSkill = await runQueryRef(ctx, internalRefs.skills.getSkillBySlugInternal, {
@@ -1026,32 +1219,70 @@ async function publishPackageImpl(
packageJson,
readmeText: readmeEntry?.text ?? null,
});
const staticScan = await runStaticPublishScan(ctx, {
slug: name,
displayName,
summary,
metadata: {
packageJson,
pluginManifest: maybeParseJson(pluginManifestEntry?.text),
bundleManifest: maybeParseJson(bundleManifestEntry?.text),
source: payload.source,
},
files,
});
const verificationSource = codeArtifacts?.verification ?? bundleArtifacts?.verification;
const initialScanStatus = staticScan.status === "malicious" ? "malicious" : "pending";
const verification = verificationSource
? {
...verificationSource,
scanStatus: initialScanStatus,
}
: undefined;
const integritySha256 = await hashSkillFiles(
files.map((file) => ({ path: file.path, sha256: file.sha256 })),
);
return await runMutationRef(ctx, internalRefs.packages.insertReleaseInternal, {
userId,
name,
displayName,
family,
version,
changelog: payload.changelog.trim(),
tags: payload.tags?.map((tag: string) => tag.trim()).filter(Boolean) ?? ["latest"],
summary,
sourceRepo: payload.source?.repo || payload.source?.url,
runtimeId: codeArtifacts?.runtimeId ?? bundleArtifacts?.runtimeId,
channel: payload.channel,
compatibility: codeArtifacts?.compatibility ?? bundleArtifacts?.compatibility,
capabilities: codeArtifacts?.capabilities ?? bundleArtifacts?.capabilities,
verification: codeArtifacts?.verification ?? bundleArtifacts?.verification,
files,
integritySha256,
extractedPackageJson: packageJson,
extractedPluginManifest: family === "code-plugin" ? maybeParseJson(pluginManifestEntry?.text) : undefined,
normalizedBundleManifest: family === "bundle-plugin" ? maybeParseJson(bundleManifestEntry?.text) : undefined,
source: payload.source,
const publishResult = await runMutationRef<{ ok: true; packageId: Id<"packages">; releaseId: Id<"packageReleases"> }>(
ctx,
internalRefs.packages.insertReleaseInternal,
{
actorUserId,
ownerUserId,
ownerPublisherId,
name,
displayName,
family,
version,
changelog: payload.changelog.trim(),
tags: payload.tags?.map((tag: string) => tag.trim()).filter(Boolean) ?? ["latest"],
summary,
sourceRepo: payload.source?.repo || payload.source?.url,
runtimeId: codeArtifacts?.runtimeId ?? bundleArtifacts?.runtimeId,
channel: payload.channel,
compatibility: codeArtifacts?.compatibility ?? bundleArtifacts?.compatibility,
capabilities: codeArtifacts?.capabilities ?? bundleArtifacts?.capabilities,
verification,
staticScan,
files,
integritySha256,
extractedPackageJson: packageJson,
extractedPluginManifest:
family === "code-plugin" ? maybeParseJson(pluginManifestEntry?.text) : undefined,
normalizedBundleManifest:
family === "bundle-plugin" ? maybeParseJson(bundleManifestEntry?.text) : undefined,
source: payload.source,
},
);
await runAfterRef(ctx, 0, internalRefs.vt.scanPackageReleaseWithVirusTotal, {
releaseId: publishResult.releaseId,
});
await runAfterRef(ctx, 0, internalRefs.llmEval.evaluatePackageReleaseWithLlm, {
releaseId: publishResult.releaseId,
});
return publishResult;
}
export const publishPackage = action({
@@ -1064,11 +1295,11 @@ export const publishPackage = action({
export const publishPackageForUserInternal = internalAction({
args: {
userId: v.id("users"),
actorUserId: v.id("users"),
payload: v.any(),
},
handler: async (ctx, args) => {
return await publishPackageImpl(ctx, args.userId, args.payload);
return await publishPackageImpl(ctx, args.actorUserId, args.payload);
},
});
@@ -1082,7 +1313,9 @@ export const publishRelease = action({
export const insertReleaseInternal = internalMutation({
args: {
userId: v.id("users"),
actorUserId: v.id("users"),
ownerUserId: v.id("users"),
ownerPublisherId: v.optional(v.id("publishers")),
name: v.string(),
displayName: v.string(),
family: v.union(v.literal("skill"), v.literal("code-plugin"), v.literal("bundle-plugin")),
@@ -1096,6 +1329,7 @@ export const insertReleaseInternal = internalMutation({
compatibility: v.optional(v.any()),
capabilities: v.optional(v.any()),
verification: v.optional(v.any()),
staticScan: v.optional(v.any()),
files: v.array(
v.object({
path: v.string(),
@@ -1114,9 +1348,18 @@ export const insertReleaseInternal = internalMutation({
handler: async (ctx, args) => {
const now = Date.now();
const normalizedName = normalizePackageName(args.name);
const owner = await ctx.db.get(args.userId);
const actor = await ctx.db.get(args.actorUserId);
if (!actor) throw new ConvexError("Unauthorized");
const owner = await ctx.db.get(args.ownerUserId);
if (!owner) throw new ConvexError("Unauthorized");
if (args.channel === "official" && !owner.trustedPublisher) {
const ownerPublisher = args.ownerPublisherId
? await ctx.db.get(args.ownerPublisherId)
: null;
if (args.ownerUserId !== args.actorUserId) {
assertAdmin(actor);
}
const publisherTrusted = ownerPublisher?.trustedPublisher ?? owner.trustedPublisher;
if (args.channel === "official" && !publisherTrusted) {
throw new ConvexError("Only trusted publishers may publish to the official channel");
}
const existing = await getPackageByNormalizedName(ctx, normalizedName);
@@ -1124,12 +1367,29 @@ export const insertReleaseInternal = internalMutation({
args.channel ??
(existing?.channel === "private"
? "private"
: owner.trustedPublisher
: publisherTrusted
? "official"
: "community");
const nextIsOfficial = nextChannel === "official";
if (existing && existing.ownerUserId !== args.userId) {
throw new ConvexError("Package already exists and belongs to another user");
if (existing) {
const existingIsLegacyPersonalPackage =
!existing.ownerPublisherId &&
Boolean(
args.ownerPublisherId &&
ownerPublisher?.kind === "user" &&
ownerPublisher.linkedUserId === existing.ownerUserId,
);
const existingOwnerKey = existing.ownerPublisherId
? `publisher:${existing.ownerPublisherId}`
: existingIsLegacyPersonalPackage
? `publisher:${args.ownerPublisherId}`
: `user:${existing.ownerUserId}`;
const nextOwnerKey = args.ownerPublisherId
? `publisher:${args.ownerPublisherId}`
: `user:${args.ownerUserId}`;
if (existingOwnerKey !== nextOwnerKey) {
throw new ConvexError("Package already exists and belongs to another publisher");
}
}
if (existing && existing.family !== args.family) {
throw new ConvexError(
@@ -1164,7 +1424,8 @@ export const insertReleaseInternal = internalMutation({
normalizedName,
displayName: args.displayName,
summary: args.summary,
ownerUserId: args.userId,
ownerUserId: args.ownerUserId,
ownerPublisherId: args.ownerPublisherId,
family: args.family,
channel: nextChannel,
isOfficial: nextIsOfficial,
@@ -1176,6 +1437,7 @@ export const insertReleaseInternal = internalMutation({
compatibility: args.compatibility,
capabilities: args.capabilities,
verification: args.verification,
scanStatus: args.verification?.scanStatus,
stats: { downloads: 0, installs: 0, stars: 0, versions: 0 },
createdAt: now,
updatedAt: now,
@@ -1214,8 +1476,9 @@ export const insertReleaseInternal = internalMutation({
compatibility: args.compatibility,
capabilities: args.capabilities,
verification: args.verification,
staticScan: args.staticScan,
source: args.source,
createdBy: args.userId,
createdBy: args.actorUserId,
createdAt: now,
});
@@ -1232,6 +1495,8 @@ export const insertReleaseInternal = internalMutation({
await ctx.db.patch(pkgId, {
displayName: args.displayName,
ownerUserId: args.ownerUserId,
ownerPublisherId: args.ownerPublisherId ?? pkg.ownerPublisherId,
summary: shouldPromoteLatest ? args.summary : pkg.summary,
sourceRepo: args.sourceRepo,
runtimeId: shouldPromoteLatest ? args.runtimeId : pkg.runtimeId,
@@ -1260,6 +1525,7 @@ export const insertReleaseInternal = internalMutation({
compatibility: shouldPromoteLatest ? args.compatibility : pkg.compatibility,
capabilities: shouldPromoteLatest ? args.capabilities : pkg.capabilities,
verification: shouldPromoteLatest ? args.verification : pkg.verification,
scanStatus: shouldPromoteLatest ? args.verification?.scanStatus : pkg.scanStatus,
stats: { ...pkg.stats, versions: (pkg.stats?.versions ?? 0) + 1 },
updatedAt: now,
});
@@ -1271,3 +1537,169 @@ export const insertReleaseInternal = internalMutation({
};
},
});
function isReleaseActive(release: Doc<"packageReleases"> | null | undefined) {
return Boolean(release && !release.softDeletedAt);
}
async function syncLatestPackageVerification(
ctx: MutationCtx,
release: Doc<"packageReleases">,
scanStatus: Doc<"packages">["scanStatus"],
) {
const pkg = await ctx.db.get(release.packageId);
if (!pkg || pkg.latestReleaseId !== release._id) return;
const nextVerification = pkg.verification
? {
...pkg.verification,
scanStatus,
}
: pkg.latestVersionSummary?.verification
? {
...pkg.latestVersionSummary.verification,
scanStatus,
}
: undefined;
await ctx.db.patch(pkg._id, {
verification: nextVerification,
scanStatus,
latestVersionSummary: pkg.latestVersionSummary
? {
...pkg.latestVersionSummary,
verification: nextVerification,
}
: pkg.latestVersionSummary,
});
}
export const updateReleaseScanResultsInternal = internalMutation({
args: {
releaseId: v.id("packageReleases"),
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(),
}),
),
},
handler: async (ctx, args) => {
const release = await ctx.db.get(args.releaseId);
if (!release || release.softDeletedAt) return;
const activeRelease = release;
const patch: Partial<Doc<"packageReleases">> = {};
if (args.sha256hash !== undefined) patch.sha256hash = args.sha256hash;
if (args.vtAnalysis !== undefined) {
const nextScanStatus = toPackageScanStatus(args.vtAnalysis.status) ?? "pending";
patch.vtAnalysis = args.vtAnalysis;
patch.verification = activeRelease.verification
? {
...activeRelease.verification,
scanStatus: nextScanStatus,
}
: activeRelease.verification;
}
if (Object.keys(patch).length > 0) {
await ctx.db.patch(args.releaseId, patch);
}
if (args.vtAnalysis !== undefined) {
const nextScanStatus = toPackageScanStatus(args.vtAnalysis.status) ?? "pending";
await syncLatestPackageVerification(
ctx,
{ ...activeRelease, ...patch } as Doc<"packageReleases">,
nextScanStatus,
);
}
},
});
export const updateReleaseLlmAnalysisInternal = internalMutation({
args: {
releaseId: v.id("packageReleases"),
llmAnalysis: 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(),
}),
},
handler: async (ctx, args) => {
const release = await ctx.db.get(args.releaseId);
if (!isReleaseActive(release)) return;
await ctx.db.patch(args.releaseId, { llmAnalysis: args.llmAnalysis });
},
});
export const backfillPackageReleaseScansInternal = internalAction({
args: {
cursor: v.optional(v.number()),
batchSize: v.optional(v.number()),
scheduled: v.optional(v.number()),
},
handler: async (ctx, args) => {
const batchSize = Math.max(1, Math.min(args.batchSize ?? 50, 200));
const batch = (await runQueryRef(ctx, internalRefs.packages.getPackageReleaseScanBackfillBatchInternal, {
cursor: args.cursor,
batchSize,
})) as {
releases: Array<{ releaseId: Id<"packageReleases"> }>;
nextCursor: number;
done: boolean;
};
let scheduled = args.scheduled ?? 0;
for (const release of batch.releases) {
await runAfterRef(ctx, 0, internalRefs.vt.scanPackageReleaseWithVirusTotal, {
releaseId: release.releaseId,
});
await runAfterRef(ctx, 0, internalRefs.llmEval.evaluatePackageReleaseWithLlm, {
releaseId: release.releaseId,
});
scheduled += 1;
}
if (!batch.done) {
await runAfterRef(ctx, 0, internalRefs.packages.backfillPackageReleaseScansInternal, {
cursor: batch.nextCursor,
batchSize,
scheduled,
});
}
return {
scheduled,
nextCursor: batch.nextCursor,
done: batch.done,
};
},
});
export const backfillPackageReleaseScans = action({
args: {
batchSize: v.optional(v.number()),
},
handler: async (ctx, args) => {
return await runActionRef(ctx, internalRefs.packages.backfillPackageReleaseScansInternal, {
batchSize: args.batchSize,
});
},
});
+450
View File
@@ -0,0 +1,450 @@
import { getAuthUserId } from "@convex-dev/auth/server";
import { describe, expect, it, vi } from "vitest";
import {
addMember,
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;
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("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",
}),
);
});
});
+683
View File
@@ -0,0 +1,683 @@
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,
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 = internalQuery({
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 =
actor.personalPublisherId
? await ctx.db.get(actor.personalPublisherId)
: await getPersonalPublisherForUser(ctx, actor._id);
if (!requestedHandle) {
if (!personal || personal.deletedAt || personal.deactivatedAt) {
throw new ConvexError("Personal publisher not found");
}
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 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,
};
}),
);
return publishers.filter(
(
item,
): item is {
publisher: NonNullable<ReturnType<typeof toPublicPublisher>>;
role: Doc<"publisherMembers">["role"];
} => Boolean(item),
);
},
});
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),
});
+113 -2
View File
@@ -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(),
@@ -713,7 +819,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 +829,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 +1247,8 @@ const skillOwnershipTransfers = defineTable({
export default defineSchema({
...authTables,
users,
publishers,
publisherMembers,
skills,
skillSlugAliases,
packages,
+23 -14
View File
@@ -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 };
@@ -254,7 +259,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 {
@@ -286,7 +293,7 @@ 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).
@@ -335,7 +342,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 {
+124
View File
@@ -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" })]);
});
});
+3 -2
View File
@@ -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
View File
@@ -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.runQuery(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
View File
@@ -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
View File
@@ -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.
+26
View File
@@ -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({
+219 -2
View File
@@ -1,9 +1,45 @@
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: {
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.
@@ -484,6 +520,187 @@ 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"),
},
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 entries: Array<{ path: string; bytes: Uint8Array }> = [];
for (const file of release.files) {
const content = await ctx.storage.get(file.storageId);
if (!content) continue;
entries.push({
path: file.path,
bytes: new Uint8Array(await content.arrayBuffer()),
});
}
if (entries.length === 0) {
console.warn(`[vt:package] No files found for release ${args.releaseId}, skipping scan`);
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 aiResult = existingFile?.data.attributes.crowdsourced_ai_results?.find(
(r) => r.category === "code_insight",
);
if (aiResult) {
const verdict = normalizeVerdict(aiResult.verdict);
await runMutationRef(ctx, internalRefs.packages.updateReleaseScanResultsInternal, {
releaseId: args.releaseId,
vtAnalysis: {
status: verdictToStatus(verdict),
verdict: aiResult.verdict,
analysis: aiResult.analysis,
source: aiResult.source,
checkedAt: Date.now(),
},
});
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);
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);
}
},
});
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 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 aiResult = vtResult.data.attributes.crowdsourced_ai_results?.find(
(r) => r.category === "code_insight",
);
if (aiResult) {
const verdict = normalizeVerdict(aiResult.verdict);
await runMutationRef(ctx, internalRefs.packages.updateReleaseScanResultsInternal, {
releaseId: args.releaseId,
vtAnalysis: {
status: verdictToStatus(verdict),
verdict: aiResult.verdict,
analysis: aiResult.analysis,
source: aiResult.source,
checkedAt: Date.now(),
},
});
return;
}
const status = statusFromAvStats(vtResult.data.attributes.last_analysis_stats);
if (status) {
await runMutationRef(ctx, internalRefs.packages.updateReleaseScanResultsInternal, {
releaseId: args.releaseId,
vtAnalysis: {
status,
source: "engines",
checkedAt: Date.now(),
},
});
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);
}
},
});
/**
* Poll for pending scans and update skill moderation status
* Called by cron job to check VT results for skills awaiting scan
+6
View File
@@ -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
View File
@@ -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:
+11
View File
@@ -346,6 +346,7 @@ Query params:
Notes:
- Defaults to the latest release.
- 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.
@@ -424,6 +425,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:
@@ -431,6 +433,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`
@@ -444,6 +447,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
View File
@@ -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
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "clawhub",
"version": "0.8.0",
"version": "0.9.0",
"description": "ClawHub CLI \\u2014 install, update, search, and publish skills plus OpenClaw packages.",
"license": "MIT",
"bin": {
+1
View File
@@ -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,
+1
View File
@@ -67,6 +67,7 @@ export type BundlePublishMetadata = (typeof BundlePublishMetadataSchema)[inferre
export const PackagePublishRequestSchema = type({
name: "string",
displayName: "string?",
ownerHandle: "string?",
family: PackageFamilySchema,
version: "string",
changelog: "string",
+1
View File
@@ -65,6 +65,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?: {
+1
View File
@@ -48,6 +48,7 @@ export const BundlePublishMetadataSchema = type({
export const PackagePublishRequestSchema = type({
name: "string",
displayName: "string?",
ownerHandle: "string?",
family: PackageFamilySchema,
version: "string",
changelog: "string",
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"packages.js","sourceRoot":"","sources":["../src/packages.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiB,IAAI,EAAE,MAAM,SAAS,CAAC;AAC9C,OAAO,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAEzE,MAAM,CAAC,MAAM,mBAAmB,GAAG,IAAI,CAAC,uCAAuC,CAAC,CAAC;AAGjF,MAAM,CAAC,MAAM,oBAAoB,GAAG,IAAI,CAAC,kCAAkC,CAAC,CAAC;AAG7E,MAAM,CAAC,MAAM,6BAA6B,GAAG,IAAI,CAC/C,uEAAuE,CACxE,CAAC;AAGF,MAAM,CAAC,MAAM,8BAA8B,GAAG,IAAI,CAAC,0CAA0C,CAAC,CAAC;AAG/F,MAAM,CAAC,MAAM,0BAA0B,GAAG,IAAI,CAAC;IAC7C,cAAc,EAAE,SAAS;IACzB,wBAAwB,EAAE,SAAS;IACnC,gBAAgB,EAAE,SAAS;IAC3B,iBAAiB,EAAE,SAAS;CAC7B,CAAC,CAAC;AAGH,MAAM,CAAC,MAAM,8BAA8B,GAAG,IAAI,CAAC;IACjD,YAAY,EAAE,SAAS;IACvB,SAAS,EAAE,SAAS;IACpB,UAAU,EAAE,SAAS;IACrB,QAAQ,EAAE,WAAW;IACrB,SAAS,EAAE,WAAW;IACtB,KAAK,EAAE,WAAW;IAClB,aAAa,EAAE,WAAW;IAC1B,UAAU,EAAE,UAAU;IACtB,YAAY,EAAE,UAAU;IACxB,aAAa,EAAE,UAAU;IACzB,wBAAwB,EAAE,UAAU;IACpC,SAAS,EAAE,WAAW;IACtB,YAAY,EAAE,WAAW;IACzB,YAAY,EAAE,WAAW;IACzB,cAAc,EAAE,WAAW;IAC3B,cAAc,EAAE,SAAS;IACzB,YAAY,EAAE,SAAS;IACvB,WAAW,EAAE,WAAW;CACzB,CAAC,CAAC;AAGH,MAAM,CAAC,MAAM,gCAAgC,GAAG,IAAI,CAAC;IACnD,IAAI,EAAE,6BAA6B;IACnC,KAAK,EAAE,8BAA8B;IACrC,OAAO,EAAE,SAAS;IAClB,UAAU,EAAE,SAAS;IACrB,YAAY,EAAE,SAAS;IACvB,SAAS,EAAE,SAAS;IACpB,aAAa,EAAE,UAAU;IACzB,UAAU,EAAE,uDAAuD;CACpE,CAAC,CAAC;AAGH,MAAM,CAAC,MAAM,2BAA2B,GAAG,IAAI,CAAC;IAC9C,EAAE,EAAE,SAAS;IACb,MAAM,EAAE,SAAS;IACjB,WAAW,EAAE,WAAW;CACzB,CAAC,CAAC;AAGH,MAAM,CAAC,MAAM,2BAA2B,GAAG,IAAI,CAAC;IAC9C,IAAI,EAAE,QAAQ;IACd,WAAW,EAAE,SAAS;IACtB,MAAM,EAAE,mBAAmB;IAC3B,OAAO,EAAE,QAAQ;IACjB,SAAS,EAAE,QAAQ;IACnB,OAAO,EAAE,oBAAoB,CAAC,QAAQ,EAAE;IACxC,IAAI,EAAE,WAAW;IACjB,MAAM,EAAE,mBAAmB,CAAC,QAAQ,EAAE;IACtC,MAAM,EAAE,2BAA2B,CAAC,QAAQ,EAAE;IAC9C,KAAK,EAAE,oBAAoB,CAAC,KAAK,EAAE;CACpC,CAAC,CAAC;AAGH,MAAM,CAAC,MAAM,qBAAqB,GAAG,IAAI,CAAC;IACxC,IAAI,EAAE,QAAQ;IACd,WAAW,EAAE,QAAQ;IACrB,MAAM,EAAE,mBAAmB;IAC3B,SAAS,EAAE,cAAc;IACzB,OAAO,EAAE,oBAAoB;IAC7B,UAAU,EAAE,SAAS;IACrB,OAAO,EAAE,cAAc;IACvB,WAAW,EAAE,cAAc;IAC3B,SAAS,EAAE,QAAQ;IACnB,SAAS,EAAE,QAAQ;IACnB,aAAa,EAAE,cAAc;IAC7B,cAAc,EAAE,WAAW;IAC3B,YAAY,EAAE,UAAU;IACxB,gBAAgB,EAAE,6BAA6B,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE;CACtE,CAAC,CAAC;AAGH,MAAM,CAAC,MAAM,8BAA8B,GAAG,IAAI,CAAC;IACjD,KAAK,EAAE,qBAAqB,CAAC,KAAK,EAAE;IACpC,UAAU,EAAE,aAAa;CAC1B,CAAC,CAAC;AAEH,MAAM,CAAC,MAAM,gCAAgC,GAAG,IAAI,CAAC;IACnD,OAAO,EAAE,IAAI,CAAC;QACZ,KAAK,EAAE,QAAQ;QACf,OAAO,EAAE,qBAAqB;KAC/B,CAAC,CAAC,KAAK,EAAE;CACX,CAAC,CAAC;AAEH,MAAM,CAAC,MAAM,0BAA0B,GAAG,IAAI,CAAC;IAC7C,OAAO,EAAE,IAAI,CAAC;QACZ,IAAI,EAAE,QAAQ;QACd,WAAW,EAAE,QAAQ;QACrB,MAAM,EAAE,mBAAmB;QAC3B,SAAS,EAAE,cAAc;QACzB,OAAO,EAAE,oBAAoB;QAC7B,UAAU,EAAE,SAAS;QACrB,OAAO,EAAE,cAAc;QACvB,WAAW,EAAE,cAAc;QAC3B,SAAS,EAAE,QAAQ;QACnB,SAAS,EAAE,QAAQ;QACnB,aAAa,EAAE,cAAc;QAC7B,IAAI,EAAE,SAAS;QACf,aAAa,EAAE,0BAA0B,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE;QAC/D,YAAY,EAAE,8BAA8B,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE;QAClE,YAAY,EAAE,gCAAgC,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE;KACrE,CAAC,CAAC,EAAE,CAAC,MAAM,CAAC;IACb,KAAK,EAAE,IAAI,CAAC;QACV,MAAM,EAAE,aAAa;QACrB,WAAW,EAAE,cAAc;QAC3B,KAAK,EAAE,cAAc;KACtB,CAAC,CAAC,EAAE,CAAC,MAAM,CAAC;CACd,CAAC,CAAC;AAEH,MAAM,CAAC,MAAM,qCAAqC,GAAG,IAAI,CAAC;IACxD,KAAK,EAAE,IAAI,CAAC;QACV,OAAO,EAAE,QAAQ;QACjB,SAAS,EAAE,QAAQ;QACnB,SAAS,EAAE,QAAQ;QACnB,QAAQ,EAAE,WAAW;KACtB,CAAC,CAAC,KAAK,EAAE;IACV,UAAU,EAAE,aAAa;CAC1B,CAAC,CAAC;AAEH,MAAM,CAAC,MAAM,iCAAiC,GAAG,IAAI,CAAC;IACpD,OAAO,EAAE,IAAI,CAAC;QACZ,IAAI,EAAE,QAAQ;QACd,WAAW,EAAE,QAAQ;QACrB,MAAM,EAAE,mBAAmB;KAC5B,CAAC,CAAC,EAAE,CAAC,MAAM,CAAC;IACb,OAAO,EAAE,IAAI,CAAC;QACZ,OAAO,EAAE,QAAQ;QACjB,SAAS,EAAE,QAAQ;QACnB,SAAS,EAAE,QAAQ;QACnB,QAAQ,EAAE,WAAW;QACrB,KAAK,EAAE,SAAS;QAChB,aAAa,EAAE,0BAA0B,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE;QAC/D,YAAY,EAAE,8BAA8B,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE;QAClE,YAAY,EAAE,gCAAgC,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE;KACrE,CAAC,CAAC,EAAE,CAAC,MAAM,CAAC;CACd,CAAC,CAAC;AAEH,MAAM,CAAC,MAAM,iCAAiC,GAAG,IAAI,CAAC;IACpD,EAAE,EAAE,MAAM;IACV,SAAS,EAAE,QAAQ;IACnB,SAAS,EAAE,QAAQ;CACpB,CAAC,CAAC"}
{"version":3,"file":"packages.js","sourceRoot":"","sources":["../src/packages.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiB,IAAI,EAAE,MAAM,SAAS,CAAC;AAC9C,OAAO,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAEzE,MAAM,CAAC,MAAM,mBAAmB,GAAG,IAAI,CAAC,uCAAuC,CAAC,CAAC;AAGjF,MAAM,CAAC,MAAM,oBAAoB,GAAG,IAAI,CAAC,kCAAkC,CAAC,CAAC;AAG7E,MAAM,CAAC,MAAM,6BAA6B,GAAG,IAAI,CAC/C,uEAAuE,CACxE,CAAC;AAGF,MAAM,CAAC,MAAM,8BAA8B,GAAG,IAAI,CAAC,0CAA0C,CAAC,CAAC;AAG/F,MAAM,CAAC,MAAM,0BAA0B,GAAG,IAAI,CAAC;IAC7C,cAAc,EAAE,SAAS;IACzB,wBAAwB,EAAE,SAAS;IACnC,gBAAgB,EAAE,SAAS;IAC3B,iBAAiB,EAAE,SAAS;CAC7B,CAAC,CAAC;AAGH,MAAM,CAAC,MAAM,8BAA8B,GAAG,IAAI,CAAC;IACjD,YAAY,EAAE,SAAS;IACvB,SAAS,EAAE,SAAS;IACpB,UAAU,EAAE,SAAS;IACrB,QAAQ,EAAE,WAAW;IACrB,SAAS,EAAE,WAAW;IACtB,KAAK,EAAE,WAAW;IAClB,aAAa,EAAE,WAAW;IAC1B,UAAU,EAAE,UAAU;IACtB,YAAY,EAAE,UAAU;IACxB,aAAa,EAAE,UAAU;IACzB,wBAAwB,EAAE,UAAU;IACpC,SAAS,EAAE,WAAW;IACtB,YAAY,EAAE,WAAW;IACzB,YAAY,EAAE,WAAW;IACzB,cAAc,EAAE,WAAW;IAC3B,cAAc,EAAE,SAAS;IACzB,YAAY,EAAE,SAAS;IACvB,WAAW,EAAE,WAAW;CACzB,CAAC,CAAC;AAGH,MAAM,CAAC,MAAM,gCAAgC,GAAG,IAAI,CAAC;IACnD,IAAI,EAAE,6BAA6B;IACnC,KAAK,EAAE,8BAA8B;IACrC,OAAO,EAAE,SAAS;IAClB,UAAU,EAAE,SAAS;IACrB,YAAY,EAAE,SAAS;IACvB,SAAS,EAAE,SAAS;IACpB,aAAa,EAAE,UAAU;IACzB,UAAU,EAAE,uDAAuD;CACpE,CAAC,CAAC;AAGH,MAAM,CAAC,MAAM,2BAA2B,GAAG,IAAI,CAAC;IAC9C,EAAE,EAAE,SAAS;IACb,MAAM,EAAE,SAAS;IACjB,WAAW,EAAE,WAAW;CACzB,CAAC,CAAC;AAGH,MAAM,CAAC,MAAM,2BAA2B,GAAG,IAAI,CAAC;IAC9C,IAAI,EAAE,QAAQ;IACd,WAAW,EAAE,SAAS;IACtB,WAAW,EAAE,SAAS;IACtB,MAAM,EAAE,mBAAmB;IAC3B,OAAO,EAAE,QAAQ;IACjB,SAAS,EAAE,QAAQ;IACnB,OAAO,EAAE,oBAAoB,CAAC,QAAQ,EAAE;IACxC,IAAI,EAAE,WAAW;IACjB,MAAM,EAAE,mBAAmB,CAAC,QAAQ,EAAE;IACtC,MAAM,EAAE,2BAA2B,CAAC,QAAQ,EAAE;IAC9C,KAAK,EAAE,oBAAoB,CAAC,KAAK,EAAE;CACpC,CAAC,CAAC;AAGH,MAAM,CAAC,MAAM,qBAAqB,GAAG,IAAI,CAAC;IACxC,IAAI,EAAE,QAAQ;IACd,WAAW,EAAE,QAAQ;IACrB,MAAM,EAAE,mBAAmB;IAC3B,SAAS,EAAE,cAAc;IACzB,OAAO,EAAE,oBAAoB;IAC7B,UAAU,EAAE,SAAS;IACrB,OAAO,EAAE,cAAc;IACvB,WAAW,EAAE,cAAc;IAC3B,SAAS,EAAE,QAAQ;IACnB,SAAS,EAAE,QAAQ;IACnB,aAAa,EAAE,cAAc;IAC7B,cAAc,EAAE,WAAW;IAC3B,YAAY,EAAE,UAAU;IACxB,gBAAgB,EAAE,6BAA6B,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE;CACtE,CAAC,CAAC;AAGH,MAAM,CAAC,MAAM,8BAA8B,GAAG,IAAI,CAAC;IACjD,KAAK,EAAE,qBAAqB,CAAC,KAAK,EAAE;IACpC,UAAU,EAAE,aAAa;CAC1B,CAAC,CAAC;AAEH,MAAM,CAAC,MAAM,gCAAgC,GAAG,IAAI,CAAC;IACnD,OAAO,EAAE,IAAI,CAAC;QACZ,KAAK,EAAE,QAAQ;QACf,OAAO,EAAE,qBAAqB;KAC/B,CAAC,CAAC,KAAK,EAAE;CACX,CAAC,CAAC;AAEH,MAAM,CAAC,MAAM,0BAA0B,GAAG,IAAI,CAAC;IAC7C,OAAO,EAAE,IAAI,CAAC;QACZ,IAAI,EAAE,QAAQ;QACd,WAAW,EAAE,QAAQ;QACrB,MAAM,EAAE,mBAAmB;QAC3B,SAAS,EAAE,cAAc;QACzB,OAAO,EAAE,oBAAoB;QAC7B,UAAU,EAAE,SAAS;QACrB,OAAO,EAAE,cAAc;QACvB,WAAW,EAAE,cAAc;QAC3B,SAAS,EAAE,QAAQ;QACnB,SAAS,EAAE,QAAQ;QACnB,aAAa,EAAE,cAAc;QAC7B,IAAI,EAAE,SAAS;QACf,aAAa,EAAE,0BAA0B,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE;QAC/D,YAAY,EAAE,8BAA8B,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE;QAClE,YAAY,EAAE,gCAAgC,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE;KACrE,CAAC,CAAC,EAAE,CAAC,MAAM,CAAC;IACb,KAAK,EAAE,IAAI,CAAC;QACV,MAAM,EAAE,aAAa;QACrB,WAAW,EAAE,cAAc;QAC3B,KAAK,EAAE,cAAc;KACtB,CAAC,CAAC,EAAE,CAAC,MAAM,CAAC;CACd,CAAC,CAAC;AAEH,MAAM,CAAC,MAAM,qCAAqC,GAAG,IAAI,CAAC;IACxD,KAAK,EAAE,IAAI,CAAC;QACV,OAAO,EAAE,QAAQ;QACjB,SAAS,EAAE,QAAQ;QACnB,SAAS,EAAE,QAAQ;QACnB,QAAQ,EAAE,WAAW;KACtB,CAAC,CAAC,KAAK,EAAE;IACV,UAAU,EAAE,aAAa;CAC1B,CAAC,CAAC;AAEH,MAAM,CAAC,MAAM,iCAAiC,GAAG,IAAI,CAAC;IACpD,OAAO,EAAE,IAAI,CAAC;QACZ,IAAI,EAAE,QAAQ;QACd,WAAW,EAAE,QAAQ;QACrB,MAAM,EAAE,mBAAmB;KAC5B,CAAC,CAAC,EAAE,CAAC,MAAM,CAAC;IACb,OAAO,EAAE,IAAI,CAAC;QACZ,OAAO,EAAE,QAAQ;QACjB,SAAS,EAAE,QAAQ;QACnB,SAAS,EAAE,QAAQ;QACnB,QAAQ,EAAE,WAAW;QACrB,KAAK,EAAE,SAAS;QAChB,aAAa,EAAE,0BAA0B,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE;QAC/D,YAAY,EAAE,8BAA8B,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE;QAClE,YAAY,EAAE,gCAAgC,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE;KACrE,CAAC,CAAC,EAAE,CAAC,MAAM,CAAC;CACd,CAAC,CAAC;AAEH,MAAM,CAAC,MAAM,iCAAiC,GAAG,IAAI,CAAC;IACpD,EAAE,EAAE,MAAM;IACV,SAAS,EAAE,QAAQ;IACnB,SAAS,EAAE,QAAQ;CACpB,CAAC,CAAC"}
+1
View File
@@ -67,6 +67,7 @@ export type BundlePublishMetadata = (typeof BundlePublishMetadataSchema)[inferre
export const PackagePublishRequestSchema = type({
name: "string",
displayName: "string?",
ownerHandle: "string?",
family: PackageFamilySchema,
version: "string",
changelog: "string",
+3 -3
View File
@@ -52,14 +52,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 +86,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;
+77 -6
View File
@@ -1,6 +1,6 @@
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:
@@ -16,13 +16,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/plugins/new";
function renderPublishRoute() {
const route = Route as unknown as {
@@ -47,7 +48,7 @@ function getFileInput() {
return input;
}
describe("packages publish route", () => {
describe("plugins publish route", () => {
beforeEach(() => {
generateUploadUrl.mockReset();
publishRelease.mockReset();
@@ -74,12 +75,12 @@ describe("packages publish route", () => {
vi.unstubAllGlobals();
});
it("registers the publish form on /packages/new", () => {
it("registers the publish form on /plugins/new", () => {
const route = Route as unknown as {
__path: string;
};
expect(route.__path).toBe("/packages/new");
expect(route.__path).toBe("/plugins/new");
});
it("publishes a code plugin folder with source metadata and normalized file paths", async () => {
@@ -164,7 +165,7 @@ describe("packages publish route", () => {
it("publishes a bundle plugin folder with bundle metadata", async () => {
renderPublishRoute();
fireEvent.change(screen.getByRole("combobox"), {
fireEvent.change(screen.getAllByRole("combobox")[0], {
target: { value: "bundle-plugin" },
});
@@ -302,4 +303,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();
});
});
+45 -14
View File
@@ -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,
}),
);
});
});
+56 -17
View File
@@ -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: [] },
};
}
+1 -1
View File
@@ -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");
+32 -1
View File
@@ -1,6 +1,6 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { strToU8, zipSync } from "fflate";
import { vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Upload } from "../routes/upload";
vi.mock("@tanstack/react-router", () => ({
@@ -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"), {
+2 -2
View File
@@ -93,7 +93,7 @@ export default function Header() {
Skills
</Link>
)}
{isSoulMode ? null : <Link to="/packages">Packages</Link>}
{isSoulMode ? null : <Link to="/plugins">Plugins</Link>}
<Link to="/upload" search={{ updateSlug: undefined }}>
Upload
</Link>
@@ -176,7 +176,7 @@ export default function Header() {
</DropdownMenuItem>
{isSoulMode ? null : (
<DropdownMenuItem asChild>
<Link to="/packages">Packages</Link>
<Link to="/plugins">Plugins</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem asChild>
+22 -4
View File
@@ -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 &&
+2 -2
View File
@@ -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;
+1 -1
View File
@@ -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[];
};
+15 -6
View File
@@ -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}`}>
+2 -2
View File
@@ -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) {
+96
View File
@@ -12,6 +12,7 @@ import {
fetchPackageDetail,
fetchPackageReadme,
fetchPackageVersion,
fetchPluginCatalog,
fetchPackages,
getPackageDownloadPath,
} from "./packageApi";
@@ -268,3 +269,98 @@ describe("fetchPackages", () => {
);
});
});
describe("fetchPluginCatalog", () => {
afterEach(() => {
getRequestHeadersMock.mockReset();
getRequestUrlMock.mockReset();
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
it("uses code and bundle plugin endpoints for browse mode without touching the unified catalog", async () => {
vi.stubEnv("VITE_CONVEX_URL", "https://registry.example");
const fetchMock = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
new Response(JSON.stringify({ items: [], nextCursor: "code:next" }), { status: 200 }),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ items: [], nextCursor: "bundle:next" }), { status: 200 }),
);
const result = await fetchPluginCatalog({
isOfficial: true,
limit: 20,
});
expect(result.nextCursor).toContain("plugcat:");
expect(fetchMock).toHaveBeenCalledTimes(2);
const urls = fetchMock.mock.calls.map(([requestUrl]) => new URL(requestUrl as string));
expect(urls[0]?.pathname).toBe("/api/v1/code-plugins");
expect(urls[1]?.pathname).toBe("/api/v1/bundle-plugins");
expect(urls[0]?.searchParams.get("isOfficial")).toBe("true");
expect(urls[1]?.searchParams.get("isOfficial")).toBe("true");
});
it("uses code and bundle plugin search endpoints for search mode", async () => {
vi.stubEnv("VITE_CONVEX_URL", "https://registry.example");
const fetchMock = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
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,
},
},
],
}),
{ status: 200 },
),
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
results: [
{
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"]);
const urls = fetchMock.mock.calls.map(([requestUrl]) => new URL(requestUrl as string));
expect(urls[0]?.pathname).toBe("/api/v1/packages/search");
expect(urls[0]?.searchParams.get("family")).toBe("code-plugin");
expect(urls[1]?.searchParams.get("family")).toBe("bundle-plugin");
});
});
+238
View File
@@ -71,6 +71,81 @@ export type PackageVersionDetail = {
} | null;
};
type PluginFamily = "code-plugin" | "bundle-plugin";
type PluginCatalogSourceCursor = {
cursor: string | null;
offset: number;
pageSize: number;
done: boolean;
};
type PluginCatalogCursorState = {
code: PluginCatalogSourceCursor;
bundle: PluginCatalogSourceCursor;
};
type PluginCatalogResult = {
items: PackageListItem[];
nextCursor: string | null;
};
const DEFAULT_PLUGIN_SOURCE_CURSOR: PluginCatalogSourceCursor = {
cursor: null,
offset: 0,
pageSize: 0,
done: false,
};
function clonePluginSourceCursor(
source: Partial<PluginCatalogSourceCursor> | null | undefined,
): PluginCatalogSourceCursor {
return {
cursor: typeof source?.cursor === "string" ? source.cursor : null,
offset: typeof source?.offset === "number" && source.offset > 0 ? source.offset : 0,
pageSize: typeof source?.pageSize === "number" && source.pageSize > 0 ? source.pageSize : 0,
done: source?.done === true,
};
}
function encodePluginCatalogCursor(state: PluginCatalogCursorState) {
return `plugcat:${encodeURIComponent(JSON.stringify(state))}`;
}
function decodePluginCatalogCursor(cursor: string | undefined): PluginCatalogCursorState {
if (!cursor?.startsWith("plugcat:")) {
return {
code: { ...DEFAULT_PLUGIN_SOURCE_CURSOR },
bundle: { ...DEFAULT_PLUGIN_SOURCE_CURSOR },
};
}
try {
const decoded = JSON.parse(decodeURIComponent(cursor.slice("plugcat:".length))) as {
code?: Partial<PluginCatalogSourceCursor>;
bundle?: Partial<PluginCatalogSourceCursor>;
};
return {
code: clonePluginSourceCursor(decoded.code),
bundle: clonePluginSourceCursor(decoded.bundle),
};
} catch {
return {
code: { ...DEFAULT_PLUGIN_SOURCE_CURSOR },
bundle: { ...DEFAULT_PLUGIN_SOURCE_CURSOR },
};
}
}
function comparePluginListItems(a: PackageListItem | undefined, b: PackageListItem | undefined) {
if (!a) return 1;
if (!b) return -1;
return (
b.updatedAt - a.updatedAt ||
b.createdAt - a.createdAt ||
a.name.localeCompare(b.name)
);
}
function normalizeApiPath(path: string) {
return path.startsWith("/") ? path : `/${path}`;
}
@@ -187,6 +262,169 @@ 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,
};
}
const limit = Math.max(1, Math.min(params.limit ?? 25, 100));
const families: PluginFamily[] = ["code-plugin", "bundle-plugin"];
if (params.q?.trim()) {
const results = await Promise.all(
families.map(async (family) => {
const response = await fetchPackages({
q: params.q,
family,
isOfficial: params.isOfficial,
executesCode: params.executesCode,
limit,
});
return "results" in response ? response.results : [];
}),
);
return {
items: results
.flat()
.sort(
(a, b) =>
b.score - a.score ||
Number(b.package.isOfficial) - Number(a.package.isOfficial) ||
b.package.updatedAt - a.package.updatedAt ||
a.package.name.localeCompare(b.package.name),
)
.slice(0, limit)
.map((entry) => entry.package),
nextCursor: null,
};
}
const decodedCursor = decodePluginCatalogCursor(params.cursor);
const requests = await Promise.all(
families.map(async (family) => {
const source = family === "code-plugin" ? decodedCursor.code : decodedCursor.bundle;
if (source.done && source.offset === 0) {
return {
family,
source,
items: [] as PackageListItem[],
nextCursor: null,
effectivePageSize: source.pageSize,
pageCursor: source.cursor,
isDone: true,
};
}
const effectivePageSize =
source.offset > 0 && source.pageSize
? Math.max(source.pageSize, source.offset + 1)
: Math.max(limit * 3, limit);
const response = await fetchPackages({
family,
cursor: source.cursor ?? undefined,
isOfficial: params.isOfficial,
executesCode: params.executesCode,
limit: effectivePageSize,
});
if ("results" in response) {
throw new Error("Expected list response for plugin catalog browse");
}
return {
family,
source,
items: response.items,
nextCursor: response.nextCursor,
effectivePageSize,
pageCursor: source.cursor,
isDone: response.nextCursor === null,
};
}),
);
const indexes: Record<PluginFamily, number> = {
"code-plugin": decodedCursor.code.offset,
"bundle-plugin": decodedCursor.bundle.offset,
};
const items: PackageListItem[] = [];
while (items.length < limit) {
const codeRequest = requests.find((entry) => entry.family === "code-plugin");
const bundleRequest = requests.find((entry) => entry.family === "bundle-plugin");
const codeItem =
codeRequest && indexes["code-plugin"] < codeRequest.items.length
? codeRequest.items[indexes["code-plugin"]]
: undefined;
const bundleItem =
bundleRequest && indexes["bundle-plugin"] < bundleRequest.items.length
? bundleRequest.items[indexes["bundle-plugin"]]
: undefined;
if (!codeItem && !bundleItem) break;
if (comparePluginListItems(codeItem, bundleItem) <= 0) {
items.push(codeItem!);
indexes["code-plugin"] += 1;
} else {
items.push(bundleItem!);
indexes["bundle-plugin"] += 1;
}
}
const nextState: PluginCatalogCursorState = {
code: { ...DEFAULT_PLUGIN_SOURCE_CURSOR },
bundle: { ...DEFAULT_PLUGIN_SOURCE_CURSOR },
};
for (const request of requests) {
const nextOffset = indexes[request.family];
const nextSource =
nextOffset < request.items.length
? {
cursor: request.pageCursor,
offset: nextOffset,
pageSize: request.effectivePageSize,
done: request.isDone,
}
: {
cursor: request.nextCursor,
offset: 0,
pageSize: request.effectivePageSize,
done: request.isDone,
};
if (request.family === "code-plugin") {
nextState.code = nextSource;
} else {
nextState.bundle = nextSource;
}
}
const isDone =
nextState.code.done &&
nextState.code.offset === 0 &&
nextState.bundle.done &&
nextState.bundle.offset === 0;
return {
items,
nextCursor: isDone ? null : encodePluginCatalogCursor(nextState),
};
}
export async function fetchPackageDetail(name: string) {
const url = await packageApiUrl(`${ApiRoutes.packages}/${encodeURIComponent(name)}`);
const response = await packageFetch(url, "application/json");
+7
View File
@@ -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"
+6 -3
View File
@@ -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,
+84
View File
@@ -20,11 +20,15 @@ import { Route as AdminRouteImport } from './routes/admin'
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'
@@ -83,6 +87,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 +107,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 +127,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',
@@ -131,11 +155,15 @@ export interface FileRoutesByFullPath {
'/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
}
@@ -151,11 +179,15 @@ export interface FileRoutesByTo {
'/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
}
@@ -172,11 +204,15 @@ export interface FileRoutesById {
'/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
}
@@ -194,11 +230,15 @@ export interface FileRouteTypes {
| '/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
@@ -214,11 +254,15 @@ export interface FileRouteTypes {
| '/upload'
| '/$owner/$slug'
| '/cli/auth'
| '/orgs/$handle'
| '/packages/$name'
| '/packages/new'
| '/plugins/$name'
| '/plugins/new'
| '/souls/$slug'
| '/u/$handle'
| '/packages'
| '/plugins'
| '/skills'
| '/souls'
id:
@@ -234,11 +278,15 @@ export interface FileRouteTypes {
| '/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
@@ -255,11 +303,15 @@ export interface RootRouteChildren {
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
}
@@ -343,6 +395,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 +423,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 +451,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'
@@ -407,11 +487,15 @@ const rootRouteChildren: RootRouteChildren = {
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,
}
+1 -2
View File
@@ -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)) {
+58 -6
View File
@@ -1,6 +1,7 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery } from "convex/react";
import { Clock, Package, Plus, Upload } from "lucide-react";
import { useEffect, useState } from "react";
import { api } from "../../convex/_generated/api";
import type { Doc } from "../../convex/_generated/dataModel";
import { formatCompactStat } from "../lib/numberFormat";
@@ -14,11 +15,38 @@ export const Route = createFileRoute("/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;
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 (
<main className="section">
@@ -28,14 +56,33 @@ function Dashboard() {
}
const skills = mySkills ?? [];
const ownerHandle = me.handle ?? me.name ?? me.displayName ?? me._id;
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>
<div style={{ display: "grid", gap: "6px" }}>
<h1 className="section-title" style={{ margin: 0 }}>
Publisher Skills
</h1>
<p className="section-subtitle" style={{ margin: 0 }}>
New skill versions stay private until automated security checks and verification finish.
</p>
</div>
{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="/upload" search={{ updateSlug: undefined }} className="btn btn-primary">
<Plus className="h-4 w-4" aria-hidden="true" />
Upload New Skill
@@ -79,11 +126,16 @@ function SkillCard({ skill, ownerHandle }: { skill: DashboardSkill; ownerHandle:
{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>}
{skill.pendingReview ? (
<p className="dashboard-skill-description">
Hidden until VirusTotal and verification checks finish.
</p>
) : null}
<div className="dashboard-skill-stats">
<span>
<Package size={13} aria-hidden="true" /> {formatCompactStat(skill.stats.downloads)}
+2 -2
View File
@@ -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;
};
+122
View File
@@ -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>
);
}
+3 -146
View File
@@ -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>
);
}
+3 -242
View File
@@ -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>
);
}
+4 -235
View File
@@ -1,238 +1,7 @@
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,
beforeLoad: () => {
throw redirect({ to: "/plugins/new" });
},
});
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,
});
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>
);
}
+184
View File
@@ -0,0 +1,184 @@
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 PluginDetailLoaderData = {
detail: PackageDetailResponse;
version: PackageVersionDetail | null;
readme: string | null;
};
export const Route = createFileRoute("/plugins/$name")({
loader: async ({ params }): Promise<PluginDetailLoaderData> => {
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 };
},
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}
<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>
);
}
+263
View File
@@ -0,0 +1,263 @@
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="/plugins/new">
Publish
</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>
);
}
+293
View File
@@ -0,0 +1,293 @@
import { createFileRoute } from "@tanstack/react-router";
import { useAction, useMutation, useQuery } from "convex/react";
import { startTransition, useEffect, useMemo, useState } from "react";
import { api } from "../../../convex/_generated/api";
import {
MAX_PUBLISH_FILE_BYTES,
MAX_PUBLISH_TOTAL_BYTES,
} from "../../../convex/lib/publishLimits";
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";
export const Route = createFileRoute("/plugins/new")({
component: PublishPluginRoute,
});
const apiRefs = api as unknown as {
packages: {
publishRelease: unknown;
};
};
function PublishPluginRoute() {
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">("code-plugin");
const [name, setName] = useState("");
const [displayName, setDisplayName] = useState("");
const [ownerHandle, setOwnerHandle] = 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 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 onPickFiles = async (selected: File[]) => {
const expanded = await expandFilesWithReport(selected, {
includeBinaryArchiveFiles: true,
});
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
}
};
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 }}>
Publish Plugin
</h1>
<p className="section-subtitle" style={{ marginBottom: 0 }}>
Upload 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>
</header>
<div className="card" style={{ display: "grid", gap: 12 }}>
{!isAuthenticated ? <div>Log in to publish plugins.</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="Plugin name" value={name} onChange={(event) => setName(event.target.value)} />
<input
className="input"
placeholder="Display name"
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
/>
<select className="input" value={ownerHandle} 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} 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}
{validationError ? <div className="tag tag-accent">{validationError}</div> : null}
<button
className="btn"
type="button"
disabled={
!isAuthenticated ||
!name.trim() ||
!version.trim() ||
files.length === 0 ||
Boolean(validationError) ||
Boolean(status) ||
(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>
<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>
);
}
+192
View File
@@ -25,11 +25,51 @@ function Settings() {
| undefined;
const createToken = useMutation(api.tokens.create);
const revokeToken = useMutation(api.tokens.revoke);
const publisherMemberships = useQuery(api.publishers.listMine) as
| Array<{
publisher: {
_id: Id<"publishers">;
handle: string;
displayName: string;
kind: "user" | "org";
};
role: "owner" | "admin" | "publisher";
}>
| undefined;
const createOrg = useMutation(api.publishers.createOrg);
const addOrgMember = useMutation(api.publishers.addMember);
const removeOrgMember = useMutation(api.publishers.removeMember);
const [displayName, setDisplayName] = useState("");
const [bio, setBio] = useState("");
const [status, setStatus] = useState<string | null>(null);
const [tokenLabel, setTokenLabel] = useState("CLI token");
const [newToken, setNewToken] = useState<string | null>(null);
const [orgHandle, setOrgHandle] = useState("");
const [orgDisplayName, setOrgDisplayName] = useState("");
const [selectedOrgHandle, setSelectedOrgHandle] = useState("");
const [memberHandle, setMemberHandle] = useState("");
const [memberRole, setMemberRole] = useState<"owner" | "admin" | "publisher">("publisher");
const orgs = (publisherMemberships ?? []).filter((entry) => entry.publisher.kind === "org");
const selectedOrg =
orgs.find((entry) => entry.publisher.handle === selectedOrgHandle) ?? orgs[0] ?? null;
const orgMembers = useQuery(
api.publishers.listMembers,
selectedOrg ? { publisherHandle: selectedOrg.publisher.handle } : "skip",
) as
| {
publisher: { _id: Id<"publishers">; handle: string } | null;
members: Array<{
role: "owner" | "admin" | "publisher";
user: {
_id: Id<"users">;
handle: string | null;
displayName: string | null;
image: string | null;
};
}>;
}
| null
| undefined;
useEffect(() => {
if (!me) return;
@@ -37,6 +77,13 @@ function Settings() {
setBio(me.bio ?? "");
}, [me]);
useEffect(() => {
if (selectedOrgHandle) return;
if (orgs[0]?.publisher.handle) {
setSelectedOrgHandle(orgs[0].publisher.handle);
}
}, [orgs, selectedOrgHandle]);
if (!me) {
return (
<main className="section">
@@ -70,6 +117,19 @@ function Settings() {
setNewToken(result.token);
}
async function onCreateOrg() {
const result = await createOrg({
handle: orgHandle.trim(),
displayName: orgDisplayName.trim() || orgHandle.trim(),
bio: undefined,
});
if (result?.publisher?.handle) {
setSelectedOrgHandle(result.publisher.handle);
setOrgHandle("");
setOrgDisplayName("");
}
}
return (
<main className="section settings-shell">
<h1 className="section-title">Settings</h1>
@@ -114,6 +174,138 @@ function Settings() {
</div>
</form>
<div className="card settings-card">
<h2 className="section-title" style={{ marginTop: 0 }}>
Organizations
</h2>
<p className="section-subtitle">
Create org publishers and manage who can publish under them.
</p>
<div className="settings-field">
<span>Org handle</span>
<input
className="settings-input"
value={orgHandle}
onChange={(event) => setOrgHandle(event.target.value)}
placeholder="openclaw"
/>
</div>
<div className="settings-field">
<span>Display name</span>
<input
className="settings-input"
value={orgDisplayName}
onChange={(event) => setOrgDisplayName(event.target.value)}
placeholder="OpenClaw"
/>
</div>
<div className="settings-actions">
<button
className="btn btn-primary settings-save"
type="button"
disabled={!orgHandle.trim()}
onClick={() => void onCreateOrg()}
>
Create org
</button>
</div>
{orgs.length > 0 ? (
<>
<div className="settings-field" style={{ marginTop: 16 }}>
<span>Manage org</span>
<select
className="settings-input"
value={selectedOrg?.publisher.handle ?? ""}
onChange={(event) => setSelectedOrgHandle(event.target.value)}
>
{orgs.map((entry) => (
<option key={entry.publisher._id} value={entry.publisher.handle}>
@{entry.publisher.handle} · {entry.role}
</option>
))}
</select>
</div>
{selectedOrg && selectedOrg.role !== "publisher" ? (
<>
<div className="settings-field">
<span>Add member</span>
<input
className="settings-input"
value={memberHandle}
onChange={(event) => setMemberHandle(event.target.value)}
placeholder="@username"
/>
</div>
<div className="settings-field">
<span>Role</span>
<select
className="settings-input"
value={memberRole}
onChange={(event) => setMemberRole(event.target.value as typeof memberRole)}
>
<option value="publisher">Publisher</option>
<option value="admin">Admin</option>
<option value="owner">Owner</option>
</select>
</div>
<div className="settings-actions">
<button
className="btn"
type="button"
disabled={!memberHandle.trim()}
onClick={() =>
void addOrgMember({
publisherId: selectedOrg.publisher._id,
userHandle: memberHandle,
role: memberRole,
}).then(() => setMemberHandle(""))
}
>
Add member
</button>
</div>
</>
) : null}
{(orgMembers?.members ?? []).length ? (
<div style={{ display: "grid", gap: 10, marginTop: 16 }}>
{orgMembers?.members.map((entry) => (
<div
key={`${entry.user._id}:${entry.role}`}
className="stat"
style={{ display: "flex", justifyContent: "space-between", gap: 12 }}
>
<div>
<strong>{entry.user.displayName ?? entry.user.handle ?? entry.user._id}</strong>
<div style={{ opacity: 0.7 }}>
@{entry.user.handle ?? "user"} · {entry.role}
</div>
</div>
{selectedOrg && selectedOrg.role !== "publisher" ? (
<button
className="btn"
type="button"
onClick={() =>
void removeOrgMember({
publisherId: selectedOrg.publisher._id,
userId: entry.user._id,
})
}
>
Remove
</button>
) : null}
</div>
))}
</div>
) : null}
</>
) : null}
</div>
<div className="card settings-card">
<h2 className="section-title danger-title" style={{ marginTop: 0 }}>
API tokens
+32 -39
View File
@@ -49,8 +49,7 @@ export function SkillsResults({
const clawdis = entry.latestVersion?.parsed?.clawdis;
const isPlugin = Boolean(clawdis?.nix?.plugin);
const platforms = getPlatformLabels(clawdis?.os, clawdis?.nix?.systems);
const ownerHandle =
entry.owner?.handle ?? entry.owner?.name ?? entry.ownerHandle ?? null;
const ownerHandle = entry.owner?.handle ?? entry.ownerHandle ?? null;
const skillHref = buildSkillHref(skill, ownerHandle);
return (
<SkillCard
@@ -79,50 +78,44 @@ export function SkillsResults({
})}
</div>
) : (
<div className="skills-list">
<div className="skills-table">
<div className="skills-table-header">
<span>Skill</span>
<span>Summary</span>
<span>Author</span>
<span className="skills-table-stats">Stats</span>
</div>
{sorted.map((entry) => {
const skill = entry.skill;
const clawdis = entry.latestVersion?.parsed?.clawdis;
const isPlugin = Boolean(clawdis?.nix?.plugin);
const platforms = getPlatformLabels(clawdis?.os, clawdis?.nix?.systems);
const ownerHandle =
entry.owner?.handle ?? entry.owner?.name ?? entry.ownerHandle ?? null;
const ownerHandle = entry.owner?.handle ?? entry.ownerHandle ?? null;
const skillHref = buildSkillHref(skill, ownerHandle);
return (
<Link key={skill._id} className="skills-row" to={skillHref}>
<div className="skills-row-main">
<div className="skills-row-title">
<span>{skill.displayName}</span>
<span className="skills-row-slug">/{skill.slug}</span>
<Link key={skill._id} className="skills-table-row" to={skillHref}>
<span className="skills-table-name">
<span>
{skill.displayName}
{getSkillBadges(skill).map((badge) => (
<span key={badge} className="tag">
{badge}
</span>
<span key={badge} className="tag tag-compact">{badge}</span>
))}
{isPlugin ? (
<span className="tag tag-accent tag-compact">Plugin bundle (nix)</span>
) : null}
{platforms.map((label) => (
<span key={label} className="tag tag-compact">
{label}
</span>
))}
</div>
<div className="skills-row-summary">
{skill.summary ?? "No summary provided."}
</div>
<div className="skills-row-owner">
<UserBadge
user={entry.owner}
fallbackHandle={ownerHandle}
prefix="by"
link={false}
/>
</div>
</div>
<div className="skills-row-metrics">
</span>
{entry.latestVersion?.version ? (
<span className="skills-table-version">v{entry.latestVersion.version}</span>
) : null}
</span>
<span className="skills-table-summary">
{skill.summary ?? "No summary provided."}
</span>
<span className="skills-table-author">
<UserBadge
user={entry.owner}
fallbackHandle={ownerHandle}
prefix=""
link={false}
/>
</span>
<span className="skills-table-stats">
<SkillMetricsRow stats={skill.stats} />
</div>
</span>
</Link>
);
})}
+4 -4
View File
@@ -1,5 +1,5 @@
import type { Doc } from "../../../convex/_generated/dataModel";
import type { PublicSkill, PublicUser } from "../../lib/publicUser";
import type { PublicPublisher, PublicSkill } from "../../lib/publicUser";
export type SkillListEntry = {
skill: PublicSkill;
@@ -19,7 +19,7 @@ export type SkillListEntry = {
};
} | null;
ownerHandle?: string | null;
owner?: PublicUser | null;
owner?: PublicPublisher | null;
searchScore?: number;
};
@@ -28,10 +28,10 @@ export type SkillSearchEntry = {
version: Doc<"skillVersions"> | null;
score: number;
ownerHandle?: string | null;
owner?: PublicUser | null;
owner?: PublicPublisher | null;
};
export function buildSkillHref(skill: PublicSkill, ownerHandle?: string | null) {
const owner = ownerHandle?.trim() || String(skill.ownerUserId);
const owner = ownerHandle?.trim() || String(skill.ownerPublisherId ?? skill.ownerUserId);
return `/${encodeURIComponent(owner)}/${encodeURIComponent(skill.slug)}`;
}
+66 -4
View File
@@ -8,6 +8,10 @@ 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";
@@ -81,6 +85,18 @@ export function Upload() {
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) => {
@@ -92,7 +108,6 @@ export function Upload() {
};
const validationRef = useRef<HTMLDivElement | null>(null);
const navigate = useNavigate();
const maxBytes = 50 * 1024 * 1024;
const totalBytes = useMemo(() => files.reduce((sum, file) => sum + file.size, 0), [files]);
const stripRoot = useMemo(() => {
if (files.length === 0) return null;
@@ -123,6 +138,14 @@ export function Upload() {
[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(
@@ -169,6 +192,14 @@ export function Upload() {
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;
@@ -266,7 +297,10 @@ export function Upload() {
.join(", ")}`,
);
}
if (totalBytes > maxBytes) {
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) {
@@ -286,6 +320,8 @@ export function Upload() {
hasRequiredFile,
isSoulMode,
totalBytes,
oversizedFiles.length,
oversizedFileNames,
requiredFileLabel,
slugCollision,
]);
@@ -325,7 +361,11 @@ export function Upload() {
return;
}
setError(null);
if (totalBytes > maxBytes) {
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;
}
@@ -364,6 +404,7 @@ export function Upload() {
setStatus("Publishing…");
try {
const result = await publishVersion({
ownerHandle: isSoulMode ? undefined : ownerHandle || undefined,
slug: trimmedSlug,
displayName: trimmedName,
version,
@@ -377,7 +418,8 @@ export function Upload() {
setHasAttempted(false);
setChangelogSource("user");
if (result) {
const ownerParam = me?.handle ?? (me?._id ? String(me._id) : "unknown");
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 },
@@ -424,6 +466,26 @@ export function Upload() {
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>
+143 -2
View File
@@ -248,6 +248,30 @@ code {
.theme-toggle {
display: inline-flex;
gap: 2px;
}
.theme-toggle button {
all: unset;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
cursor: pointer;
color: var(--ink-soft);
opacity: 0.5;
transition: all 0.2s ease;
}
.theme-toggle button:hover {
opacity: 0.8;
}
.theme-toggle button[data-state="on"] {
opacity: 1;
color: var(--ink);
}
.user-trigger {
@@ -288,6 +312,7 @@ code {
display: inline-flex;
align-items: center;
gap: 8px;
white-space: nowrap;
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
@@ -1217,12 +1242,14 @@ code {
appearance: none;
border: 1px solid var(--border-ui);
border-radius: var(--radius-pill);
padding: 10px 14px;
background: rgba(255, 255, 255, 0.45);
padding: 10px 20px 10px 14px;
background: rgba(255, 255, 255, 0.45) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 24 24' fill='none' stroke='%236b5549' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E") no-repeat right 7px center;
background-size: 10px;
color: var(--ink);
font-weight: 650;
font-size: 0.9rem;
cursor: pointer;
width: fit-content;
}
[data-theme="dark"] .skills-sort {
@@ -1270,6 +1297,119 @@ code {
background: var(--surface);
}
.skills-table {
border-radius: var(--radius-md);
border: 1px solid var(--line);
overflow: hidden;
background: var(--surface);
}
.skills-table-header {
display: grid;
grid-template-columns: minmax(160px, 1fr) minmax(0, 2fr) 120px 140px;
gap: 12px;
padding: 10px 18px;
font-size: 0.78rem;
font-weight: 650;
color: var(--ink-soft);
text-transform: uppercase;
letter-spacing: 0.04em;
border-bottom: 1px solid var(--line);
background: rgba(42, 31, 25, 0.03);
}
.skills-table-row {
display: grid;
grid-template-columns: minmax(160px, 1fr) minmax(0, 2fr) 120px 140px;
gap: 12px;
padding: 12px 18px;
text-decoration: none;
color: inherit;
border-bottom: 1px solid var(--line);
transition: background 0.15s ease;
align-items: center;
}
.skills-table-row:last-child {
border-bottom: none;
}
.skills-table-row:hover {
background: rgba(255, 107, 74, 0.07);
}
[data-theme="dark"] .skills-table-row:hover {
background: rgba(232, 106, 71, 0.11);
}
[data-theme="dark"] .skills-table-header {
background: rgba(255, 255, 255, 0.03);
}
.skills-table-name {
font-family: var(--font-display);
font-size: 0.95rem;
font-weight: 600;
letter-spacing: -0.01em;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.skills-table-name > span:first-child {
display: flex;
align-items: baseline;
gap: 6px;
flex-wrap: wrap;
word-break: break-word;
}
.skills-table-summary {
color: var(--ink-soft);
font-size: 0.85rem;
line-height: 1.4;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
min-width: 0;
}
.skills-table-author {
font-size: 0.85rem;
color: var(--ink-soft);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.skills-table-stats {
display: flex;
gap: 10px;
font-size: 0.82rem;
color: var(--ink-soft);
white-space: nowrap;
}
.skills-table-version {
font-size: 0.85rem;
color: var(--ink-soft);
}
@media (max-width: 860px) {
.skills-table-header,
.skills-table-row {
grid-template-columns: minmax(0, 1fr) minmax(0, 1.5fr) 100px;
}
.skills-table-stats {
display: none;
}
.skills-table-header span:last-child {
display: none;
}
}
.skills-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
@@ -1386,6 +1526,7 @@ code {
.skill-card {
min-height: 176px;
box-shadow: 0 1px 3px rgba(42, 31, 25, 0.08);
}
.skill-card-tags {