mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-15 09:22:08 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48d0fc91f3 | ||
|
|
fefb2340a8 | ||
|
|
22287558b9 | ||
|
|
f6ce8f9e1e | ||
|
|
b5cdee50a9 | ||
|
|
b5fdee1c13 | ||
|
|
2530beaf51 | ||
|
|
c627b202f7 | ||
|
|
5cbeb54c8e | ||
|
|
eb8136b7fb | ||
|
|
a166c95eb0 | ||
|
|
86dc196e9b | ||
|
|
649c14a43f | ||
|
|
7cbf0434b0 | ||
|
|
76944f0e32 | ||
|
|
6ead1da08b | ||
|
|
22413f4ff8 |
@@ -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:
|
||||
|
||||
@@ -24,7 +24,3 @@ coverage
|
||||
playwright-report
|
||||
test-results
|
||||
.playwright
|
||||
skills/
|
||||
docs/
|
||||
convex/
|
||||
skills-lock.json
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -62,7 +62,7 @@ Common CLI flows:
|
||||
- Manage local installs: `clawhub install <slug>`, `clawhub uninstall <slug>`, `clawhub list`, `clawhub update --all`
|
||||
- Inspect without installing: `clawhub inspect <slug>`
|
||||
- Publish/sync: `clawhub publish <path>`, `clawhub sync`
|
||||
- Publish plugins: `clawhub package publish <path> --source-repo <owner/repo> --source-commit <sha>`
|
||||
- Publish plugins: `clawhub package publish <path> [--owner <handle>] --source-repo <owner/repo> --source-commit <sha>`
|
||||
- Canonicalize owned skills: `clawhub skill rename <slug> <new-slug>`, `clawhub skill merge <source> <target>`
|
||||
|
||||
Docs: [`docs/quickstart.md`](docs/quickstart.md), [`docs/cli.md`](docs/cli.md).
|
||||
|
||||
Vendored
+8
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ActionCtx } from "../_generated/server";
|
||||
import { assertAdmin } from "../lib/access";
|
||||
import { requireApiTokenUser } from "../lib/apiTokenAuth";
|
||||
import { corsHeaders, mergeHeaders } from "../lib/httpHeaders";
|
||||
import { getPublishFileSizeError, MAX_PUBLISH_FILE_BYTES } from "../lib/publishLimits";
|
||||
import { isMacJunkPath } from "../lib/skills";
|
||||
|
||||
export const MAX_RAW_FILE_BYTES = 200 * 1024;
|
||||
@@ -263,6 +264,9 @@ export async function parseMultipartPublish(
|
||||
const path = file.name;
|
||||
if (isMacJunkPath(path)) continue;
|
||||
const size = file.size;
|
||||
if (size > MAX_PUBLISH_FILE_BYTES) {
|
||||
throw new Error(getPublishFileSizeError(path));
|
||||
}
|
||||
const contentType = file.type || undefined;
|
||||
const buffer = new Uint8Array(await file.arrayBuffer());
|
||||
const sha256 = await sha256Hex(buffer);
|
||||
|
||||
@@ -22,7 +22,13 @@ export async function usersPostRouterV1Handler(ctx: ActionCtx, request: Request)
|
||||
return text("Not found", 404, rate.headers);
|
||||
}
|
||||
const action = segments[0];
|
||||
if (action !== "ban" && action !== "role" && action !== "restore" && action !== "reclaim") {
|
||||
if (
|
||||
action !== "ban" &&
|
||||
action !== "role" &&
|
||||
action !== "restore" &&
|
||||
action !== "reclaim" &&
|
||||
action !== "publisher"
|
||||
) {
|
||||
return text("Not found", 404, rate.headers);
|
||||
}
|
||||
|
||||
@@ -48,6 +54,12 @@ export async function usersPostRouterV1Handler(ctx: ActionCtx, request: Request)
|
||||
return handleAdminReclaim(ctx, request, payload, actorUserId, rate.headers);
|
||||
}
|
||||
|
||||
if (action === "publisher") {
|
||||
const admin = requireAdminOrResponse(actorUser, rate.headers);
|
||||
if (!admin.ok) return admin.response;
|
||||
return handleAdminEnsurePublisher(ctx, payload, actorUserId, rate.headers);
|
||||
}
|
||||
|
||||
const handleRaw = typeof payload.handle === "string" ? payload.handle.trim() : "";
|
||||
const userIdRaw = typeof payload.userId === "string" ? payload.userId.trim() : "";
|
||||
const reasonRaw = typeof payload.reason === "string" ? payload.reason.trim() : "";
|
||||
@@ -215,6 +227,39 @@ async function handleAdminReclaim(
|
||||
return json({ ok: true, results, succeeded, failed }, 200, headers);
|
||||
}
|
||||
|
||||
async function handleAdminEnsurePublisher(
|
||||
ctx: ActionCtx,
|
||||
payload: Record<string, unknown>,
|
||||
actorUserId: Id<"users">,
|
||||
headers: HeadersInit,
|
||||
) {
|
||||
const handle = typeof payload.handle === "string" ? payload.handle.trim().toLowerCase() : "";
|
||||
if (!handle) return text("Missing handle", 400, headers);
|
||||
|
||||
const displayName = typeof payload.displayName === "string" ? payload.displayName.trim() : undefined;
|
||||
const trusted =
|
||||
typeof payload.trusted === "boolean" ? payload.trusted : true;
|
||||
|
||||
try {
|
||||
const result = await ctx.runMutation(internal.publishers.ensureOrgPublisherHandleInternal, {
|
||||
actorUserId,
|
||||
handle,
|
||||
displayName,
|
||||
trusted,
|
||||
});
|
||||
return json(result, 200, headers);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Publisher ensure failed";
|
||||
if (message.toLowerCase().includes("forbidden")) {
|
||||
return text("Forbidden", 403, headers);
|
||||
}
|
||||
if (message.toLowerCase().includes("not found")) {
|
||||
return text(message, 404, headers);
|
||||
}
|
||||
return text(message, 400, headers);
|
||||
}
|
||||
}
|
||||
|
||||
export async function usersListV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const rate = await applyRateLimit(ctx, request, "read");
|
||||
if (!rate.ok) return rate.response;
|
||||
|
||||
@@ -46,9 +46,11 @@ describe("packageRegistry", () => {
|
||||
|
||||
expect(result.runtimeId).toBe("demo.plugin");
|
||||
expect(result.compatibility?.pluginApiRange).toBe("^1.2.0");
|
||||
expect(result.compatibility?.minGatewayVersion).toBe("2026.3.0");
|
||||
expect(result.capabilities.executesCode).toBe(true);
|
||||
expect(result.capabilities.toolNames).toContain("demoTool");
|
||||
expect(result.verification.tier).toBe("source-linked");
|
||||
expect(result.verification.scanStatus).toBe("not-run");
|
||||
});
|
||||
|
||||
it("requires source metadata for code plugins", () => {
|
||||
@@ -69,6 +71,63 @@ describe("packageRegistry", () => {
|
||||
).toThrow("source repo and commit");
|
||||
});
|
||||
|
||||
it("maps legacy minHostVersion to minGatewayVersion instead of pluginApiRange", () => {
|
||||
expect(() =>
|
||||
extractCodePluginArtifacts({
|
||||
packageName: "@openclaw/matrix",
|
||||
packageJson: {
|
||||
name: "@openclaw/matrix",
|
||||
version: "2026.3.13",
|
||||
openclaw: {
|
||||
extensions: ["./index.ts"],
|
||||
install: {
|
||||
npmSpec: "@openclaw/matrix",
|
||||
localPath: "extensions/matrix",
|
||||
defaultChoice: "npm",
|
||||
minHostVersion: "2026.3.13",
|
||||
},
|
||||
},
|
||||
},
|
||||
pluginManifest: {
|
||||
id: "matrix",
|
||||
channels: ["matrix"],
|
||||
configSchema: { type: "object" },
|
||||
},
|
||||
source: {
|
||||
kind: "github",
|
||||
url: "https://github.com/openclaw/openclaw",
|
||||
repo: "openclaw/openclaw",
|
||||
ref: "refs/tags/v2026.3.13",
|
||||
commit: "abc123",
|
||||
path: "extensions/matrix",
|
||||
importedAt: Date.now(),
|
||||
},
|
||||
}),
|
||||
).toThrow("package.json openclaw.compat.pluginApi is required");
|
||||
});
|
||||
|
||||
it("extracts legacy minHostVersion as minGatewayVersion while preserving build metadata", () => {
|
||||
const result = extractBundlePluginArtifacts({
|
||||
packageName: "@openclaw/matrix-bundle",
|
||||
packageJson: {
|
||||
name: "@openclaw/matrix-bundle",
|
||||
version: "2026.3.13",
|
||||
openclaw: {
|
||||
install: {
|
||||
minHostVersion: "2026.3.13",
|
||||
},
|
||||
},
|
||||
},
|
||||
bundleManifest: {
|
||||
hostTargets: ["openclaw"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.compatibility?.pluginApiRange).toBeUndefined();
|
||||
expect(result.compatibility?.minGatewayVersion).toBe("2026.3.13");
|
||||
expect(result.compatibility?.builtWithOpenClawVersion).toBe("2026.3.13");
|
||||
});
|
||||
|
||||
it("requires host targets for bundle plugins", () => {
|
||||
expect(() =>
|
||||
extractBundlePluginArtifacts({
|
||||
|
||||
@@ -176,14 +176,25 @@ function extractOpenClawBlock(packageJson: JsonRecord | undefined) {
|
||||
}
|
||||
|
||||
function extractCompatibility(packageJson: JsonRecord | undefined): PackageCompatibility | undefined {
|
||||
const { compat, build } = extractOpenClawBlock(packageJson);
|
||||
const { openclaw, compat, build } = extractOpenClawBlock(packageJson);
|
||||
const install = isRecord(openclaw?.install) ? openclaw.install : undefined;
|
||||
const version =
|
||||
typeof packageJson?.version === "string" ? packageJson.version.trim() : undefined;
|
||||
const minHostVersion =
|
||||
typeof install?.minHostVersion === "string" ? install.minHostVersion.trim() : undefined;
|
||||
const compatibility: PackageCompatibility = {};
|
||||
if (typeof compat?.pluginApi === "string") compatibility.pluginApiRange = compat.pluginApi.trim();
|
||||
if (typeof compat?.pluginApi === "string") {
|
||||
compatibility.pluginApiRange = compat.pluginApi.trim();
|
||||
}
|
||||
if (typeof compat?.minGatewayVersion === "string") {
|
||||
compatibility.minGatewayVersion = compat.minGatewayVersion.trim();
|
||||
} else if (minHostVersion) {
|
||||
compatibility.minGatewayVersion = minHostVersion;
|
||||
}
|
||||
if (typeof build?.openclawVersion === "string") {
|
||||
compatibility.builtWithOpenClawVersion = build.openclawVersion.trim();
|
||||
} else if (version) {
|
||||
compatibility.builtWithOpenClawVersion = version;
|
||||
}
|
||||
if (typeof build?.pluginSdkVersion === "string") {
|
||||
compatibility.pluginSdkVersion = build.pluginSdkVersion.trim();
|
||||
|
||||
@@ -13,10 +13,12 @@ const SHARED_KEYS = [
|
||||
"channel",
|
||||
"isOfficial",
|
||||
"ownerUserId",
|
||||
"ownerPublisherId",
|
||||
"summary",
|
||||
"capabilityTags",
|
||||
"executesCode",
|
||||
"runtimeId",
|
||||
"scanStatus",
|
||||
"softDeletedAt",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
@@ -31,13 +33,16 @@ const CAPABILITY_SHARED_KEYS = [
|
||||
"channel",
|
||||
"isOfficial",
|
||||
"ownerUserId",
|
||||
"ownerPublisherId",
|
||||
"ownerHandle",
|
||||
"ownerKind",
|
||||
"summary",
|
||||
"latestVersion",
|
||||
"runtimeId",
|
||||
"capabilityTags",
|
||||
"executesCode",
|
||||
"verificationTier",
|
||||
"scanStatus",
|
||||
"softDeletedAt",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
@@ -47,6 +52,7 @@ export type PackageSearchDigestFields = Pick<Doc<"packages">, (typeof SHARED_KEY
|
||||
packageId: Id<"packages">;
|
||||
latestVersion?: string;
|
||||
ownerHandle?: string;
|
||||
ownerKind?: "user" | "org";
|
||||
verificationTier?: Doc<"packageSearchDigest">["verificationTier"];
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,11 @@ export type PublicUser = Pick<
|
||||
"_id" | "_creationTime" | "handle" | "name" | "displayName" | "image" | "bio"
|
||||
>;
|
||||
|
||||
export type PublicPublisher = Pick<
|
||||
Doc<"publishers">,
|
||||
"_id" | "_creationTime" | "kind" | "handle" | "displayName" | "image" | "bio" | "linkedUserId"
|
||||
>;
|
||||
|
||||
export type PublicSkill = Pick<
|
||||
Doc<"skills">,
|
||||
| "_id"
|
||||
@@ -14,6 +19,7 @@ export type PublicSkill = Pick<
|
||||
| "displayName"
|
||||
| "summary"
|
||||
| "ownerUserId"
|
||||
| "ownerPublisherId"
|
||||
| "canonicalSkillId"
|
||||
| "forkOf"
|
||||
| "latestVersionId"
|
||||
@@ -38,6 +44,7 @@ export type HydratableSkill = Pick<
|
||||
| "displayName"
|
||||
| "summary"
|
||||
| "ownerUserId"
|
||||
| "ownerPublisherId"
|
||||
| "canonicalSkillId"
|
||||
| "forkOf"
|
||||
| "latestVersionId"
|
||||
@@ -65,6 +72,7 @@ export type PublicSoul = Pick<
|
||||
| "displayName"
|
||||
| "summary"
|
||||
| "ownerUserId"
|
||||
| "ownerPublisherId"
|
||||
| "latestVersionId"
|
||||
| "tags"
|
||||
| "stats"
|
||||
@@ -85,6 +93,22 @@ export function toPublicUser(user: Doc<"users"> | null | undefined): PublicUser
|
||||
};
|
||||
}
|
||||
|
||||
export function toPublicPublisher(
|
||||
publisher: Doc<"publishers"> | null | undefined,
|
||||
): PublicPublisher | null {
|
||||
if (!publisher || publisher.deletedAt || publisher.deactivatedAt) return null;
|
||||
return {
|
||||
_id: publisher._id,
|
||||
_creationTime: publisher._creationTime,
|
||||
kind: publisher.kind,
|
||||
handle: publisher.handle,
|
||||
displayName: publisher.displayName,
|
||||
image: publisher.image,
|
||||
bio: publisher.bio,
|
||||
linkedUserId: publisher.linkedUserId,
|
||||
};
|
||||
}
|
||||
|
||||
export function toPublicSkill(skill: HydratableSkill | null | undefined): PublicSkill | null {
|
||||
if (!skill) return null;
|
||||
if (!isPublicSkillDoc(skill)) return null;
|
||||
@@ -112,6 +136,7 @@ export function toPublicSkill(skill: HydratableSkill | null | undefined): Public
|
||||
displayName: skill.displayName,
|
||||
summary: skill.summary,
|
||||
ownerUserId: skill.ownerUserId,
|
||||
ownerPublisherId: skill.ownerPublisherId,
|
||||
canonicalSkillId: skill.canonicalSkillId,
|
||||
forkOf: skill.forkOf,
|
||||
latestVersionId: skill.latestVersionId,
|
||||
@@ -132,6 +157,7 @@ export function toPublicSoul(soul: Doc<"souls"> | null | undefined): PublicSoul
|
||||
displayName: soul.displayName,
|
||||
summary: soul.summary,
|
||||
ownerUserId: soul.ownerUserId,
|
||||
ownerPublisherId: soul.ownerPublisherId,
|
||||
latestVersionId: soul.latestVersionId,
|
||||
tags: soul.tags,
|
||||
stats: soul.stats,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
findOversizedPublishFile,
|
||||
getPublishFileSizeError,
|
||||
getPublishTotalSizeError,
|
||||
MAX_PUBLISH_FILE_BYTES,
|
||||
} from "./publishLimits";
|
||||
|
||||
describe("publishLimits", () => {
|
||||
it("finds files over the max publish file size", () => {
|
||||
expect(
|
||||
findOversizedPublishFile([
|
||||
{ path: "small.txt", size: 128 },
|
||||
{ path: "big.txt", size: MAX_PUBLISH_FILE_BYTES + 1 },
|
||||
]),
|
||||
).toEqual({
|
||||
path: "big.txt",
|
||||
size: MAX_PUBLISH_FILE_BYTES + 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("formats user-facing size errors", () => {
|
||||
expect(getPublishFileSizeError("dist/plugin.wasm")).toBe(
|
||||
'File "dist/plugin.wasm" exceeds 10MB limit',
|
||||
);
|
||||
expect(getPublishTotalSizeError("package")).toBe("Package exceeds 50MB limit");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
export const MAX_PUBLISH_TOTAL_BYTES = 50 * 1024 * 1024;
|
||||
export const MAX_PUBLISH_FILE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
type SizedPathLike = {
|
||||
path: string;
|
||||
size: number;
|
||||
};
|
||||
|
||||
export function findOversizedPublishFile<TFile extends SizedPathLike>(files: TFile[]) {
|
||||
return files.find((file) => file.size > MAX_PUBLISH_FILE_BYTES) ?? null;
|
||||
}
|
||||
|
||||
export function getPublishFileSizeError(path: string) {
|
||||
return `File "${path}" exceeds 10MB limit`;
|
||||
}
|
||||
|
||||
export function getPublishTotalSizeError(target: "skill bundle" | "package") {
|
||||
return `${target[0]?.toUpperCase() ?? ""}${target.slice(1)} exceeds 50MB limit`;
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import { getSkillBadgeMap, isSkillHighlighted } from "./badges";
|
||||
import { generateChangelogForPublish } from "./changelog";
|
||||
import { generateEmbedding } from "./embeddings";
|
||||
import { requireGitHubAccountAge } from "./githubAccount";
|
||||
import { runStaticModerationScan } from "./moderationEngine";
|
||||
import type { PublicUser } from "./public";
|
||||
import {
|
||||
computeQualitySignals,
|
||||
@@ -28,9 +27,15 @@ import {
|
||||
sanitizePath,
|
||||
} from "./skills";
|
||||
import { generateSkillSummary } from "./skillSummary";
|
||||
import { runStaticPublishScan } from "./staticPublishScan";
|
||||
import type { WebhookSkillPayload } from "./webhooks";
|
||||
import {
|
||||
findOversizedPublishFile,
|
||||
getPublishFileSizeError,
|
||||
getPublishTotalSizeError,
|
||||
MAX_PUBLISH_TOTAL_BYTES,
|
||||
} from "./publishLimits";
|
||||
|
||||
const MAX_TOTAL_BYTES = 50 * 1024 * 1024;
|
||||
const MAX_FILES_FOR_EMBEDDING = 40;
|
||||
const QUALITY_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
const QUALITY_ACTIVITY_LIMIT = 60;
|
||||
@@ -73,6 +78,7 @@ export type PublishOptions = {
|
||||
bypassQualityGate?: boolean;
|
||||
skipBackup?: boolean;
|
||||
skipWebhook?: boolean;
|
||||
ownerPublisherId?: Id<"publishers">;
|
||||
};
|
||||
|
||||
export async function publishVersionForUser(
|
||||
@@ -119,9 +125,14 @@ export async function publishVersionForUser(
|
||||
throw new ConvexError("Only text-based files are allowed");
|
||||
}
|
||||
|
||||
const oversizedFile = findOversizedPublishFile(publishFiles);
|
||||
if (oversizedFile) {
|
||||
throw new ConvexError(getPublishFileSizeError(oversizedFile.path));
|
||||
}
|
||||
|
||||
const totalBytes = publishFiles.reduce((sum, file) => sum + file.size, 0);
|
||||
if (totalBytes > MAX_TOTAL_BYTES) {
|
||||
throw new ConvexError("Skill bundle exceeds 50MB limit");
|
||||
if (totalBytes > MAX_PUBLISH_TOTAL_BYTES) {
|
||||
throw new ConvexError(getPublishTotalSizeError("skill bundle"));
|
||||
}
|
||||
|
||||
const readmeFile = publishFiles.find(
|
||||
@@ -221,14 +232,13 @@ export async function publishVersionForUser(
|
||||
.filter((file) => !file.path.toLowerCase().endsWith(".md"))
|
||||
.slice(0, MAX_FILES_FOR_EMBEDDING);
|
||||
|
||||
const staticScan = runStaticModerationScan({
|
||||
const staticScan = await runStaticPublishScan(ctx, {
|
||||
slug,
|
||||
displayName,
|
||||
summary,
|
||||
frontmatter,
|
||||
metadata,
|
||||
files: publishFiles.map((file) => ({ path: file.path, size: file.size })),
|
||||
fileContents,
|
||||
files: publishFiles,
|
||||
});
|
||||
|
||||
const embeddingText = buildEmbeddingText({
|
||||
@@ -263,6 +273,7 @@ export async function publishVersionForUser(
|
||||
|
||||
const publishResult = (await ctx.runMutation(internal.skills.insertVersion, {
|
||||
userId,
|
||||
ownerPublisherId: options.ownerPublisherId,
|
||||
slug,
|
||||
displayName,
|
||||
version,
|
||||
|
||||
@@ -157,13 +157,14 @@ describe("digestToOwnerInfo", () => {
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.ownerHandle).toBe("jdoe");
|
||||
expect(result!.owner).toEqual({
|
||||
_id: "users:owner",
|
||||
_id: "publishers:missing",
|
||||
_creationTime: 0,
|
||||
kind: "user",
|
||||
handle: "jdoe",
|
||||
name: "John",
|
||||
displayName: "John Doe",
|
||||
image: "https://example.com/avatar.png",
|
||||
bio: undefined,
|
||||
linkedUserId: "users:owner",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -190,13 +191,14 @@ describe("digestToOwnerInfo", () => {
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.ownerHandle).toBe("users:owner");
|
||||
expect(result!.owner).toEqual({
|
||||
_id: "users:owner",
|
||||
_id: "publishers:missing",
|
||||
_creationTime: 0,
|
||||
handle: undefined,
|
||||
name: "No Handle User",
|
||||
kind: "user",
|
||||
handle: "users:owner",
|
||||
displayName: "No Handle",
|
||||
image: "https://example.com/avatar.png",
|
||||
bio: undefined,
|
||||
linkedUserId: "users:owner",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
import type { MutationCtx } from "../_generated/server";
|
||||
import type { HydratableSkill, PublicUser } from "./public";
|
||||
import type { HydratableSkill, PublicPublisher } from "./public";
|
||||
|
||||
function pick<T extends Record<string, unknown>, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
|
||||
return Object.fromEntries(keys.map((k) => [k, obj[k]])) as Pick<T, K>;
|
||||
@@ -16,6 +16,7 @@ const SHARED_KEYS = [
|
||||
"displayName",
|
||||
"summary",
|
||||
"ownerUserId",
|
||||
"ownerPublisherId",
|
||||
"canonicalSkillId",
|
||||
"forkOf",
|
||||
"latestVersionId",
|
||||
@@ -40,6 +41,7 @@ export type SkillSearchDigestFields = Pick<Doc<"skills">, (typeof SHARED_KEYS)[n
|
||||
skillId: Id<"skills">;
|
||||
isSuspicious?: boolean;
|
||||
ownerHandle?: string;
|
||||
ownerKind?: "user" | "org";
|
||||
ownerName?: string;
|
||||
ownerDisplayName?: string;
|
||||
ownerImage?: string;
|
||||
@@ -106,14 +108,22 @@ function hasDigestChanged(
|
||||
export function digestToOwnerInfo(
|
||||
digest: Pick<
|
||||
Doc<"skillSearchDigest">,
|
||||
"ownerHandle" | "ownerName" | "ownerDisplayName" | "ownerImage" | "ownerUserId"
|
||||
| "ownerHandle"
|
||||
| "ownerKind"
|
||||
| "ownerName"
|
||||
| "ownerDisplayName"
|
||||
| "ownerImage"
|
||||
| "ownerUserId"
|
||||
| "ownerPublisherId"
|
||||
>,
|
||||
): { ownerHandle: string | null; owner: PublicUser | null } | null {
|
||||
): { ownerHandle: string | null; owner: PublicPublisher | null } | null {
|
||||
if (digest.ownerHandle === undefined) return null;
|
||||
// Empty string means backfilled but owner has no handle.
|
||||
// Use userId as fallback handle, matching the live getOwnerInfo path.
|
||||
const handle = digest.ownerHandle || undefined;
|
||||
const fallbackHandle = handle ?? String(digest.ownerUserId);
|
||||
const fallbackHandle =
|
||||
handle ?? String(digest.ownerPublisherId ?? digest.ownerUserId);
|
||||
const resolvedHandle = handle ?? fallbackHandle;
|
||||
// Determine if we have real profile data (deactivated/deleted owners have
|
||||
// all profile fields undefined, while handle-less visible owners still have
|
||||
// name/displayName/image populated).
|
||||
@@ -126,13 +136,14 @@ export function digestToOwnerInfo(
|
||||
owner:
|
||||
handle || hasProfileData
|
||||
? {
|
||||
_id: digest.ownerUserId,
|
||||
_id: digest.ownerPublisherId ?? ("publishers:missing" as Id<"publishers">),
|
||||
_creationTime: 0,
|
||||
handle,
|
||||
name: digest.ownerName,
|
||||
displayName: digest.ownerDisplayName,
|
||||
handle: resolvedHandle,
|
||||
displayName: digest.ownerDisplayName ?? digest.ownerName ?? resolvedHandle,
|
||||
image: digest.ownerImage,
|
||||
bio: undefined,
|
||||
kind: digest.ownerKind ?? "user",
|
||||
linkedUserId: digest.ownerKind === "org" ? undefined : digest.ownerUserId,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ActionCtx } from "../_generated/server";
|
||||
import { runStaticModerationScan, type StaticScanResult } from "./moderationEngine";
|
||||
import { readStorageText } from "./packageRegistry";
|
||||
import { isTextFile } from "./skills";
|
||||
|
||||
type PublishFile = {
|
||||
path: string;
|
||||
size: number;
|
||||
storageId: string;
|
||||
contentType?: string;
|
||||
};
|
||||
|
||||
type StaticPublishScanInput = {
|
||||
slug: string;
|
||||
displayName: string;
|
||||
summary?: string;
|
||||
frontmatter?: Record<string, unknown>;
|
||||
metadata?: unknown;
|
||||
files: PublishFile[];
|
||||
};
|
||||
|
||||
export async function runStaticPublishScan(
|
||||
ctx: Pick<ActionCtx, "storage">,
|
||||
input: StaticPublishScanInput,
|
||||
): Promise<StaticScanResult> {
|
||||
const fileContents: Array<{ path: string; content: string }> = [];
|
||||
for (const file of input.files) {
|
||||
if (!isTextFile(file.path, file.contentType ?? undefined)) continue;
|
||||
const content = await readStorageText(ctx, file.storageId);
|
||||
fileContents.push({ path: file.path, content });
|
||||
}
|
||||
|
||||
return runStaticModerationScan({
|
||||
slug: input.slug,
|
||||
displayName: input.displayName,
|
||||
summary: input.summary,
|
||||
frontmatter: input.frontmatter ?? {},
|
||||
metadata: input.metadata,
|
||||
files: input.files.map((file) => ({ path: file.path, size: file.size })),
|
||||
fileContents,
|
||||
});
|
||||
}
|
||||
@@ -20,6 +20,30 @@ import {
|
||||
SECURITY_EVALUATOR_SYSTEM_PROMPT,
|
||||
} from "./lib/securityPrompt";
|
||||
|
||||
const internalRefs = internal as unknown as {
|
||||
packages: {
|
||||
getReleaseByIdInternal: unknown;
|
||||
getPackageByIdInternal: unknown;
|
||||
updateReleaseLlmAnalysisInternal: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
async function runQueryRef<T>(
|
||||
ctx: { runQuery: (ref: never, args: never) => Promise<unknown> },
|
||||
ref: unknown,
|
||||
args: unknown,
|
||||
): Promise<T> {
|
||||
return (await ctx.runQuery(ref as never, args as never)) as T;
|
||||
}
|
||||
|
||||
async function runMutationRef<T>(
|
||||
ctx: { runMutation: (ref: never, args: never) => Promise<unknown> },
|
||||
ref: unknown,
|
||||
args: unknown,
|
||||
): Promise<T> {
|
||||
return (await ctx.runMutation(ref as never, args as never)) as T;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -249,6 +273,176 @@ export const evaluateWithLlm = internalAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const evaluatePackageReleaseWithLlm = internalAction({
|
||||
args: {
|
||||
releaseId: v.id("packageReleases"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
if (!apiKey) {
|
||||
console.log("[llmEval] OPENAI_API_KEY not configured, skipping package evaluation");
|
||||
return;
|
||||
}
|
||||
|
||||
const model = getLlmEvalModel();
|
||||
const storeError = async (message: string) => {
|
||||
console.error(`[llmEval:package] ${message}`);
|
||||
await runMutationRef(ctx, internalRefs.packages.updateReleaseLlmAnalysisInternal, {
|
||||
releaseId: args.releaseId,
|
||||
llmAnalysis: {
|
||||
status: "error",
|
||||
summary: message,
|
||||
model,
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const release = (await runQueryRef(ctx, internalRefs.packages.getReleaseByIdInternal, {
|
||||
releaseId: args.releaseId,
|
||||
})) as Doc<"packageReleases"> | null;
|
||||
if (!release || release.softDeletedAt) {
|
||||
await storeError(`Release ${args.releaseId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const pkg = (await runQueryRef(ctx, internalRefs.packages.getPackageByIdInternal, {
|
||||
packageId: release.packageId,
|
||||
})) as Doc<"packages"> | null;
|
||||
if (!pkg) {
|
||||
await storeError(`Package ${release.packageId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
let readmeContent = "";
|
||||
const fileContents: Array<{ path: string; content: string }> = [];
|
||||
for (const f of release.files) {
|
||||
try {
|
||||
const blob = await ctx.storage.get(f.storageId as Id<"_storage">);
|
||||
if (!blob) continue;
|
||||
const content = await blob.text();
|
||||
fileContents.push({ path: f.path, content });
|
||||
const lower = f.path.toLowerCase();
|
||||
if (!readmeContent && (lower === "readme.md" || lower === "readme.mdx" || lower === "readme.markdown")) {
|
||||
readmeContent = content;
|
||||
}
|
||||
} catch {
|
||||
// Best-effort read.
|
||||
}
|
||||
}
|
||||
|
||||
if (!readmeContent) {
|
||||
const packageJsonText = fileContents.find((entry) => entry.path.toLowerCase() === "package.json")?.content;
|
||||
readmeContent = packageJsonText ?? `# ${pkg.displayName}\n\n${release.summary ?? pkg.summary ?? pkg.name}`;
|
||||
}
|
||||
|
||||
const allContent = [readmeContent, ...fileContents.map((f) => f.content)].join("\n");
|
||||
const injectionSignals = detectInjectionPatterns(allContent);
|
||||
|
||||
const evalCtx: SkillEvalContext = {
|
||||
slug: pkg.name,
|
||||
displayName: pkg.displayName,
|
||||
ownerUserId: String(pkg.ownerUserId),
|
||||
version: release.version,
|
||||
createdAt: release.createdAt,
|
||||
summary: release.summary ?? pkg.summary ?? undefined,
|
||||
source: pkg.sourceRepo ?? undefined,
|
||||
homepage: undefined,
|
||||
parsed: {
|
||||
frontmatter: {},
|
||||
metadata: {
|
||||
compatibility: release.compatibility,
|
||||
capabilities: release.capabilities,
|
||||
verification: release.verification,
|
||||
staticScan: release.staticScan,
|
||||
},
|
||||
},
|
||||
files: release.files.map((f) => ({ path: f.path, size: f.size })),
|
||||
skillMdContent: readmeContent,
|
||||
fileContents,
|
||||
injectionSignals,
|
||||
};
|
||||
|
||||
const userMessage = assembleEvalUserMessage(evalCtx);
|
||||
const MAX_RETRIES = 3;
|
||||
let raw: string | null = null;
|
||||
try {
|
||||
const body = JSON.stringify({
|
||||
model,
|
||||
instructions: SECURITY_EVALUATOR_SYSTEM_PROMPT,
|
||||
input: userMessage,
|
||||
max_output_tokens: LLM_EVAL_MAX_OUTPUT_TOKENS,
|
||||
text: {
|
||||
format: {
|
||||
type: "json_object",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let response: Response | null = null;
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
response = await fetch("https://api.openai.com/v1/responses", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (response.status === 429 || response.status >= 500) {
|
||||
if (attempt < MAX_RETRIES) {
|
||||
const delay = 2 ** attempt * 2000 + Math.random() * 1000;
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (!response || !response.ok) {
|
||||
const errorText = response ? await response.text() : "No response";
|
||||
await storeError(`OpenAI API error (${response?.status}): ${errorText.slice(0, 200)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as unknown;
|
||||
raw = extractResponseText(payload);
|
||||
} catch (error) {
|
||||
await storeError(
|
||||
`OpenAI API call failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!raw) {
|
||||
await storeError("Empty response from OpenAI");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = parseLlmEvalResponse(raw);
|
||||
if (!result) {
|
||||
await storeError("Failed to parse LLM evaluation response");
|
||||
return;
|
||||
}
|
||||
|
||||
await runMutationRef(ctx, internalRefs.packages.updateReleaseLlmAnalysisInternal, {
|
||||
releaseId: args.releaseId,
|
||||
llmAnalysis: {
|
||||
status: verdictToStatus(result.verdict),
|
||||
verdict: result.verdict,
|
||||
confidence: result.confidence,
|
||||
summary: result.summary,
|
||||
dimensions: result.dimensions,
|
||||
guidance: result.guidance,
|
||||
findings: result.findings || undefined,
|
||||
model,
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Convenience: evaluate a single skill by slug (for testing / manual runs)
|
||||
// Usage: npx convex run llmEval:evaluateBySlug '{"slug": "transcribeexx"}'
|
||||
|
||||
+503
-13
@@ -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
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { list } from "./skills";
|
||||
|
||||
vi.mock("@convex-dev/auth/server", () => ({
|
||||
getAuthUserId: vi.fn(),
|
||||
authTables: {},
|
||||
}));
|
||||
|
||||
type WrappedHandler<TArgs, TResult = unknown> = {
|
||||
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
|
||||
};
|
||||
|
||||
const listHandler = (
|
||||
list as unknown as WrappedHandler<
|
||||
{ ownerPublisherId?: string; ownerUserId?: string; limit?: number },
|
||||
Array<{ slug: string }>
|
||||
>
|
||||
)._handler;
|
||||
|
||||
describe("skills.list", () => {
|
||||
it("includes legacy personal skills when listing a personal publisher", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
|
||||
const legacySkill = {
|
||||
_id: "skills:legacy",
|
||||
_creationTime: 1,
|
||||
slug: "legacy-skill",
|
||||
displayName: "Legacy Skill",
|
||||
summary: "Pre-backfill skill",
|
||||
ownerUserId: "users:owner",
|
||||
ownerPublisherId: undefined,
|
||||
canonicalSkillId: undefined,
|
||||
forkOf: undefined,
|
||||
latestVersionId: undefined,
|
||||
tags: {},
|
||||
badges: undefined,
|
||||
stats: {
|
||||
downloads: 0,
|
||||
installsCurrent: 0,
|
||||
installsAllTime: 0,
|
||||
stars: 0,
|
||||
versions: 1,
|
||||
comments: 0,
|
||||
},
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
moderationFlags: [],
|
||||
moderationReason: undefined,
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "publishers:self") {
|
||||
return {
|
||||
_id: "publishers:self",
|
||||
_creationTime: 1,
|
||||
kind: "user",
|
||||
handle: "owner",
|
||||
displayName: "Owner",
|
||||
linkedUserId: "users:owner",
|
||||
};
|
||||
}
|
||||
if (id === "users:owner") {
|
||||
return {
|
||||
_id: "users:owner",
|
||||
_creationTime: 1,
|
||||
handle: "owner",
|
||||
displayName: "Owner",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn().mockResolvedValue(null),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (table === "skills") {
|
||||
return {
|
||||
withIndex: vi.fn((indexName: string) => {
|
||||
if (indexName === "by_owner_publisher") {
|
||||
return {
|
||||
order: vi.fn(() => ({
|
||||
take: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (indexName === "by_owner") {
|
||||
return {
|
||||
order: vi.fn(() => ({
|
||||
take: vi.fn().mockResolvedValue([legacySkill]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected skills index ${indexName}`);
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "skillBadges") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
take: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await listHandler(
|
||||
ctx as never,
|
||||
{ ownerPublisherId: "publishers:self", limit: 10 } as never,
|
||||
);
|
||||
|
||||
expect(result).toEqual([expect.objectContaining({ slug: "legacy-skill" })]);
|
||||
});
|
||||
});
|
||||
@@ -113,13 +113,14 @@ describe("skills.getBySlug", () => {
|
||||
const result = await getBySlugHandler(ctx, { slug: "demo" } as never);
|
||||
|
||||
expect(result?.owner).toEqual({
|
||||
_id: "users:1",
|
||||
_id: "publishers:demo-owner",
|
||||
_creationTime: 1,
|
||||
kind: "user",
|
||||
handle: "demo-owner",
|
||||
name: "Demo Owner",
|
||||
displayName: "Demo Owner",
|
||||
image: null,
|
||||
bio: "Ships demo skills",
|
||||
linkedUserId: "users:1",
|
||||
});
|
||||
expect(result?.owner).not.toHaveProperty("email");
|
||||
expect(result?.owner).not.toHaveProperty("emailVerificationTime");
|
||||
|
||||
+217
-33
@@ -41,7 +41,18 @@ import {
|
||||
summarizeReasonCodes,
|
||||
verdictFromCodes,
|
||||
} from "./lib/moderationReasonCodes";
|
||||
import { type HydratableSkill, toPublicSkill, toPublicUser } from "./lib/public";
|
||||
import {
|
||||
type HydratableSkill,
|
||||
type PublicPublisher,
|
||||
toPublicPublisher,
|
||||
toPublicSkill,
|
||||
toPublicUser,
|
||||
} from "./lib/public";
|
||||
import {
|
||||
ensurePersonalPublisherForUser,
|
||||
getOwnerPublisher,
|
||||
requirePublisherRole,
|
||||
} from "./lib/publishers";
|
||||
import {
|
||||
AUTO_HIDE_REPORT_THRESHOLD,
|
||||
MAX_ACTIVE_REPORTS_PER_USER,
|
||||
@@ -437,21 +448,36 @@ async function syncSkillModerationFromLatestVersion(
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
}
|
||||
|
||||
function buildConflictingSkillUrl(skill: Doc<"skills">, owner: Doc<"users"> | null | undefined) {
|
||||
function buildConflictingSkillUrl(
|
||||
skill: Doc<"skills">,
|
||||
owner: Doc<"users"> | Doc<"publishers"> | null | undefined,
|
||||
) {
|
||||
if (!owner || owner.deletedAt || owner.deactivatedAt || !isPublicSkillDoc(skill)) return null;
|
||||
const ownerParam = owner.handle?.trim() || String(owner._id);
|
||||
if (!ownerParam) return null;
|
||||
return `/${encodeURIComponent(ownerParam)}/${encodeURIComponent(skill.slug)}`;
|
||||
}
|
||||
|
||||
function buildSlugTakenErrorMessage(skill: Doc<"skills">, owner: Doc<"users"> | null | undefined) {
|
||||
function buildSlugTakenErrorMessage(
|
||||
skill: Doc<"skills">,
|
||||
owner: Doc<"users"> | Doc<"publishers"> | null | undefined,
|
||||
) {
|
||||
if (!owner || owner.deletedAt || owner.deactivatedAt) {
|
||||
return (
|
||||
"This slug is locked to a deleted or banned account. " +
|
||||
"If you believe you are the rightful owner, please contact security@openclaw.ai to reclaim it."
|
||||
);
|
||||
}
|
||||
const base = "Slug is already taken. Choose a different slug.";
|
||||
const url = buildConflictingSkillUrl(skill, owner);
|
||||
if (!url) return base;
|
||||
return `${base} Existing skill: ${url}`;
|
||||
}
|
||||
|
||||
function buildAliasTakenErrorMessage(skill: Doc<"skills">, owner: Doc<"users"> | null | undefined) {
|
||||
function buildAliasTakenErrorMessage(
|
||||
skill: Doc<"skills">,
|
||||
owner: Doc<"users"> | Doc<"publishers"> | null | undefined,
|
||||
) {
|
||||
const base = "Slug redirects to an existing skill. Choose a different slug.";
|
||||
const url = buildConflictingSkillUrl(skill, owner);
|
||||
if (!url) return base;
|
||||
@@ -972,7 +998,7 @@ type PublicSkillEntry = {
|
||||
skill: NonNullable<ReturnType<typeof toPublicSkill>>;
|
||||
latestVersion: PublicSkillListVersion | null;
|
||||
ownerHandle: string | null;
|
||||
owner: ReturnType<typeof toPublicUser> | null;
|
||||
owner: PublicPublisher | null;
|
||||
};
|
||||
|
||||
type StaffSkillAuditLogEntry = Doc<"auditLogs"> & {
|
||||
@@ -1050,30 +1076,38 @@ async function buildPublicSkillEntries(
|
||||
includeVersion?: boolean;
|
||||
preResolvedOwners?: Map<
|
||||
Id<"skills">,
|
||||
{ ownerHandle: string | null; owner: ReturnType<typeof toPublicUser> | null }
|
||||
{ ownerHandle: string | null; owner: PublicPublisher | null }
|
||||
>;
|
||||
},
|
||||
) {
|
||||
const includeVersion = opts?.includeVersion ?? true;
|
||||
const ownerInfoCache = new Map<
|
||||
Id<"users">,
|
||||
string,
|
||||
Promise<{
|
||||
ownerHandle: string | null;
|
||||
owner: ReturnType<typeof toPublicUser> | null;
|
||||
owner: PublicPublisher | null;
|
||||
}>
|
||||
>();
|
||||
|
||||
const getOwnerInfo = (skillId: Id<"skills">, ownerUserId: Id<"users">) => {
|
||||
const getOwnerInfo = (
|
||||
skillId: Id<"skills">,
|
||||
ownerUserId: Id<"users">,
|
||||
ownerPublisherId?: Id<"publishers"> | null,
|
||||
) => {
|
||||
// Use pre-resolved owner from digest when available to avoid adding the
|
||||
// users table to the reactive read set (which causes thundering-herd
|
||||
// invalidation on every user-doc write).
|
||||
const preResolved = opts?.preResolvedOwners?.get(skillId);
|
||||
if (preResolved?.owner) return Promise.resolve(preResolved);
|
||||
|
||||
const cached = ownerInfoCache.get(ownerUserId);
|
||||
const cacheKey = String(ownerPublisherId ?? ownerUserId);
|
||||
const cached = ownerInfoCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
const ownerPromise = ctx.db.get(ownerUserId).then((ownerDoc) => {
|
||||
const publicOwner = toPublicUser(ownerDoc);
|
||||
const ownerPromise = getOwnerPublisher(ctx, {
|
||||
ownerPublisherId,
|
||||
ownerUserId,
|
||||
}).then((ownerDoc) => {
|
||||
const publicOwner = toPublicPublisher(ownerDoc);
|
||||
if (!publicOwner) {
|
||||
return { ownerHandle: null, owner: null };
|
||||
}
|
||||
@@ -1082,7 +1116,7 @@ async function buildPublicSkillEntries(
|
||||
owner: publicOwner,
|
||||
};
|
||||
});
|
||||
ownerInfoCache.set(ownerUserId, ownerPromise);
|
||||
ownerInfoCache.set(cacheKey, ownerPromise);
|
||||
return ownerPromise;
|
||||
};
|
||||
|
||||
@@ -1095,7 +1129,7 @@ async function buildPublicSkillEntries(
|
||||
includeVersion && !hasSummary && skill.latestVersionId
|
||||
? ctx.db.get(skill.latestVersionId)
|
||||
: null,
|
||||
getOwnerInfo(skill._id, skill.ownerUserId),
|
||||
getOwnerInfo(skill._id, skill.ownerUserId, skill.ownerPublisherId),
|
||||
]);
|
||||
const publicSkill = toPublicSkill(skill);
|
||||
if (!publicSkill || !ownerInfo.owner) return null;
|
||||
@@ -1337,20 +1371,43 @@ export const getBySlug = query({
|
||||
if (!skill) return null;
|
||||
|
||||
const userId = await getAuthUserId(ctx);
|
||||
const isOwner = Boolean(userId && userId === skill.ownerUserId);
|
||||
const ownerPublisher = await getOwnerPublisher(ctx, {
|
||||
ownerPublisherId: skill.ownerPublisherId,
|
||||
ownerUserId: skill.ownerUserId,
|
||||
});
|
||||
const membership =
|
||||
userId && skill.ownerPublisherId
|
||||
? await ctx.db
|
||||
.query("publisherMembers")
|
||||
.withIndex("by_publisher_user", (q) =>
|
||||
q.eq("publisherId", skill.ownerPublisherId!).eq("userId", userId),
|
||||
)
|
||||
.unique()
|
||||
: null;
|
||||
const isOwner = Boolean(userId && (userId === skill.ownerUserId || membership));
|
||||
|
||||
const latestVersion = toPublicSkillVersion(
|
||||
skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null,
|
||||
);
|
||||
const owner = toPublicUser(await ctx.db.get(skill.ownerUserId));
|
||||
const owner = toPublicPublisher(ownerPublisher);
|
||||
if (!owner) return null;
|
||||
const badges = await getSkillBadgeMap(ctx, skill._id);
|
||||
|
||||
const forkOfSkill = skill.forkOf?.skillId ? await ctx.db.get(skill.forkOf.skillId) : null;
|
||||
const forkOfOwner = forkOfSkill ? await ctx.db.get(forkOfSkill.ownerUserId) : null;
|
||||
const forkOfOwner = forkOfSkill
|
||||
? await getOwnerPublisher(ctx, {
|
||||
ownerPublisherId: forkOfSkill.ownerPublisherId,
|
||||
ownerUserId: forkOfSkill.ownerUserId,
|
||||
})
|
||||
: null;
|
||||
|
||||
const canonicalSkill = skill.canonicalSkillId ? await ctx.db.get(skill.canonicalSkillId) : null;
|
||||
const canonicalOwner = canonicalSkill ? await ctx.db.get(canonicalSkill.ownerUserId) : null;
|
||||
const canonicalOwner = canonicalSkill
|
||||
? await getOwnerPublisher(ctx, {
|
||||
ownerPublisherId: canonicalSkill.ownerPublisherId,
|
||||
ownerUserId: canonicalSkill.ownerUserId,
|
||||
})
|
||||
: null;
|
||||
|
||||
const publicSkill = toPublicSkill({ ...skill, badges });
|
||||
|
||||
@@ -1426,8 +1483,8 @@ export const getBySlug = query({
|
||||
displayName: forkOfSkill.displayName,
|
||||
},
|
||||
owner: {
|
||||
handle: forkOfOwner?.handle ?? forkOfOwner?.name ?? null,
|
||||
userId: forkOfOwner?._id ?? null,
|
||||
handle: forkOfOwner?.handle ?? null,
|
||||
userId: forkOfOwner?.linkedUserId ?? null,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
@@ -1438,8 +1495,8 @@ export const getBySlug = query({
|
||||
displayName: canonicalSkill.displayName,
|
||||
},
|
||||
owner: {
|
||||
handle: canonicalOwner?.handle ?? canonicalOwner?.name ?? null,
|
||||
userId: canonicalOwner?._id ?? null,
|
||||
handle: canonicalOwner?.handle ?? null,
|
||||
userId: canonicalOwner?.linkedUserId ?? null,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
@@ -1958,6 +2015,7 @@ export const list = query({
|
||||
args: {
|
||||
batch: v.optional(v.string()),
|
||||
ownerUserId: v.optional(v.id("users")),
|
||||
ownerPublisherId: v.optional(v.id("publishers")),
|
||||
limit: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
@@ -1984,6 +2042,81 @@ export const list = query({
|
||||
.map((skill) => toPublicSkill(skill))
|
||||
.filter((skill): skill is NonNullable<typeof skill> => Boolean(skill));
|
||||
}
|
||||
const ownerPublisherId = args.ownerPublisherId;
|
||||
if (ownerPublisherId) {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
const ownerPublisher = await ctx.db.get(ownerPublisherId);
|
||||
const membership =
|
||||
userId &&
|
||||
(await ctx.db
|
||||
.query("publisherMembers")
|
||||
.withIndex("by_publisher_user", (q) =>
|
||||
q.eq("publisherId", ownerPublisherId).eq("userId", userId),
|
||||
)
|
||||
.unique());
|
||||
const isOwnDashboard = Boolean(
|
||||
membership || (userId && ownerPublisher?.kind === "user" && ownerPublisher.linkedUserId === userId),
|
||||
);
|
||||
const scopedEntries = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", ownerPublisherId))
|
||||
.order("desc")
|
||||
.take(takeLimit);
|
||||
const legacyEntries =
|
||||
ownerPublisher?.kind === "user" && ownerPublisher.linkedUserId
|
||||
? await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_owner", (q) => q.eq("ownerUserId", ownerPublisher.linkedUserId!))
|
||||
.order("desc")
|
||||
.take(takeLimit)
|
||||
: [];
|
||||
const combined = [...scopedEntries, ...legacyEntries].filter(
|
||||
(skill, index, all) =>
|
||||
!skill.softDeletedAt &&
|
||||
(!skill.ownerPublisherId || skill.ownerPublisherId === ownerPublisherId) &&
|
||||
all.findIndex((candidate) => candidate._id === skill._id) === index,
|
||||
);
|
||||
const filtered = combined.slice(0, limit);
|
||||
const withBadges = await attachBadgesToSkills(ctx, filtered);
|
||||
|
||||
if (isOwnDashboard) {
|
||||
return withBadges
|
||||
.map((skill) => {
|
||||
const publicSkill = toPublicSkill(skill);
|
||||
if (publicSkill) return publicSkill;
|
||||
const isPending =
|
||||
skill.moderationStatus === "hidden" && skill.moderationReason === "pending.scan";
|
||||
if (isPending) {
|
||||
const { badges } = skill;
|
||||
return {
|
||||
_id: skill._id,
|
||||
_creationTime: skill._creationTime,
|
||||
slug: skill.slug,
|
||||
displayName: skill.displayName,
|
||||
summary: skill.summary,
|
||||
ownerUserId: skill.ownerUserId,
|
||||
ownerPublisherId: skill.ownerPublisherId,
|
||||
canonicalSkillId: skill.canonicalSkillId,
|
||||
forkOf: skill.forkOf,
|
||||
latestVersionId: skill.latestVersionId,
|
||||
tags: skill.tags,
|
||||
badges,
|
||||
stats: skill.stats,
|
||||
createdAt: skill.createdAt,
|
||||
updatedAt: skill.updatedAt,
|
||||
pendingReview: true as const,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((skill): skill is NonNullable<typeof skill> => Boolean(skill));
|
||||
}
|
||||
|
||||
const visibleSkills = await filterSkillsByActiveOwner(ctx, withBadges);
|
||||
return visibleSkills
|
||||
.map((skill) => toPublicSkill(skill))
|
||||
.filter((skill): skill is NonNullable<typeof skill> => Boolean(skill));
|
||||
}
|
||||
const ownerUserId = args.ownerUserId;
|
||||
if (ownerUserId) {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
@@ -4358,6 +4491,7 @@ export const getVersionBySkillAndVersion = query({
|
||||
|
||||
export const publishVersion: ReturnType<typeof action> = action({
|
||||
args: {
|
||||
ownerHandle: v.optional(v.string()),
|
||||
slug: v.string(),
|
||||
displayName: v.string(),
|
||||
version: v.string(),
|
||||
@@ -4385,7 +4519,14 @@ export const publishVersion: ReturnType<typeof action> = action({
|
||||
throw new ConvexError("MIT-0 license terms must be accepted to publish skills");
|
||||
}
|
||||
const { userId } = await requireUserFromAction(ctx);
|
||||
return publishVersionForUser(ctx, userId, args);
|
||||
const target = (await ctx.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
@@ -1,5 +1,9 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@convex-dev/auth/server", () => ({
|
||||
getAuthUserId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./lib/access", async () => {
|
||||
const actual = await vi.importActual<typeof import("./lib/access")>("./lib/access");
|
||||
return { ...actual, requireUser: vi.fn() };
|
||||
@@ -10,21 +14,45 @@ vi.mock("./skillStatEvents", () => ({
|
||||
}));
|
||||
|
||||
const { requireUser } = await import("./lib/access");
|
||||
const { getAuthUserId } = await import("@convex-dev/auth/server");
|
||||
const { insertStatEvent } = await import("./skillStatEvents");
|
||||
const {
|
||||
ensureHandler,
|
||||
list,
|
||||
searchInternal,
|
||||
banUserInternal,
|
||||
me,
|
||||
placeUserUnderModerationInternal,
|
||||
reserveHandleInternal,
|
||||
syncGitHubProfileInternal,
|
||||
} = await import("./users");
|
||||
|
||||
type WrappedHandler<TArgs, TResult> = {
|
||||
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
|
||||
};
|
||||
|
||||
const meHandler = (me as unknown as WrappedHandler<Record<string, never>, unknown>)._handler;
|
||||
|
||||
function makeCtx() {
|
||||
const patch = vi.fn();
|
||||
const get = vi.fn();
|
||||
const insert = vi.fn();
|
||||
const publisherRows = new Map<string, Record<string, unknown>>();
|
||||
const publisherMembers: Array<Record<string, unknown>> = [];
|
||||
const get = vi.fn(async (id: string) => publisherRows.get(id) ?? null);
|
||||
const insert = vi.fn(async (table: string, value: Record<string, unknown>) => {
|
||||
if (table === "publishers") {
|
||||
const handle = typeof value.handle === "string" ? value.handle : "user";
|
||||
const id = `publishers:${handle}`;
|
||||
publisherRows.set(id, { _id: id, _creationTime: 1, ...value });
|
||||
return id;
|
||||
}
|
||||
if (table === "publisherMembers") {
|
||||
const id = `publisherMembers:${publisherMembers.length + 1}`;
|
||||
publisherMembers.push({ _id: id, ...value });
|
||||
return id;
|
||||
}
|
||||
if (table === "auditLogs") return "auditLogs:1";
|
||||
return `${table}:1`;
|
||||
});
|
||||
const query = vi.fn((table: string) => {
|
||||
if (table === "reservedHandles") {
|
||||
return {
|
||||
@@ -44,6 +72,39 @@ function makeCtx() {
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name === "by_handle") {
|
||||
return { unique: vi.fn(async () => null) };
|
||||
}
|
||||
if (name === "by_linked_user") {
|
||||
return { unique: vi.fn(async () => null) };
|
||||
}
|
||||
throw new Error(`Unexpected publishers index ${name}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_publisher_user") {
|
||||
throw new Error(`Unexpected publisherMembers index ${name}`);
|
||||
}
|
||||
return { unique: vi.fn(async () => null) };
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "packages" || table === "skills") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_owner_publisher") {
|
||||
throw new Error(`Unexpected ${table} index ${name}`);
|
||||
}
|
||||
return { collect: vi.fn(async () => []) };
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
});
|
||||
return {
|
||||
@@ -116,6 +177,7 @@ function makeBanCtx() {
|
||||
describe("ensureHandler", () => {
|
||||
afterEach(() => {
|
||||
vi.mocked(requireUser).mockReset();
|
||||
vi.mocked(getAuthUserId).mockReset();
|
||||
});
|
||||
|
||||
it("updates handle and display name when GitHub login changes", async () => {
|
||||
@@ -212,7 +274,14 @@ describe("ensureHandler", () => {
|
||||
|
||||
const result = await ensureHandler(ctx);
|
||||
|
||||
expect(patch).not.toHaveBeenCalled();
|
||||
expect(patch).not.toHaveBeenCalledWith(
|
||||
"users:4",
|
||||
expect.objectContaining({
|
||||
handle: expect.anything(),
|
||||
displayName: expect.anything(),
|
||||
role: expect.anything(),
|
||||
}),
|
||||
);
|
||||
expect(get).toHaveBeenCalledWith("users:4");
|
||||
expect(result).toMatchObject({ _id: "users:4" });
|
||||
});
|
||||
@@ -310,6 +379,125 @@ describe("ensureHandler", () => {
|
||||
|
||||
expect(patch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not auto-claim a handle already owned by an org publisher", async () => {
|
||||
const { ctx, patch, query } = makeCtx();
|
||||
query.mockImplementation(((table: string) => {
|
||||
if (table === "reservedHandles") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_handle_active_updatedAt") {
|
||||
throw new Error(`Unexpected reservedHandles index ${name}`);
|
||||
}
|
||||
return { order: () => ({ take: async () => [] }) };
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: (name: string, builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown) => {
|
||||
let handle = "";
|
||||
let linkedUserId = "";
|
||||
const q = {
|
||||
eq: (field: string, value: string) => {
|
||||
if (field === "handle") handle = value;
|
||||
if (field === "linkedUserId") linkedUserId = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
builder?.(q);
|
||||
if (name === "by_handle") {
|
||||
return {
|
||||
unique: vi.fn(async () =>
|
||||
handle === "openclaw"
|
||||
? {
|
||||
_id: "publishers:openclaw",
|
||||
kind: "org",
|
||||
handle: "openclaw",
|
||||
displayName: "OpenClaw",
|
||||
}
|
||||
: null,
|
||||
),
|
||||
};
|
||||
}
|
||||
if (name === "by_linked_user") {
|
||||
return {
|
||||
unique: vi.fn(async () =>
|
||||
linkedUserId === "users:other"
|
||||
? {
|
||||
_id: "publishers:openclaw-user",
|
||||
kind: "user",
|
||||
handle: "openclaw-user",
|
||||
linkedUserId: "users:other",
|
||||
displayName: "OpenClaw User",
|
||||
}
|
||||
: null,
|
||||
),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected publishers index ${name}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_publisher_user") {
|
||||
throw new Error(`Unexpected publisherMembers index ${name}`);
|
||||
}
|
||||
return { unique: vi.fn(async () => null) };
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "packages" || table === "skills") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_owner_publisher") {
|
||||
throw new Error(`Unexpected ${table} index ${name}`);
|
||||
}
|
||||
return { collect: vi.fn(async () => []) };
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
}) as never);
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:other",
|
||||
user: {
|
||||
_id: "users:other",
|
||||
_creationTime: 1,
|
||||
handle: undefined,
|
||||
displayName: undefined,
|
||||
name: "openclaw",
|
||||
email: undefined,
|
||||
role: "user",
|
||||
createdAt: 1,
|
||||
},
|
||||
} as never);
|
||||
|
||||
await ensureHandler(ctx);
|
||||
|
||||
expect(patch).not.toHaveBeenCalledWith(
|
||||
"users:other",
|
||||
expect.objectContaining({ handle: "openclaw" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("me", () => {
|
||||
afterEach(() => {
|
||||
vi.mocked(getAuthUserId).mockReset();
|
||||
});
|
||||
|
||||
it("returns null when auth resolution throws", async () => {
|
||||
vi.mocked(getAuthUserId).mockRejectedValue(new Error("stale session"));
|
||||
const get = vi.fn();
|
||||
|
||||
const result = await meHandler({ db: { get } } as never, {});
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("users.syncGitHubProfileInternal", () => {
|
||||
@@ -370,6 +558,119 @@ describe("users.syncGitHubProfileInternal", () => {
|
||||
expect.objectContaining({ handle: "openclaw" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a derived handle unchanged when the new login belongs to an org publisher", async () => {
|
||||
const { ctx, get, patch, query } = makeCtx();
|
||||
get.mockResolvedValue({
|
||||
_id: "users:other",
|
||||
handle: "old-handle",
|
||||
displayName: "old-handle",
|
||||
name: "old-handle",
|
||||
});
|
||||
query.mockImplementation(((table: string) => {
|
||||
if (table === "reservedHandles") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_handle_active_updatedAt") {
|
||||
throw new Error(`Unexpected reservedHandles index ${name}`);
|
||||
}
|
||||
return { order: () => ({ take: async () => [] }) };
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: (name: string, builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown) => {
|
||||
let handle = "";
|
||||
let linkedUserId = "";
|
||||
const q = {
|
||||
eq: (field: string, value: string) => {
|
||||
if (field === "handle") handle = value;
|
||||
if (field === "linkedUserId") linkedUserId = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
builder?.(q);
|
||||
if (name === "by_handle") {
|
||||
return {
|
||||
unique: vi.fn(async () =>
|
||||
handle === "openclaw"
|
||||
? {
|
||||
_id: "publishers:openclaw",
|
||||
kind: "org",
|
||||
handle: "openclaw",
|
||||
displayName: "OpenClaw",
|
||||
}
|
||||
: null,
|
||||
),
|
||||
};
|
||||
}
|
||||
if (name === "by_linked_user") {
|
||||
return {
|
||||
unique: vi.fn(async () =>
|
||||
linkedUserId === "users:other"
|
||||
? {
|
||||
_id: "publishers:old-handle",
|
||||
kind: "user",
|
||||
handle: "old-handle",
|
||||
linkedUserId: "users:other",
|
||||
displayName: "Old Handle",
|
||||
}
|
||||
: null,
|
||||
),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected publishers index ${name}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_publisher_user") {
|
||||
throw new Error(`Unexpected publisherMembers index ${name}`);
|
||||
}
|
||||
return { unique: vi.fn(async () => null) };
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "packages" || table === "skills") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_owner_publisher") {
|
||||
throw new Error(`Unexpected ${table} index ${name}`);
|
||||
}
|
||||
return { collect: vi.fn(async () => []) };
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
}) as never);
|
||||
|
||||
const handler = (
|
||||
syncGitHubProfileInternal as unknown as {
|
||||
_handler: (ctx: unknown, args: unknown) => Promise<void>;
|
||||
}
|
||||
)._handler;
|
||||
|
||||
await handler(ctx, {
|
||||
userId: "users:other",
|
||||
name: "openclaw",
|
||||
syncedAt: 10,
|
||||
});
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"users:other",
|
||||
expect.objectContaining({
|
||||
githubProfileSyncedAt: 10,
|
||||
name: "openclaw",
|
||||
}),
|
||||
);
|
||||
expect(patch).not.toHaveBeenCalledWith(
|
||||
"users:other",
|
||||
expect.objectContaining({ handle: "openclaw" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("users.list", () => {
|
||||
|
||||
+180
-6
@@ -6,6 +6,7 @@ import type { ActionCtx, MutationCtx } from "./_generated/server";
|
||||
import { internalAction, internalMutation, internalQuery, mutation, query } from "./functions";
|
||||
import { assertAdmin, assertModerator, requireUser } from "./lib/access";
|
||||
import { syncGitHubProfile } from "./lib/githubAccount";
|
||||
import { ensurePersonalPublisherForUser, getPublisherByHandle } from "./lib/publishers";
|
||||
import { toPublicUser } from "./lib/public";
|
||||
import {
|
||||
getLatestActiveReservedHandle,
|
||||
@@ -32,6 +33,18 @@ export const getByIdInternal = internalQuery({
|
||||
handler: async (ctx, args) => ctx.db.get(args.userId),
|
||||
});
|
||||
|
||||
export const getByHandleInternal = internalQuery({
|
||||
args: { handle: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const normalizedHandle = normalizeReservedHandle(args.handle);
|
||||
if (!normalizedHandle) return null;
|
||||
return await ctx.db
|
||||
.query("users")
|
||||
.withIndex("handle", (q) => q.eq("handle", normalizedHandle))
|
||||
.unique();
|
||||
},
|
||||
});
|
||||
|
||||
export const searchInternal = internalQuery({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
@@ -84,7 +97,7 @@ export const syncGitHubProfileInternal = internalMutation({
|
||||
handler: async (ctx, args) => {
|
||||
const user = await ctx.db.get(args.userId);
|
||||
if (!user || user.deletedAt || user.deactivatedAt) return;
|
||||
const canClaimNewHandle = !(await isHandleReservedForAnotherUser(ctx, args.name, args.userId));
|
||||
const canClaimNewHandle = await canUserClaimHandle(ctx, args.name, args.userId);
|
||||
|
||||
const updates: Partial<Doc<"users">> = { githubProfileSyncedAt: args.syncedAt };
|
||||
let didChangeProfile = false;
|
||||
@@ -134,6 +147,8 @@ export const syncGitHubProfileInternal = internalMutation({
|
||||
updates.updatedAt = Date.now();
|
||||
}
|
||||
await ctx.db.patch(args.userId, updates);
|
||||
const nextUser = didChangeProfile ? ({ ...user, ...updates } as Doc<"users">) : user;
|
||||
await ensurePersonalPublisherForUser(ctx, nextUser);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -151,7 +166,13 @@ export const syncGitHubProfileAction = internalAction({
|
||||
export const me = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
let userId: Awaited<ReturnType<typeof getAuthUserId>>;
|
||||
try {
|
||||
userId = await getAuthUserId(ctx);
|
||||
} catch {
|
||||
// Public pages should treat broken/stale auth as anonymous instead of crashing SSR.
|
||||
return null;
|
||||
}
|
||||
if (!userId) return null;
|
||||
const user = await ctx.db.get(userId);
|
||||
if (!user || user.deletedAt || user.deactivatedAt) return null;
|
||||
@@ -176,6 +197,40 @@ function deriveHandle(args: { existingHandle?: string; githubLogin?: string; ema
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function appendHandleSuffix(base: string, suffix: number) {
|
||||
const suffixText = suffix <= 1 ? "" : `-${suffix}`;
|
||||
const maxBaseLength = Math.max(2, 40 - suffixText.length);
|
||||
return `${base.slice(0, maxBaseLength)}${suffixText}`;
|
||||
}
|
||||
|
||||
async function resolveAvailableHandle(
|
||||
ctx: MutationCtx,
|
||||
preferredHandle: string | undefined,
|
||||
userId: Id<"users">,
|
||||
) {
|
||||
const normalizedHandle = normalizeReservedHandle(preferredHandle);
|
||||
if (!normalizedHandle) return undefined;
|
||||
for (let suffix = 1; suffix <= 50; suffix += 1) {
|
||||
const candidate = appendHandleSuffix(normalizedHandle, suffix);
|
||||
if (await canUserClaimHandle(ctx, candidate, userId)) return candidate;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function canUserClaimHandle(
|
||||
ctx: MutationCtx,
|
||||
handle: string | undefined,
|
||||
userId: Id<"users">,
|
||||
) {
|
||||
const normalizedHandle = normalizeReservedHandle(handle);
|
||||
if (!normalizedHandle) return false;
|
||||
if (await isHandleReservedForAnotherUser(ctx, normalizedHandle, userId)) return false;
|
||||
|
||||
const publisher = await getPublisherByHandle(ctx, normalizedHandle);
|
||||
if (!publisher || publisher.deletedAt || publisher.deactivatedAt) return true;
|
||||
return publisher.kind === "user" && publisher.linkedUserId === userId;
|
||||
}
|
||||
|
||||
async function computeEnsureUpdates(ctx: MutationCtx, user: Doc<"users">) {
|
||||
const updates: Record<string, unknown> = {};
|
||||
|
||||
@@ -186,10 +241,18 @@ async function computeEnsureUpdates(ctx: MutationCtx, user: Doc<"users">) {
|
||||
githubLogin,
|
||||
email: user.email,
|
||||
});
|
||||
const derivedHandle =
|
||||
requestedHandle && !(await isHandleReservedForAnotherUser(ctx, requestedHandle, user._id))
|
||||
let derivedHandle =
|
||||
requestedHandle && (await canUserClaimHandle(ctx, requestedHandle, user._id))
|
||||
? requestedHandle
|
||||
: undefined;
|
||||
if (!derivedHandle && !existingHandle) {
|
||||
const emailFallback = !requestedHandle && user.email ? user.email.split("@")[0]?.trim() : user.email?.split("@")[0]?.trim();
|
||||
derivedHandle =
|
||||
(emailFallback &&
|
||||
emailFallback !== requestedHandle &&
|
||||
(await resolveAvailableHandle(ctx, emailFallback, user._id))) ||
|
||||
(await resolveAvailableHandle(ctx, requestedHandle, user._id));
|
||||
}
|
||||
const baseHandle = derivedHandle ?? existingHandle;
|
||||
|
||||
if (derivedHandle && existingHandle !== derivedHandle) {
|
||||
@@ -216,12 +279,14 @@ export async function ensureHandler(ctx: MutationCtx) {
|
||||
const { userId, user } = await requireUser(ctx);
|
||||
const updates = await computeEnsureUpdates(ctx, user);
|
||||
|
||||
const hasUpdates = Object.keys(updates).length > 0;
|
||||
if (Object.keys(updates).length > 0) {
|
||||
updates.updatedAt = Date.now();
|
||||
await ctx.db.patch(userId, updates);
|
||||
}
|
||||
|
||||
return ctx.db.get(userId);
|
||||
const ensuredUser = hasUpdates ? ({ ...user, ...updates } as Doc<"users">) : ((await ctx.db.get(userId)) ?? user);
|
||||
await ensurePersonalPublisherForUser(ctx, ensuredUser);
|
||||
return await ctx.db.get(userId);
|
||||
}
|
||||
|
||||
export const updateProfile = mutation({
|
||||
@@ -236,6 +301,10 @@ export const updateProfile = mutation({
|
||||
bio: args.bio?.trim(),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
const user = await ctx.db.get(userId);
|
||||
if (user) {
|
||||
await ensurePersonalPublisherForUser(ctx, user);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -713,6 +782,111 @@ export const setTrustedPublisherInternal = internalMutation({
|
||||
},
|
||||
});
|
||||
|
||||
async function ensurePublisherHandleWithActor(
|
||||
ctx: MutationCtx,
|
||||
args: {
|
||||
actorUserId: Id<"users">;
|
||||
handle: string;
|
||||
displayName?: string;
|
||||
trusted?: boolean;
|
||||
},
|
||||
) {
|
||||
const actor = await ctx.db.get(args.actorUserId);
|
||||
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new Error("User not found");
|
||||
assertAdmin(actor);
|
||||
|
||||
const normalizedHandle = normalizeReservedHandle(args.handle);
|
||||
if (!normalizedHandle) throw new Error("Handle required");
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("handle", (q) => q.eq("handle", normalizedHandle))
|
||||
.unique();
|
||||
if (existing?.deletedAt || existing?.deactivatedAt) {
|
||||
throw new Error("Handle belongs to a deleted or deactivated user");
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const displayName = args.displayName?.trim() || normalizedHandle;
|
||||
const trusted = args.trusted === false ? undefined : true;
|
||||
const userId =
|
||||
existing?._id ??
|
||||
(await ctx.db.insert("users", {
|
||||
handle: normalizedHandle,
|
||||
displayName,
|
||||
role: "user",
|
||||
trustedPublisher: trusted,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}));
|
||||
|
||||
if (existing) {
|
||||
const nextDisplayName =
|
||||
args.displayName?.trim() && (!existing.displayName || existing.displayName === existing.handle)
|
||||
? displayName
|
||||
: existing.displayName;
|
||||
await ctx.db.patch(existing._id, {
|
||||
displayName: nextDisplayName,
|
||||
trustedPublisher: trusted,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
await upsertReservedHandleForRightfulOwner(ctx, {
|
||||
handle: normalizedHandle,
|
||||
rightfulOwnerUserId: userId,
|
||||
reason: "shared publisher",
|
||||
now,
|
||||
});
|
||||
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: args.actorUserId,
|
||||
action: "user.publisher.ensure",
|
||||
targetType: "user",
|
||||
targetId: userId,
|
||||
metadata: {
|
||||
handle: normalizedHandle,
|
||||
trusted: trusted === true,
|
||||
},
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
userId,
|
||||
handle: normalizedHandle,
|
||||
created: !existing,
|
||||
trusted: trusted === true,
|
||||
};
|
||||
}
|
||||
|
||||
export const ensurePublisherHandle = mutation({
|
||||
args: {
|
||||
handle: v.string(),
|
||||
displayName: v.optional(v.string()),
|
||||
trusted: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUser(ctx);
|
||||
return await ensurePublisherHandleWithActor(ctx, {
|
||||
actorUserId: user._id,
|
||||
handle: args.handle,
|
||||
displayName: args.displayName,
|
||||
trusted: args.trusted,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const ensurePublisherHandleInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
handle: v.string(),
|
||||
displayName: v.optional(v.string()),
|
||||
trusted: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => await ensurePublisherHandleWithActor(ctx, args),
|
||||
});
|
||||
|
||||
/**
|
||||
* Auto-ban a user whose skill was flagged malicious by VT.
|
||||
* Skips moderators/admins. No actor required — this is a system-level action.
|
||||
|
||||
@@ -47,12 +47,16 @@ function makeActionCtx(args: {
|
||||
soul?: Record<string, unknown> | null;
|
||||
version?: Record<string, unknown> | null;
|
||||
actor?: Record<string, unknown> | null;
|
||||
publisherMemberRole?: "owner" | "admin" | "publisher" | null;
|
||||
}) {
|
||||
return {
|
||||
runQuery: vi.fn(async (_endpoint: unknown, payload: Record<string, unknown>) => {
|
||||
if (payload.versionId && args.version) return args.version ?? null;
|
||||
if (payload.skillId && args.skill) return args.skill ?? null;
|
||||
if (payload.soulId && args.soul) return args.soul ?? null;
|
||||
if (payload.publisherId && payload.userId === args.actor?._id) {
|
||||
return args.publisherMemberRole ?? null;
|
||||
}
|
||||
if (payload.userId === args.actor?._id) {
|
||||
return args.actor ?? null;
|
||||
}
|
||||
@@ -104,6 +108,28 @@ describe("version file access actions", () => {
|
||||
).resolves.toEqual({ path: "SKILL.md", text: "# skill" });
|
||||
});
|
||||
|
||||
it("allows org collaborators to read hidden skill versions", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:member" as never);
|
||||
const ctx = makeActionCtx({
|
||||
actor: { _id: "users:member", role: "user" },
|
||||
publisherMemberRole: "publisher",
|
||||
version: makeSkillVersion(),
|
||||
skill: {
|
||||
_id: "skills:1",
|
||||
ownerUserId: "users:owner",
|
||||
ownerPublisherId: "publishers:org",
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "pending.scan",
|
||||
moderationFlags: [],
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
getSkillReadmeHandler._handler(ctx, { versionId: "skillVersions:1" } as never),
|
||||
).resolves.toEqual({ path: "SKILL.md", text: "# skill" });
|
||||
});
|
||||
|
||||
it("allows owners to read hidden skill files", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
|
||||
const ctx = makeActionCtx({
|
||||
|
||||
+219
-2
@@ -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
|
||||
|
||||
@@ -208,6 +208,12 @@ Stores your API token + cached registry URL.
|
||||
- `--fuzzy` resolves the handle via fuzzy user search (admin only).
|
||||
- `--yes` skips confirmation.
|
||||
|
||||
### `package publish <path>`
|
||||
|
||||
- Publishes a code plugin or bundle plugin via `POST /api/v1/packages`.
|
||||
- `--owner <handle>` lets admins publish under a shared owner account while keeping their own token as the actor.
|
||||
- Code plugins still require `--source-repo` and `--source-commit`.
|
||||
|
||||
### `sync`
|
||||
|
||||
- Scans for local skill folders and publishes new/changed ones.
|
||||
|
||||
+8
-6
@@ -31,11 +31,15 @@ gh workflow run deploy.yml
|
||||
GitHub Actions secrets required for `deploy.yml`:
|
||||
|
||||
- `CONVEX_DEPLOY_KEY`
|
||||
- `VERCEL_TOKEN`
|
||||
- Optional: `PLAYWRIGHT_AUTH_STORAGE_STATE_JSON` for authenticated smoke coverage
|
||||
|
||||
`deploy.yml` now fails in preflight if either required secret is missing. It no
|
||||
longer reports a successful workflow while skipping the actual deploy jobs.
|
||||
`deploy.yml` now fails in preflight if `CONVEX_DEPLOY_KEY` is missing. Web deploy
|
||||
verification no longer depends on a separate Vercel token in GitHub Actions.
|
||||
|
||||
That workflow assumes Vercel Git integration is enabled for this repo. It does
|
||||
not run `vercel deploy` directly; instead it waits for the GitHub commit status
|
||||
`Vercel – clawhub` for the pushed SHA, then runs smoke tests against
|
||||
production.
|
||||
|
||||
Ensure Convex env is set (auth + embeddings):
|
||||
|
||||
@@ -63,11 +67,9 @@ Deploy order:
|
||||
|
||||
1. Convex
|
||||
2. contract verify
|
||||
3. web
|
||||
3. wait for Vercel production deploy for the same Git SHA
|
||||
4. smoke
|
||||
|
||||
Do not let Vercel auto-promote a newer web build before Convex is deployed.
|
||||
|
||||
## 3) Route `/api/*` to Convex
|
||||
|
||||
This repo currently uses `vercel.json` rewrites:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Vendored
+1
@@ -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?: {
|
||||
|
||||
Vendored
+1
@@ -48,6 +48,7 @@ export const BundlePublishMetadataSchema = type({
|
||||
export const PackagePublishRequestSchema = type({
|
||||
name: "string",
|
||||
displayName: "string?",
|
||||
ownerHandle: "string?",
|
||||
family: PackageFamilySchema,
|
||||
version: "string",
|
||||
changelog: "string",
|
||||
|
||||
Vendored
+1
-1
@@ -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"}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const fetchPackagesMock = vi.fn();
|
||||
const fetchPluginCatalogMock = vi.fn();
|
||||
const navigateMock = vi.fn();
|
||||
let searchMock: Record<string, unknown> = {};
|
||||
let loaderDataMock: {
|
||||
@@ -44,11 +44,11 @@ vi.mock("@tanstack/react-router", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../lib/packageApi", () => ({
|
||||
fetchPackages: (...args: unknown[]) => fetchPackagesMock(...args),
|
||||
fetchPluginCatalog: (...args: unknown[]) => fetchPluginCatalogMock(...args),
|
||||
}));
|
||||
|
||||
async function loadRoute() {
|
||||
return (await import("../routes/packages/index")).Route as unknown as {
|
||||
return (await import("../routes/plugins/index")).Route as unknown as {
|
||||
__config: {
|
||||
loader?: (args: { deps: Record<string, unknown> }) => Promise<unknown>;
|
||||
component?: ComponentType;
|
||||
@@ -57,29 +57,29 @@ async function loadRoute() {
|
||||
};
|
||||
}
|
||||
|
||||
describe("packages route", () => {
|
||||
describe("plugins route", () => {
|
||||
beforeEach(() => {
|
||||
fetchPackagesMock.mockReset();
|
||||
fetchPluginCatalogMock.mockReset();
|
||||
navigateMock.mockReset();
|
||||
searchMock = {};
|
||||
loaderDataMock = { items: [], nextCursor: null };
|
||||
});
|
||||
|
||||
it("preserves skill family filters in search state", async () => {
|
||||
it("rejects skill family filter in search state", async () => {
|
||||
const route = await loadRoute();
|
||||
const validateSearch = route.__config.validateSearch as (search: Record<string, unknown>) => Record<string, unknown>;
|
||||
|
||||
expect(validateSearch({ family: "skill", q: "demo" })).toEqual({
|
||||
family: "skill",
|
||||
family: undefined,
|
||||
q: "demo",
|
||||
cursor: undefined,
|
||||
official: undefined,
|
||||
verified: undefined,
|
||||
executesCode: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards opaque cursors through the loader", async () => {
|
||||
fetchPackagesMock.mockResolvedValue({ items: [], nextCursor: "cursor:next" });
|
||||
fetchPluginCatalogMock.mockResolvedValue({ items: [], nextCursor: "cursor:next" });
|
||||
const route = await loadRoute();
|
||||
const loader = route.__config.loader as (args: {
|
||||
deps: Record<string, unknown>;
|
||||
@@ -92,7 +92,7 @@ describe("packages route", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(fetchPackagesMock).toHaveBeenCalledWith(
|
||||
expect(fetchPluginCatalogMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cursor: "cursor:current",
|
||||
family: "code-plugin",
|
||||
@@ -134,12 +134,43 @@ describe("packages route", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the Skills family option", async () => {
|
||||
it("filters out skills from loader results", async () => {
|
||||
fetchPluginCatalogMock.mockResolvedValue({
|
||||
items: [
|
||||
{ name: "my-skill", displayName: "My Skill", family: "skill", channel: "community", isOfficial: false, createdAt: 1, updatedAt: 1 },
|
||||
{ name: "my-plugin", displayName: "My Plugin", family: "code-plugin", channel: "community", isOfficial: false, createdAt: 1, updatedAt: 1 },
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
const loader = route.__config.loader as (args: {
|
||||
deps: Record<string, unknown>;
|
||||
}) => Promise<{ items: Array<{ name: string }>; nextCursor: string | null }>;
|
||||
|
||||
render(<Component />);
|
||||
const result = await loader({ deps: {} });
|
||||
|
||||
expect(screen.getByRole("option", { name: "Skills" })).toBeTruthy();
|
||||
expect(result.items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("uses plugin-only catalog fetching for verified browse", async () => {
|
||||
fetchPluginCatalogMock.mockResolvedValue({ items: [], nextCursor: null });
|
||||
const route = await loadRoute();
|
||||
const loader = route.__config.loader as (args: {
|
||||
deps: Record<string, unknown>;
|
||||
}) => Promise<unknown>;
|
||||
|
||||
await loader({
|
||||
deps: {
|
||||
verified: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(fetchPluginCatalogMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
family: undefined,
|
||||
isOfficial: true,
|
||||
limit: 50,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ vi.mock("../lib/useAuthStatus", () => ({
|
||||
describe("SkillDetailPage", () => {
|
||||
const skillId = "skills:1" as Id<"skills">;
|
||||
const ownerId = "users:1" as Id<"users">;
|
||||
const ownerPublisherId = "publishers:steipete" as Id<"publishers">;
|
||||
const versionId = "skillVersions:1" as Id<"skillVersions">;
|
||||
const storageId = "storage:1" as Id<"_storage">;
|
||||
|
||||
@@ -75,6 +76,7 @@ describe("SkillDetailPage", () => {
|
||||
displayName: "Weather",
|
||||
summary: "Get current weather.",
|
||||
ownerUserId: ownerId,
|
||||
ownerPublisherId,
|
||||
tags: {},
|
||||
badges: {},
|
||||
stats: {
|
||||
@@ -89,10 +91,12 @@ describe("SkillDetailPage", () => {
|
||||
updatedAt: 0,
|
||||
},
|
||||
owner: {
|
||||
_id: ownerId,
|
||||
_id: ownerPublisherId,
|
||||
_creationTime: 0,
|
||||
kind: "user",
|
||||
handle: "steipete",
|
||||
name: "Peter",
|
||||
displayName: "Peter",
|
||||
linkedUserId: ownerId,
|
||||
},
|
||||
latestVersion: {
|
||||
_id: versionId,
|
||||
@@ -148,6 +152,7 @@ describe("SkillDetailPage", () => {
|
||||
displayName: "Weather",
|
||||
summary: "Get current weather.",
|
||||
ownerUserId: ownerId,
|
||||
ownerPublisherId,
|
||||
tags: {},
|
||||
badges: {},
|
||||
stats: {
|
||||
@@ -162,10 +167,12 @@ describe("SkillDetailPage", () => {
|
||||
updatedAt: 0,
|
||||
},
|
||||
owner: {
|
||||
_id: ownerId,
|
||||
_id: ownerPublisherId,
|
||||
_creationTime: 0,
|
||||
kind: "user",
|
||||
handle: "steipete",
|
||||
name: "Peter",
|
||||
displayName: "Peter",
|
||||
linkedUserId: ownerId,
|
||||
},
|
||||
latestVersion: {
|
||||
_id: versionId,
|
||||
@@ -215,17 +222,25 @@ describe("SkillDetailPage", () => {
|
||||
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
|
||||
if (args === "skip") return undefined;
|
||||
if (args && typeof args === "object" && "skillId" in args) return [];
|
||||
return {
|
||||
skill: {
|
||||
_id: "skills:1",
|
||||
slug: "weather",
|
||||
displayName: "Weather",
|
||||
summary: "Get current weather.",
|
||||
ownerUserId: "users:1",
|
||||
tags: {},
|
||||
stats: { stars: 0, downloads: 0 },
|
||||
return {
|
||||
skill: {
|
||||
_id: "skills:1",
|
||||
slug: "weather",
|
||||
displayName: "Weather",
|
||||
summary: "Get current weather.",
|
||||
ownerUserId: "users:1",
|
||||
ownerPublisherId: "publishers:steipete",
|
||||
tags: {},
|
||||
stats: { stars: 0, downloads: 0 },
|
||||
},
|
||||
owner: {
|
||||
_id: "publishers:steipete",
|
||||
_creationTime: 0,
|
||||
kind: "user",
|
||||
handle: "steipete",
|
||||
displayName: "Peter",
|
||||
linkedUserId: "users:1",
|
||||
},
|
||||
owner: { handle: "steipete", name: "Peter" },
|
||||
latestVersion: { _id: "skillVersions:1", version: "1.0.0", parsed: {} },
|
||||
};
|
||||
});
|
||||
@@ -260,10 +275,18 @@ describe("SkillDetailPage", () => {
|
||||
displayName: "Weather",
|
||||
summary: "Get current weather.",
|
||||
ownerUserId: "users:1",
|
||||
ownerPublisherId: "publishers:steipete",
|
||||
tags: {},
|
||||
stats: { stars: 0, downloads: 0 },
|
||||
},
|
||||
owner: { handle: "steipete", name: "Peter" },
|
||||
owner: {
|
||||
_id: "publishers:steipete",
|
||||
_creationTime: 0,
|
||||
kind: "user",
|
||||
handle: "steipete",
|
||||
displayName: "Peter",
|
||||
linkedUserId: "users:1",
|
||||
},
|
||||
latestVersion: { _id: "skillVersions:1", version: "1.0.0", parsed: {}, files: [] },
|
||||
};
|
||||
}
|
||||
@@ -312,10 +335,18 @@ describe("SkillDetailPage", () => {
|
||||
displayName: "Weather",
|
||||
summary: "Get current weather.",
|
||||
ownerUserId: "users:1",
|
||||
ownerPublisherId: "publishers:steipete",
|
||||
tags: {},
|
||||
stats: { stars: 0, downloads: 0 },
|
||||
},
|
||||
owner: { _id: "users:1", handle: "steipete", name: "Peter" },
|
||||
owner: {
|
||||
_id: "publishers:steipete",
|
||||
_creationTime: 0,
|
||||
kind: "user",
|
||||
handle: "steipete",
|
||||
displayName: "Peter",
|
||||
linkedUserId: "users:1",
|
||||
},
|
||||
latestVersion: { _id: "skillVersions:1", version: "1.0.0", parsed: {}, files: [] },
|
||||
};
|
||||
}
|
||||
@@ -344,10 +375,18 @@ describe("SkillDetailPage", () => {
|
||||
displayName: "Weather",
|
||||
summary: "Get current weather.",
|
||||
ownerUserId: "users:1",
|
||||
ownerPublisherId: "publishers:steipete",
|
||||
tags: {},
|
||||
stats: { stars: 0, downloads: 0 },
|
||||
},
|
||||
owner: { handle: "steipete", name: "Peter" },
|
||||
owner: {
|
||||
_id: "publishers:steipete",
|
||||
_creationTime: 0,
|
||||
kind: "user",
|
||||
handle: "steipete",
|
||||
displayName: "Peter",
|
||||
linkedUserId: "users:1",
|
||||
},
|
||||
latestVersion: { _id: "skillVersions:1", version: "1.0.0", parsed: {}, files: [] },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -252,7 +252,7 @@ describe("SkillsIndex", () => {
|
||||
});
|
||||
|
||||
const titles = Array.from(
|
||||
document.querySelectorAll(".skills-row-title > span:first-child"),
|
||||
document.querySelectorAll(".skills-table-name > span:first-child"),
|
||||
).map((node) => node.textContent);
|
||||
|
||||
expect(titles[0]).toBe("Older High Score");
|
||||
|
||||
@@ -1,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"), {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -81,6 +81,9 @@ export function SkillDetailPage({
|
||||
const reportSkill = useMutation(api.skills.report);
|
||||
const updateTags = useMutation(api.skills.updateTags);
|
||||
const getReadme = useAction(api.skills.getReadme);
|
||||
const myPublishers = useQuery(api.publishers.listMine) as
|
||||
| Array<{ publisher: { _id: Id<"publishers"> }; role: string }>
|
||||
| undefined;
|
||||
|
||||
const [readme, setReadme] = useState<string | null>(initialData?.readme ?? null);
|
||||
const [readmeError, setReadmeError] = useState<string | null>(initialData?.readmeError ?? null);
|
||||
@@ -118,14 +121,29 @@ export function SkillDetailPage({
|
||||
isAuthenticated && skill ? { skillId: skill._id } : "skip",
|
||||
);
|
||||
|
||||
const canManage = canManageSkill(me, skill);
|
||||
const isOwner = Boolean(me && skill && me._id === skill.ownerUserId);
|
||||
const myPublisherIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
(Array.isArray(myPublishers) ? myPublishers : []).map((entry) => entry.publisher._id),
|
||||
),
|
||||
[myPublishers],
|
||||
);
|
||||
const canManage =
|
||||
canManageSkill(me, skill) ||
|
||||
Boolean(skill?.ownerPublisherId && myPublisherIds.has(skill.ownerPublisherId));
|
||||
const isOwner =
|
||||
Boolean(me && skill && me._id === skill.ownerUserId) ||
|
||||
Boolean(skill?.ownerPublisherId && myPublisherIds.has(skill.ownerPublisherId));
|
||||
const ownedSkills = useQuery(
|
||||
api.skills.list,
|
||||
isOwner && skill ? { ownerUserId: skill.ownerUserId, limit: 100 } : "skip",
|
||||
isOwner && skill
|
||||
? skill.ownerPublisherId
|
||||
? { ownerPublisherId: skill.ownerPublisherId, limit: 100 }
|
||||
: { ownerUserId: skill.ownerUserId, limit: 100 }
|
||||
: "skip",
|
||||
) as Array<{ _id: Id<"skills">; slug: string; displayName: string }> | undefined;
|
||||
|
||||
const ownerHandle = owner?.handle ?? owner?.name ?? null;
|
||||
const ownerHandle = owner?.handle ?? null;
|
||||
const ownerParam = ownerHandle ?? (owner?._id ? String(owner._id) : null);
|
||||
const wantsCanonicalRedirect = Boolean(
|
||||
ownerParam &&
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Package } from "lucide-react";
|
||||
import type { Doc, Id } from "../../convex/_generated/dataModel";
|
||||
import { getSkillBadges } from "../lib/badges";
|
||||
import { formatCompactStat, formatSkillStatsTriplet } from "../lib/numberFormat";
|
||||
import type { PublicSkill, PublicUser } from "../lib/publicUser";
|
||||
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
|
||||
import { getRuntimeEnv } from "../lib/runtimeEnv";
|
||||
import { SkillInstallCard } from "./SkillInstallCard";
|
||||
import { type LlmAnalysis, SecurityScanResults } from "./SkillSecurityScanResults";
|
||||
@@ -39,7 +39,7 @@ type SkillCanonical = {
|
||||
|
||||
type SkillHeaderProps = {
|
||||
skill: Doc<"skills"> | PublicSkill;
|
||||
owner: Doc<"users"> | PublicUser | null;
|
||||
owner: PublicPublisher | null;
|
||||
ownerHandle: string | null;
|
||||
latestVersion: Doc<"skillVersions"> | null;
|
||||
modInfo: SkillModerationInfo | null;
|
||||
|
||||
@@ -15,7 +15,7 @@ type SkillOwnershipPanelProps = {
|
||||
skillId: Id<"skills">;
|
||||
slug: string;
|
||||
ownerHandle: string | null;
|
||||
ownerId: Id<"users"> | null;
|
||||
ownerId: Id<"users"> | Id<"publishers"> | null;
|
||||
ownedSkills: OwnedSkillOption[];
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PublicUser } from "../lib/publicUser";
|
||||
import type { PublicPublisher, PublicUser } from "../lib/publicUser";
|
||||
|
||||
type UserBadgeProps = {
|
||||
user: PublicUser | null | undefined;
|
||||
user: PublicUser | PublicPublisher | null | undefined;
|
||||
fallbackHandle?: string | null;
|
||||
prefix?: string;
|
||||
size?: "sm" | "md";
|
||||
@@ -17,17 +17,26 @@ export function UserBadge({
|
||||
link = true,
|
||||
showName = false,
|
||||
}: UserBadgeProps) {
|
||||
const handle = user?.handle ?? user?.name ?? fallbackHandle ?? null;
|
||||
const href = user?.handle ? `/u/${encodeURIComponent(user.handle)}` : null;
|
||||
const userName = user && "name" in user ? user.name?.trim() : undefined;
|
||||
const displayName =
|
||||
user?.displayName?.trim() || userName || null;
|
||||
const handle = user?.handle ?? fallbackHandle ?? null;
|
||||
const href =
|
||||
user?.handle && "kind" in user
|
||||
? user.kind === "org"
|
||||
? `/orgs/${encodeURIComponent(user.handle)}`
|
||||
: `/u/${encodeURIComponent(user.handle)}`
|
||||
: user?.handle
|
||||
? `/u/${encodeURIComponent(user.handle)}`
|
||||
: null;
|
||||
const label = handle ? `@${handle}` : "user";
|
||||
const image = user?.image ?? null;
|
||||
const displayName = user?.displayName?.trim() || null;
|
||||
const hasUsefulName =
|
||||
showName &&
|
||||
Boolean(displayName) &&
|
||||
Boolean(handle) &&
|
||||
displayName!.toLowerCase() !== handle!.toLowerCase();
|
||||
const initial = (user?.displayName ?? user?.name ?? handle ?? "u").charAt(0).toUpperCase();
|
||||
const initial = (displayName ?? handle ?? "u").charAt(0).toUpperCase();
|
||||
|
||||
return (
|
||||
<span className={`user-badge user-badge-${size}`}>
|
||||
|
||||
@@ -3,11 +3,11 @@ import type { Id } from "../../convex/_generated/dataModel";
|
||||
|
||||
export function buildSkillHref(
|
||||
ownerHandle: string | null,
|
||||
ownerId: Id<"users"> | null,
|
||||
ownerId: Id<"users"> | Id<"publishers"> | null,
|
||||
slug: string,
|
||||
) {
|
||||
const owner = ownerHandle?.trim() || (ownerId ? String(ownerId) : "unknown");
|
||||
return `/${owner}/${slug}`;
|
||||
return `/${encodeURIComponent(owner)}/${encodeURIComponent(slug)}`;
|
||||
}
|
||||
|
||||
export function formatConfigSnippet(raw: string) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -5,6 +5,11 @@ export type PublicUser = Pick<
|
||||
"_id" | "_creationTime" | "handle" | "name" | "displayName" | "image" | "bio"
|
||||
>;
|
||||
|
||||
export type PublicPublisher = Pick<
|
||||
Doc<"publishers">,
|
||||
"_id" | "_creationTime" | "kind" | "handle" | "displayName" | "image" | "bio" | "linkedUserId"
|
||||
>;
|
||||
|
||||
export type PublicSkill = Pick<
|
||||
Doc<"skills">,
|
||||
| "_id"
|
||||
@@ -13,6 +18,7 @@ export type PublicSkill = Pick<
|
||||
| "displayName"
|
||||
| "summary"
|
||||
| "ownerUserId"
|
||||
| "ownerPublisherId"
|
||||
| "canonicalSkillId"
|
||||
| "forkOf"
|
||||
| "latestVersionId"
|
||||
@@ -31,6 +37,7 @@ export type PublicSoul = Pick<
|
||||
| "displayName"
|
||||
| "summary"
|
||||
| "ownerUserId"
|
||||
| "ownerPublisherId"
|
||||
| "latestVersionId"
|
||||
| "tags"
|
||||
| "stats"
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Doc, Id } from "../../convex/_generated/dataModel";
|
||||
import { convexHttp } from "../convex/client";
|
||||
import type { PublicSkill, PublicUser } from "./publicUser";
|
||||
import type { PublicPublisher, PublicSkill } from "./publicUser";
|
||||
|
||||
export type SkillBySlugResult = {
|
||||
requestedSlug?: string | null;
|
||||
resolvedSlug?: string | null;
|
||||
skill: Doc<"skills"> | PublicSkill;
|
||||
latestVersion: Doc<"skillVersions"> | null;
|
||||
owner: Doc<"users"> | PublicUser | null;
|
||||
owner: PublicPublisher | null;
|
||||
pendingReview?: boolean;
|
||||
moderationInfo?: {
|
||||
isPendingScan: boolean;
|
||||
@@ -81,7 +81,10 @@ export async function fetchSkillPageData(slug: string): Promise<SkillPageLoaderD
|
||||
}
|
||||
|
||||
return {
|
||||
owner: result.owner?.handle ?? result.owner?.name ?? null,
|
||||
owner:
|
||||
result.owner?.handle ??
|
||||
result.owner?.displayName ??
|
||||
((result.owner as { name?: string | null } | null)?.name ?? null),
|
||||
displayName: result.skill.displayName ?? null,
|
||||
summary: result.skill.summary ?? null,
|
||||
version: result.latestVersion?.version ?? null,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "convex/react";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import type { PublicPublisher, PublicSkill } from "../../lib/publicUser";
|
||||
import { SkillCard } from "../../components/SkillCard";
|
||||
import { getSkillBadges } from "../../lib/badges";
|
||||
import { SkillStatsTripletLine } from "../../components/SkillStats";
|
||||
|
||||
export const Route = createFileRoute("/orgs/$handle")({
|
||||
component: OrgProfile,
|
||||
});
|
||||
|
||||
function OrgProfile() {
|
||||
const { handle } = Route.useParams();
|
||||
const publisher = useQuery(api.publishers.getByHandle, { handle }) as
|
||||
| PublicPublisher
|
||||
| null
|
||||
| undefined;
|
||||
const members = useQuery(api.publishers.listMembers, { publisherHandle: handle }) as
|
||||
| {
|
||||
publisher: PublicPublisher | null;
|
||||
members: Array<{
|
||||
role: "owner" | "admin" | "publisher";
|
||||
user: {
|
||||
_id: string;
|
||||
handle: string | null;
|
||||
displayName: string | null;
|
||||
image: string | null;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
const skills = useQuery(
|
||||
api.skills.list,
|
||||
publisher ? { ownerPublisherId: publisher._id, limit: 50 } : "skip",
|
||||
) as PublicSkill[] | undefined;
|
||||
|
||||
if (publisher === undefined) {
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="card">Loading org…</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!publisher || publisher.kind !== "org") {
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="card">Organization not found.</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="card settings-profile" style={{ marginBottom: 22 }}>
|
||||
<div className="settings-avatar" aria-hidden="true">
|
||||
{publisher.image ? (
|
||||
<img src={publisher.image} alt="" />
|
||||
) : (
|
||||
<span>{publisher.displayName.charAt(0).toUpperCase()}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="settings-profile-body">
|
||||
<div className="settings-name">{publisher.displayName}</div>
|
||||
<div className="settings-handle">@{publisher.handle}</div>
|
||||
{publisher.bio ? <div className="section-subtitle">{publisher.bio}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="section-title" style={{ fontSize: "1.3rem" }}>
|
||||
Published
|
||||
</h2>
|
||||
{(skills ?? []).length ? (
|
||||
<div className="grid" style={{ marginBottom: 18 }}>
|
||||
{(skills ?? []).map((skill) => (
|
||||
<SkillCard
|
||||
key={skill._id}
|
||||
skill={skill}
|
||||
href={`/${encodeURIComponent(publisher.handle)}/${encodeURIComponent(skill.slug)}`}
|
||||
badge={getSkillBadges(skill)}
|
||||
summaryFallback="Agent-ready skill pack."
|
||||
meta={
|
||||
<div className="stat">
|
||||
<SkillStatsTripletLine stats={skill.stats} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="card">No published skills yet.</div>
|
||||
)}
|
||||
|
||||
<h2 className="section-title" style={{ fontSize: "1.3rem" }}>
|
||||
Members
|
||||
</h2>
|
||||
{(members?.members ?? []).length ? (
|
||||
<div style={{ display: "grid", gap: 10 }}>
|
||||
{members?.members.map((entry) => (
|
||||
<div key={`${entry.user._id}:${entry.role}`} className="card">
|
||||
<strong>{entry.user.displayName ?? entry.user.handle ?? "User"}</strong>
|
||||
<div className="section-subtitle" style={{ margin: "6px 0 0" }}>
|
||||
{entry.user.handle ? (
|
||||
<Link to="/u/$handle" params={{ handle: entry.user.handle }}>
|
||||
@{entry.user.handle}
|
||||
</Link>
|
||||
) : (
|
||||
"user"
|
||||
)}{" "}
|
||||
· {entry.role}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="card">No members listed.</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,150 +1,7 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import {
|
||||
fetchPackageDetail,
|
||||
fetchPackageReadme,
|
||||
fetchPackageVersion,
|
||||
getPackageDownloadPath,
|
||||
type PackageDetailResponse,
|
||||
type PackageVersionDetail,
|
||||
} from "../../lib/packageApi";
|
||||
import { familyLabel, packageCapabilityLabel } from "../../lib/packageLabels";
|
||||
|
||||
type PackageDetailLoaderData = {
|
||||
detail: PackageDetailResponse;
|
||||
version: PackageVersionDetail | null;
|
||||
readme: string | null;
|
||||
};
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/packages/$name")({
|
||||
loader: async ({ params }): Promise<PackageDetailLoaderData> => {
|
||||
const detail = await fetchPackageDetail(params.name);
|
||||
const version =
|
||||
detail.package?.latestVersion
|
||||
? await fetchPackageVersion(params.name, detail.package.latestVersion)
|
||||
: null;
|
||||
const readme = await fetchPackageReadme(params.name, detail.package?.latestVersion);
|
||||
return { detail, version, readme };
|
||||
beforeLoad: ({ params }) => {
|
||||
throw redirect({ to: "/plugins/$name", params });
|
||||
},
|
||||
head: ({ params, loaderData }) => ({
|
||||
meta: [
|
||||
{
|
||||
title: loaderData?.detail.package?.displayName
|
||||
? `${loaderData.detail.package.displayName} · Packages`
|
||||
: params.name,
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
content: loaderData?.detail.package?.summary ?? `Package ${params.name}`,
|
||||
},
|
||||
],
|
||||
}),
|
||||
component: PackageDetailRoute,
|
||||
});
|
||||
|
||||
function PackageDetailRoute() {
|
||||
const { name } = Route.useParams();
|
||||
const { detail, version, readme } = Route.useLoaderData() as PackageDetailLoaderData;
|
||||
|
||||
if (!detail.package) {
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="card">Package not found.</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const pkg = detail.package;
|
||||
const latestRelease = version?.version ?? null;
|
||||
const installSnippet =
|
||||
pkg.family === "code-plugin"
|
||||
? `openclaw plugins install clawhub:${pkg.name}`
|
||||
: pkg.family === "bundle-plugin"
|
||||
? `openclaw bundles install clawhub:${pkg.name}`
|
||||
: `openclaw skills install ${pkg.name}`;
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="skill-detail-stack">
|
||||
<section className="card">
|
||||
<div className="skill-card-tags" style={{ marginBottom: 12 }}>
|
||||
<span className="tag">{familyLabel(pkg.family)}</span>
|
||||
<span className={`tag ${pkg.capabilities?.executesCode ? "tag-accent" : ""}`}>
|
||||
{packageCapabilityLabel(pkg.family, pkg.capabilities?.executesCode)}
|
||||
</span>
|
||||
<span className="tag">{pkg.channel}</span>
|
||||
{pkg.isOfficial ? <span className="tag">Official</span> : null}
|
||||
{pkg.verification?.tier ? <span className="tag">{pkg.verification.tier}</span> : null}
|
||||
</div>
|
||||
<h1 className="section-title" style={{ marginBottom: 8 }}>
|
||||
{pkg.displayName}
|
||||
</h1>
|
||||
<p className="section-subtitle" style={{ marginBottom: 12 }}>
|
||||
{pkg.summary ?? "No summary provided."}
|
||||
</p>
|
||||
{pkg.family === "code-plugin" && !pkg.isOfficial ? (
|
||||
<div className="tag tag-accent" style={{ marginBottom: 12 }}>
|
||||
Community code plugin. Review compatibility and verification before install.
|
||||
</div>
|
||||
) : null}
|
||||
<div className="skills-row-slug" style={{ marginBottom: 12 }}>
|
||||
{pkg.name}
|
||||
{pkg.runtimeId ? ` · runtime id ${pkg.runtimeId}` : ""}
|
||||
</div>
|
||||
<details className="bundle-details" open>
|
||||
<summary>Install</summary>
|
||||
<pre>
|
||||
<code>{installSnippet}</code>
|
||||
</pre>
|
||||
</details>
|
||||
<details className="bundle-details" open>
|
||||
<summary>Latest Release</summary>
|
||||
<div style={{ display: "grid", gap: 8 }}>
|
||||
<div>{pkg.latestVersion ? `Version ${pkg.latestVersion}` : "No latest tag"}</div>
|
||||
{pkg.latestVersion ? (
|
||||
<div>
|
||||
<a href={getPackageDownloadPath(name, pkg.latestVersion)}>Download zip</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
{latestRelease ? (
|
||||
<details className="bundle-details" open>
|
||||
<summary>Compatibility</summary>
|
||||
<pre>
|
||||
<code>{JSON.stringify(latestRelease.compatibility ?? pkg.compatibility ?? {}, null, 2)}</code>
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
{latestRelease ? (
|
||||
<details className="bundle-details" open>
|
||||
<summary>Capabilities</summary>
|
||||
<pre>
|
||||
<code>{JSON.stringify(latestRelease.capabilities ?? pkg.capabilities ?? {}, null, 2)}</code>
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
<details className="bundle-details" open>
|
||||
<summary>Verification</summary>
|
||||
<pre>
|
||||
<code>{JSON.stringify(latestRelease?.verification ?? pkg.verification ?? {}, null, 2)}</code>
|
||||
</pre>
|
||||
</details>
|
||||
<details className="bundle-details" open>
|
||||
<summary>Tags</summary>
|
||||
<pre>
|
||||
<code>{JSON.stringify(pkg.tags, null, 2)}</code>
|
||||
</pre>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
{readme ? (
|
||||
<section className="card">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{readme}</ReactMarkdown>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,246 +1,7 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchPackages, type PackageListItem } from "../../lib/packageApi";
|
||||
import { familyLabel, packageCapabilityLabel } from "../../lib/packageLabels";
|
||||
|
||||
type PackageSearchState = {
|
||||
q?: string;
|
||||
cursor?: string;
|
||||
family?: "skill" | "code-plugin" | "bundle-plugin";
|
||||
official?: boolean;
|
||||
executesCode?: boolean;
|
||||
};
|
||||
|
||||
type PackagesLoaderData = {
|
||||
items: PackageListItem[];
|
||||
nextCursor: string | null;
|
||||
};
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/packages/")({
|
||||
validateSearch: (search): PackageSearchState => ({
|
||||
q: typeof search.q === "string" && search.q.trim() ? search.q.trim() : undefined,
|
||||
cursor: typeof search.cursor === "string" && search.cursor ? search.cursor : undefined,
|
||||
family:
|
||||
search.family === "skill" || search.family === "code-plugin" || search.family === "bundle-plugin"
|
||||
? search.family
|
||||
: undefined,
|
||||
official:
|
||||
search.official === true || search.official === "true" || search.official === "1"
|
||||
? true
|
||||
: undefined,
|
||||
executesCode:
|
||||
search.executesCode === true ||
|
||||
search.executesCode === "true" ||
|
||||
search.executesCode === "1"
|
||||
? true
|
||||
: undefined,
|
||||
}),
|
||||
loaderDeps: ({ search }) => search,
|
||||
loader: async ({ deps }) => {
|
||||
const data = await fetchPackages({
|
||||
q: deps.q,
|
||||
cursor: deps.q ? undefined : deps.cursor,
|
||||
family: deps.family,
|
||||
isOfficial: deps.official,
|
||||
executesCode: deps.executesCode,
|
||||
limit: 50,
|
||||
});
|
||||
const items = "results" in data ? data.results.map((entry) => entry.package) : data.items;
|
||||
return {
|
||||
items,
|
||||
nextCursor: "results" in data ? null : data.nextCursor,
|
||||
} satisfies PackagesLoaderData;
|
||||
beforeLoad: ({ search }) => {
|
||||
throw redirect({ to: "/plugins", search });
|
||||
},
|
||||
component: PackagesIndex,
|
||||
});
|
||||
|
||||
export function PackagesIndex() {
|
||||
const search = Route.useSearch();
|
||||
const navigate = Route.useNavigate();
|
||||
const { items, nextCursor } = Route.useLoaderData() as PackagesLoaderData;
|
||||
const [query, setQuery] = useState(search.q ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
setQuery(search.q ?? "");
|
||||
}, [search.q]);
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<header className="skills-header-top">
|
||||
<h1 className="section-title" style={{ marginBottom: 8 }}>
|
||||
Packages
|
||||
</h1>
|
||||
<p className="section-subtitle" style={{ marginBottom: 0 }}>
|
||||
Unified OpenClaw catalog: skills, code plugins, bundle plugins.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="card" style={{ display: "grid", gap: 12, marginBottom: 18 }}>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
q: query.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
style={{ display: "grid", gap: 12 }}
|
||||
>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Search packages"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
|
||||
<select
|
||||
className="input"
|
||||
value={search.family ?? ""}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value as PackageSearchState["family"] | "";
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
family: value || undefined,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="">All families</option>
|
||||
<option value="skill">Skills</option>
|
||||
<option value="code-plugin">Code plugins</option>
|
||||
<option value="bundle-plugin">Bundle plugins</option>
|
||||
</select>
|
||||
<label className="tag" style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={search.official ?? false}
|
||||
onChange={(event) => {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
official: event.target.checked || undefined,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
Official only
|
||||
</label>
|
||||
<label className="tag" style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={search.executesCode ?? false}
|
||||
onChange={(event) => {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
executesCode: event.target.checked || undefined,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
Executes code
|
||||
</label>
|
||||
<Link className="btn" to="/upload" search={{ updateSlug: undefined }}>
|
||||
Publish Skill
|
||||
</Link>
|
||||
<Link className="btn" to="/packages/new">
|
||||
Publish Plugin
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="card">No packages match that filter.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid">
|
||||
{items.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
to="/packages/$name"
|
||||
params={{ name: item.name }}
|
||||
className="skill-card"
|
||||
>
|
||||
<div className="skill-card-tags">
|
||||
<span className="tag">{familyLabel(item.family)}</span>
|
||||
<span className={`tag ${item.executesCode ? "tag-accent" : ""}`}>
|
||||
{packageCapabilityLabel(item.family, item.executesCode)}
|
||||
</span>
|
||||
{item.isOfficial ? <span className="tag">Official</span> : null}
|
||||
{item.verificationTier ? <span className="tag">{item.verificationTier}</span> : null}
|
||||
</div>
|
||||
<div className="skill-card-title">{item.displayName}</div>
|
||||
<div className="skills-row-slug">{item.name}</div>
|
||||
<div className="skill-card-summary">
|
||||
{item.summary ?? "No summary provided."}
|
||||
</div>
|
||||
<div className="skill-card-footer skill-card-footer-rows">
|
||||
<div className="stat">Channel: {item.channel}</div>
|
||||
<div className="stat">
|
||||
{item.ownerHandle ? `by ${item.ownerHandle}` : "community package"}
|
||||
</div>
|
||||
<div className="stat">
|
||||
{item.latestVersion ? `v${item.latestVersion}` : "No releases yet"}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{!search.q && (search.cursor || nextCursor) ? (
|
||||
<div
|
||||
className="card"
|
||||
style={{ display: "flex", gap: 12, flexWrap: "wrap", justifyContent: "space-between", marginTop: 18 }}
|
||||
>
|
||||
<div className="section-subtitle" style={{ margin: 0 }}>
|
||||
Browsing {items.length} package{items.length === 1 ? "" : "s"} per page.
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
|
||||
{search.cursor ? (
|
||||
<button
|
||||
className="btn"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
First page
|
||||
</button>
|
||||
) : null}
|
||||
{nextCursor ? (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: nextCursor,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
Next page
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
+4
-235
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user