feat: seed local rescan fixtures

This commit is contained in:
Patrick Erichsen
2026-04-27 13:53:14 -07:00
parent 1c430cc11d
commit d3ed4434b9
11 changed files with 757 additions and 51 deletions
+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, it } from "vitest";
import { seedRescanUxFixturesHandler } from "./devSeed";
import { MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE } from "./model/rescans/policy";
function chainEq(constraints: Record<string, unknown>) {
return {
eq(field: string, value: unknown) {
constraints[field] = value;
return chainEq(constraints);
},
};
}
function matches(doc: Record<string, unknown>, constraints: Record<string, unknown>) {
return Object.entries(constraints).every(([key, value]) => doc[key] === value);
}
function createDb() {
const tables: Record<string, Array<Record<string, unknown> & { _id: string }>> = {};
const counters: Record<string, number> = {};
const list = (table: string) => {
tables[table] ??= [];
return tables[table];
};
const db = {
get: async (id: string) => {
const table = id.split(":")[0] ?? "";
return list(table).find((doc) => doc._id === id) ?? null;
},
insert: async (table: string, doc: Record<string, unknown>) => {
counters[table] = (counters[table] ?? 0) + 1;
const inserted = {
_id: `${table}:${counters[table]}`,
_creationTime: counters[table],
...doc,
};
list(table).push(inserted);
return inserted._id;
},
patch: async (id: string, patch: Record<string, unknown>) => {
const table = id.split(":")[0] ?? "";
const doc = list(table).find((candidate) => candidate._id === id);
if (doc) Object.assign(doc, patch);
},
delete: async (id: string) => {
const table = id.split(":")[0] ?? "";
const rows = list(table);
const index = rows.findIndex((doc) => doc._id === id);
if (index !== -1) rows.splice(index, 1);
},
query: (table: string) => ({
withIndex: (_name: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
const constraints: Record<string, unknown> = {};
build(chainEq(constraints));
const matched = () =>
list(table).filter((doc) => matches(doc as Record<string, unknown>, constraints));
return {
collect: async () => matched(),
unique: async () => matched()[0] ?? null,
order: () => ({
collect: async () => matched(),
}),
};
},
}),
};
return { db, tables };
}
describe("devSeed rescan UX fixtures", () => {
it("seeds flagged local owner inventory and deterministic rescan counts idempotently", async () => {
const { db, tables } = createDb();
const args = {
flaggedSkillStorageId: "storage:skill",
flaggedSkillMd: "# Flagged skill",
flaggedPluginStorageId: "storage:plugin",
flaggedPluginReadme: "# Flagged plugin",
};
await seedRescanUxFixturesHandler({ db } as never, args as never);
await seedRescanUxFixturesHandler({ db } as never, args as never);
await seedRescanUxFixturesHandler({ db } as never, { ...args, reset: true } as never);
expect(tables.users).toHaveLength(1);
expect(tables.users?.[0]).toEqual(expect.objectContaining({ handle: "local" }));
expect(tables.publishers).toHaveLength(1);
expect(tables.skills).toHaveLength(1);
expect(tables.skills?.[0]).toEqual(
expect.objectContaining({
ownerUserId: tables.users?.[0]?._id,
ownerPublisherId: tables.publishers?.[0]?._id,
moderationStatus: "hidden",
moderationVerdict: "malicious",
}),
);
expect(tables.packages).toHaveLength(1);
expect(tables.packages?.[0]).toEqual(
expect.objectContaining({
ownerUserId: tables.users?.[0]?._id,
ownerPublisherId: tables.publishers?.[0]?._id,
scanStatus: "malicious",
}),
);
const skillRequests =
tables.rescanRequests?.filter((request) => request.targetKind === "skill") ?? [];
const pluginRequests =
tables.rescanRequests?.filter((request) => request.targetKind === "plugin") ?? [];
expect(skillRequests).toHaveLength(1);
expect(pluginRequests).toHaveLength(MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE);
});
});
+503 -21
View File
@@ -1,9 +1,13 @@
import { v } from "convex/values";
import { internal } from "./_generated/api";
import type { ActionCtx } from "./_generated/server";
import type { Id } from "./_generated/dataModel";
import type { ActionCtx, MutationCtx } from "./_generated/server";
import { internalAction, internalMutation } from "./functions";
import { EMBEDDING_DIMENSIONS } from "./lib/embeddings";
import { normalizePackageName } from "./lib/packageRegistry";
import { ensurePersonalPublisherForUser } from "./lib/publishers";
import { parseClawdisMetadata, parseFrontmatter } from "./lib/skills";
import { MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE } from "./model/rescans/policy";
type SeedSkillSpec = {
slug: string;
@@ -25,6 +29,25 @@ type SeedActionResult = {
type SeedMutationResult = Record<string, unknown>;
const LOCAL_SEED_HANDLE = "local";
const FLAGGED_SKILL_SLUG = "local-flagged-wallet-sync";
const FLAGGED_PLUGIN_NAME = "local-flagged-runtime-plugin";
const FLAGGED_SKILL_MD = `---
name: local-flagged-wallet-sync
description: Local dev fixture for flagged dashboard and rescan UI.
---
# Local Flagged Wallet Sync
This seeded skill is intentionally flagged so local development can exercise owner-only recovery
flows, dashboard unavailable states, and rescan request limits.
`;
const FLAGGED_PLUGIN_README = `# Local Flagged Runtime Plugin
This seeded plugin is intentionally flagged so local development can exercise plugin owner
inventory and cap-exhausted rescan UI.
`;
const SEED_SKILLS: SeedSkillSpec[] = [
{
slug: "padel",
@@ -345,6 +368,22 @@ async function seedNixSkillsHandler(
results.push({ slug: spec.slug, ...result });
}
const [flaggedSkillStorageId, flaggedPluginStorageId] = await Promise.all([
ctx.storage.store(new Blob([FLAGGED_SKILL_MD], { type: "text/markdown" })),
ctx.storage.store(new Blob([FLAGGED_PLUGIN_README], { type: "text/markdown" })),
]);
const fixtureResult: SeedMutationResult = await ctx.runMutation(
internal.devSeed.seedRescanUxFixturesMutation,
{
reset: args.reset,
flaggedSkillStorageId,
flaggedSkillMd: FLAGGED_SKILL_MD,
flaggedPluginStorageId,
flaggedPluginReadme: FLAGGED_PLUGIN_README,
},
);
results.push({ slug: FLAGGED_SKILL_SLUG, ...fixtureResult });
return { ok: true, results };
}
@@ -388,6 +427,467 @@ export const seedPadelSkill: ReturnType<typeof internalAction> = internalAction(
handler: seedPadelSkillHandler,
});
async function ensureLocalSeedOwner(ctx: MutationCtx) {
const now = Date.now();
const existingUsers = await ctx.db
.query("users")
.withIndex("handle", (q) => q.eq("handle", LOCAL_SEED_HANDLE))
.collect();
const userId =
existingUsers[0]?._id ??
(await ctx.db.insert("users", {
handle: LOCAL_SEED_HANDLE,
displayName: "Local Dev",
role: "admin",
createdAt: now,
updatedAt: now,
}));
const user = await ctx.db.get(userId);
if (!user) throw new Error("Local seed user was not created");
const publisher = await ensurePersonalPublisherForUser(ctx, user);
if (!publisher) throw new Error("Local seed publisher was not created");
return { userId, publisherId: publisher._id };
}
async function deleteRescanRequestsForSkillVersion(ctx: MutationCtx, versionId: unknown) {
if (!versionId) return;
const requests = await ctx.db
.query("rescanRequests")
.withIndex("by_skill_version", (q) =>
q.eq("targetKind", "skill").eq("skillVersionId", versionId as never),
)
.collect();
for (const request of requests) await ctx.db.delete(request._id);
}
async function deleteRescanRequestsForPackageRelease(ctx: MutationCtx, releaseId: unknown) {
if (!releaseId) return;
const requests = await ctx.db
.query("rescanRequests")
.withIndex("by_package_release", (q) =>
q.eq("targetKind", "plugin").eq("packageReleaseId", releaseId as never),
)
.collect();
for (const request of requests) await ctx.db.delete(request._id);
}
async function deleteSeedSkillFixture(ctx: MutationCtx) {
const existing = await findSeedSkillFixture(ctx);
if (!existing) return;
const versions = await ctx.db
.query("skillVersions")
.withIndex("by_skill", (q) => q.eq("skillId", existing._id))
.collect();
for (const version of versions) {
await deleteRescanRequestsForSkillVersion(ctx, version._id);
await ctx.db.delete(version._id);
}
const embeddings = await ctx.db
.query("skillEmbeddings")
.withIndex("by_skill", (q) => q.eq("skillId", existing._id))
.collect();
for (const embedding of embeddings) {
const maps = await ctx.db
.query("embeddingSkillMap")
.withIndex("by_embedding", (q) => q.eq("embeddingId", embedding._id))
.collect();
for (const map of maps) await ctx.db.delete(map._id);
await ctx.db.delete(embedding._id);
}
await ctx.db.delete(existing._id);
}
async function findSeedSkillFixture(ctx: MutationCtx) {
return await ctx.db
.query("skills")
.withIndex("by_slug", (q) => q.eq("slug", FLAGGED_SKILL_SLUG))
.unique();
}
async function deleteSeedPluginFixture(ctx: MutationCtx) {
const existing = await findSeedPluginFixture(ctx);
if (!existing) return;
const releases = await ctx.db
.query("packageReleases")
.withIndex("by_package", (q) => q.eq("packageId", existing._id))
.collect();
for (const release of releases) {
await deleteRescanRequestsForPackageRelease(ctx, release._id);
await ctx.db.delete(release._id);
}
await ctx.db.delete(existing._id);
}
async function findSeedPluginFixture(ctx: MutationCtx) {
return await ctx.db
.query("packages")
.withIndex("by_name", (q) => q.eq("normalizedName", normalizePackageName(FLAGGED_PLUGIN_NAME)))
.unique();
}
function staticMaliciousScan(now: number) {
return {
status: "malicious" as const,
reasonCodes: ["malicious.local_dev_fixture"],
findings: [
{
code: "malicious.local_dev_fixture",
severity: "critical" as const,
file: "SKILL.md",
line: 1,
message: "Local dev fixture intentionally flagged for owner recovery testing.",
evidence: "seeded fixture",
},
],
summary: "Local dev fixture intentionally flagged as malicious.",
engineVersion: "local-dev-fixture",
checkedAt: now,
};
}
async function insertCompletedRescanRequests(
ctx: MutationCtx,
params:
| {
targetKind: "skill";
skillId: unknown;
skillVersionId: unknown;
packageId?: never;
packageReleaseId?: never;
targetVersion: string;
ownerUserId: unknown;
ownerPublisherId: unknown;
count: number;
now: number;
}
| {
targetKind: "plugin";
packageId: unknown;
packageReleaseId: unknown;
skillId?: never;
skillVersionId?: never;
targetVersion: string;
ownerUserId: unknown;
ownerPublisherId: unknown;
count: number;
now: number;
},
) {
for (let index = 0; index < params.count; index += 1) {
const createdAt = params.now - (params.count - index) * 60_000;
await ctx.db.insert("rescanRequests", {
targetKind: params.targetKind,
skillId: params.skillId as never,
skillVersionId: params.skillVersionId as never,
packageId: params.packageId as never,
packageReleaseId: params.packageReleaseId as never,
targetVersion: params.targetVersion,
requestedByUserId: params.ownerUserId as never,
ownerUserId: params.ownerUserId as never,
ownerPublisherId: params.ownerPublisherId as never,
status: "completed",
createdAt,
updatedAt: createdAt + 30_000,
completedAt: createdAt + 30_000,
});
}
}
type SeedRescanUxFixturesArgs = {
reset?: boolean;
flaggedSkillStorageId: Id<"_storage">;
flaggedSkillMd: string;
flaggedPluginStorageId: Id<"_storage">;
flaggedPluginReadme: string;
};
export async function seedRescanUxFixturesHandler(
ctx: MutationCtx,
args: SeedRescanUxFixturesArgs,
) {
const existingSkill = await findSeedSkillFixture(ctx);
const existingPlugin = await findSeedPluginFixture(ctx);
if (existingSkill && existingPlugin && !args.reset) {
return {
ok: true,
skipped: true,
ownerUserId: existingSkill.ownerUserId,
ownerPublisherId: existingSkill.ownerPublisherId ?? existingPlugin.ownerPublisherId,
flaggedSkillId: existingSkill._id,
flaggedSkillVersionId: existingSkill.latestVersionId,
flaggedPluginId: existingPlugin._id,
flaggedPluginReleaseId: existingPlugin.latestReleaseId,
};
}
await deleteSeedSkillFixture(ctx);
await deleteSeedPluginFixture(ctx);
const now = Date.now();
const { userId, publisherId } = await ensureLocalSeedOwner(ctx);
const staticScan = staticMaliciousScan(now);
const skillId = await ctx.db.insert("skills", {
slug: FLAGGED_SKILL_SLUG,
displayName: "Local Flagged Wallet Sync",
summary: "Seeded flagged skill for local owner inventory and rescan UI testing.",
ownerUserId: userId,
ownerPublisherId: publisherId,
latestVersionId: undefined,
tags: {},
softDeletedAt: undefined,
badges: { redactionApproved: undefined },
moderationStatus: "hidden",
moderationReason: "scanner.static.malicious",
moderationVerdict: "malicious",
moderationReasonCodes: ["malicious.local_dev_fixture"],
moderationEvidence: staticScan.findings,
moderationSummary: staticScan.summary,
moderationEngineVersion: staticScan.engineVersion,
moderationEvaluatedAt: now,
moderationFlags: ["blocked.malware"],
isSuspicious: true,
statsDownloads: 4,
statsStars: 1,
statsInstallsCurrent: 0,
statsInstallsAllTime: 2,
stats: {
downloads: 4,
installsCurrent: 0,
installsAllTime: 2,
stars: 1,
versions: 0,
comments: 0,
},
createdAt: now,
updatedAt: now,
});
const skillVersionId = await ctx.db.insert("skillVersions", {
skillId,
version: "0.1.0",
changelog: "Seeded flagged local version for rescan UI testing.",
files: [
{
path: "SKILL.md",
size: args.flaggedSkillMd.length,
storageId: args.flaggedSkillStorageId,
sha256: "seeded-flagged-skill",
contentType: "text/markdown",
},
],
parsed: {
frontmatter: {
name: FLAGGED_SKILL_SLUG,
description: "Local dev fixture for flagged dashboard and rescan UI.",
},
},
createdBy: userId,
createdAt: now,
softDeletedAt: undefined,
sha256hash: "seeded-flagged-skill-hash",
vtAnalysis: {
status: "malicious",
verdict: "malicious",
analysis: "Local dev fixture intentionally flagged by VirusTotal.",
source: "local-dev-seed",
checkedAt: now,
},
llmAnalysis: {
status: "suspicious",
verdict: "suspicious",
confidence: "high",
summary: "Local dev fixture intentionally flagged by OpenClaw.",
model: "local-dev-seed",
checkedAt: now,
},
staticScan,
});
await ctx.db.patch(skillId, {
latestVersionId: skillVersionId,
moderationSourceVersionId: skillVersionId,
tags: { latest: skillVersionId },
stats: {
downloads: 4,
installsCurrent: 0,
installsAllTime: 2,
stars: 1,
versions: 1,
comments: 0,
},
updatedAt: now,
});
await insertCompletedRescanRequests(ctx, {
targetKind: "skill",
skillId,
skillVersionId,
targetVersion: "0.1.0",
ownerUserId: userId,
ownerPublisherId: publisherId,
count: 1,
now,
});
const packageId = await ctx.db.insert("packages", {
name: FLAGGED_PLUGIN_NAME,
normalizedName: normalizePackageName(FLAGGED_PLUGIN_NAME),
displayName: "Local Flagged Runtime Plugin",
summary: "Seeded flagged plugin for local owner inventory and cap-exhausted rescan UI testing.",
ownerUserId: userId,
ownerPublisherId: publisherId,
family: "code-plugin",
channel: "community",
isOfficial: false,
runtimeId: "local.flagged.runtime",
sourceRepo: "openclaw/local-dev-fixture",
latestReleaseId: undefined,
latestVersionSummary: undefined,
tags: {},
capabilityTags: ["dev-tools"],
executesCode: true,
compatibility: { pluginApiRange: ">=0.1.0" },
capabilities: {
executesCode: true,
runtimeId: "local.flagged.runtime",
pluginKind: "runtime",
capabilityTags: ["dev-tools"],
},
verification: {
tier: "structural",
scope: "artifact-only",
summary: "Local dev fixture intentionally flagged.",
sourceRepo: "openclaw/local-dev-fixture",
scanStatus: "malicious",
},
scanStatus: "malicious",
stats: { downloads: 2, installs: 0, stars: 0, versions: 0 },
softDeletedAt: undefined,
createdAt: now,
updatedAt: now,
});
const packageReleaseId = await ctx.db.insert("packageReleases", {
packageId,
version: "0.1.0",
changelog: "Seeded flagged local release for cap-exhausted rescan UI testing.",
summary: "Seeded flagged plugin release.",
distTags: ["latest"],
files: [
{
path: "README.md",
size: args.flaggedPluginReadme.length,
storageId: args.flaggedPluginStorageId,
sha256: "seeded-flagged-plugin",
contentType: "text/markdown",
},
],
integritySha256: "seeded-flagged-plugin-integrity",
extractedPackageJson: {
name: FLAGGED_PLUGIN_NAME,
version: "0.1.0",
},
compatibility: { pluginApiRange: ">=0.1.0" },
capabilities: {
executesCode: true,
runtimeId: "local.flagged.runtime",
pluginKind: "runtime",
capabilityTags: ["dev-tools"],
},
verification: {
tier: "structural",
scope: "artifact-only",
summary: "Local dev fixture intentionally flagged.",
sourceRepo: "openclaw/local-dev-fixture",
scanStatus: "malicious",
},
sha256hash: "seeded-flagged-plugin-hash",
vtAnalysis: {
status: "malicious",
verdict: "malicious",
analysis: "Local dev fixture intentionally flagged by VirusTotal.",
source: "local-dev-seed",
checkedAt: now,
},
llmAnalysis: {
status: "suspicious",
verdict: "suspicious",
confidence: "high",
summary: "Local dev fixture intentionally flagged by OpenClaw.",
model: "local-dev-seed",
checkedAt: now,
},
staticScan,
source: { kind: "github", repo: "openclaw/local-dev-fixture", path: "." },
createdBy: userId,
publishActor: { kind: "user", userId },
createdAt: now,
softDeletedAt: undefined,
});
await ctx.db.patch(packageId, {
latestReleaseId: packageReleaseId,
latestVersionSummary: {
version: "0.1.0",
createdAt: now,
changelog: "Seeded flagged local release for cap-exhausted rescan UI testing.",
compatibility: { pluginApiRange: ">=0.1.0" },
capabilities: {
executesCode: true,
runtimeId: "local.flagged.runtime",
pluginKind: "runtime",
capabilityTags: ["dev-tools"],
},
verification: {
tier: "structural",
scope: "artifact-only",
summary: "Local dev fixture intentionally flagged.",
sourceRepo: "openclaw/local-dev-fixture",
scanStatus: "malicious",
},
},
tags: { latest: packageReleaseId },
stats: { downloads: 2, installs: 0, stars: 0, versions: 1 },
updatedAt: now,
});
await insertCompletedRescanRequests(ctx, {
targetKind: "plugin",
packageId,
packageReleaseId,
targetVersion: "0.1.0",
ownerUserId: userId,
ownerPublisherId: publisherId,
count: MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE,
now,
});
await ctx.db.patch(userId, {
publishedSkills: 5,
totalStars: 1,
totalDownloads: 4,
updatedAt: now,
});
return {
ok: true,
ownerUserId: userId,
ownerPublisherId: publisherId,
flaggedSkillId: skillId,
flaggedSkillVersionId: skillVersionId,
flaggedPluginId: packageId,
flaggedPluginReleaseId: packageReleaseId,
};
}
export const seedRescanUxFixturesMutation = internalMutation({
args: {
reset: v.optional(v.boolean()),
flaggedSkillStorageId: v.id("_storage"),
flaggedSkillMd: v.string(),
flaggedPluginStorageId: v.id("_storage"),
flaggedPluginReadme: v.string(),
},
handler: seedRescanUxFixturesHandler,
});
export const seedSkillMutation = internalMutation({
args: {
reset: v.optional(v.boolean()),
@@ -430,26 +930,14 @@ export const seedSkillMutation = internalMutation({
}
const now = Date.now();
const existingUsers = await ctx.db
.query("users")
.withIndex("handle", (q) => q.eq("handle", "local"))
.collect();
const userId =
existingUsers[0]?._id ??
(await ctx.db.insert("users", {
handle: "local",
displayName: "Local Dev",
role: "admin",
createdAt: now,
updatedAt: now,
}));
const { userId, publisherId } = await ensureLocalSeedOwner(ctx);
const skillId = await ctx.db.insert("skills", {
slug: args.slug,
displayName: args.displayName,
summary: args.summary,
ownerUserId: userId,
ownerPublisherId: publisherId,
latestVersionId: undefined,
tags: {},
softDeletedAt: undefined,
@@ -469,12 +957,6 @@ export const seedSkillMutation = internalMutation({
createdAt: now,
updatedAt: now,
});
await ctx.db.patch(userId, {
publishedSkills: 1,
totalStars: 0,
totalDownloads: 0,
});
const versionId = await ctx.db.insert("skillVersions", {
skillId,
version: args.version,
+62 -6
View File
@@ -5,17 +5,61 @@ import type { ActionCtx, MutationCtx, QueryCtx } from "../_generated/server";
export type Role = "admin" | "moderator" | "user";
const DEV_IMPERSONATE_LOCAL_HANDLE = "local";
function readEnv(name: string) {
const value = process.env[name]?.trim();
return value ? value : undefined;
}
function isDevImpersonationAllowed() {
const requestedHandle = readEnv("CLAW_HUB_DEV_IMPERSONATE_USER_HANDLE");
if (requestedHandle !== DEV_IMPERSONATE_LOCAL_HANDLE) return false;
const deployment = readEnv("CONVEX_DEPLOYMENT") ?? "";
if (deployment.startsWith("prod:") || deployment.includes("production")) return false;
return (
deployment.startsWith("anonymous:") ||
deployment.startsWith("dev:") ||
deployment.startsWith("local:") ||
readEnv("CLAW_HUB_ENABLE_DEV_IMPERSONATION") === "1"
);
}
async function getDevImpersonatedUserId(
ctx: Pick<MutationCtx | QueryCtx, "db">,
): Promise<Id<"users"> | undefined> {
if (!isDevImpersonationAllowed()) return undefined;
const user = await ctx.db
.query("users")
.withIndex("handle", (q) => q.eq("handle", DEV_IMPERSONATE_LOCAL_HANDLE))
.unique();
if (!user || user.deletedAt || user.deactivatedAt) return undefined;
return user._id;
}
async function getDevImpersonatedUserIdFromAction(
ctx: ActionCtx,
): Promise<Id<"users"> | undefined> {
if (!isDevImpersonationAllowed()) return undefined;
const user = await ctx.runQuery(internal.users.getByHandleInternal, {
handle: DEV_IMPERSONATE_LOCAL_HANDLE,
});
if (!user || user.deletedAt || user.deactivatedAt) return undefined;
return user._id;
}
export async function getOptionalActiveAuthUserId(
ctx: MutationCtx | QueryCtx,
): Promise<Id<"users"> | undefined> {
try {
const userId = await getAuthUserId(ctx);
if (!userId) return undefined;
if (!userId) return await getDevImpersonatedUserId(ctx);
const user = await ctx.db.get(userId);
if (!user || user.deletedAt || user.deactivatedAt) return undefined;
return userId;
} catch {
return undefined;
return await getDevImpersonatedUserId(ctx);
}
}
@@ -24,17 +68,23 @@ export async function getOptionalActiveAuthUserIdFromAction(
): Promise<Id<"users"> | undefined> {
try {
const userId = await getAuthUserId(ctx);
if (!userId) return undefined;
if (!userId) return await getDevImpersonatedUserIdFromAction(ctx);
const user = await ctx.runQuery(internal.users.getByIdInternal, { userId });
if (!user || user.deletedAt || user.deactivatedAt) return undefined;
return userId;
} catch {
return undefined;
return await getDevImpersonatedUserIdFromAction(ctx);
}
}
export async function requireUser(ctx: MutationCtx | QueryCtx) {
const userId = await getAuthUserId(ctx);
let userId: Id<"users"> | null | undefined = null;
try {
userId = await getAuthUserId(ctx);
} catch {
userId = null;
}
userId ??= await getDevImpersonatedUserId(ctx);
if (!userId) throw new Error("Unauthorized");
let user: Doc<"users"> | null;
try {
@@ -49,7 +99,13 @@ export async function requireUser(ctx: MutationCtx | QueryCtx) {
export async function requireUserFromAction(
ctx: ActionCtx,
): Promise<{ userId: Id<"users">; user: Doc<"users"> }> {
const userId = await getAuthUserId(ctx);
let userId: Id<"users"> | null | undefined = null;
try {
userId = await getAuthUserId(ctx);
} catch {
userId = null;
}
userId ??= await getDevImpersonatedUserIdFromAction(ctx);
if (!userId) throw new Error("Unauthorized");
let user: Doc<"users"> | null;
try {
+2 -3
View File
@@ -1,9 +1,8 @@
import { getAuthUserId } from "@convex-dev/auth/server";
import { ConvexError, v } from "convex/values";
import type { Doc, Id } from "./_generated/dataModel";
import type { MutationCtx } from "./_generated/server";
import { internalMutation, internalQuery, mutation, query } from "./functions";
import { assertAdmin, requireUser } from "./lib/access";
import { assertAdmin, getOptionalActiveAuthUserId, requireUser } from "./lib/access";
import { toPublicPublisher } from "./lib/public";
import {
ensurePersonalPublisherForUser,
@@ -404,7 +403,7 @@ export const resolvePublishTargetForUserInternal = internalMutation({
export const listMine = query({
args: {},
handler: async (ctx) => {
const userId = await getAuthUserId(ctx);
const userId = await getOptionalActiveAuthUserId(ctx);
if (!userId) return [];
const user = await ctx.db.get(userId);
if (!user || user.deletedAt || user.deactivatedAt) return [];
+11 -1
View File
@@ -153,7 +153,17 @@ describe("skills.list", () => {
const ctx = {
db: {
get: vi.fn().mockResolvedValue(null),
get: vi.fn(async (id: string) => {
if (id === "users:owner") {
return {
_id: "users:owner",
_creationTime: 1,
handle: "owner",
displayName: "Owner",
};
}
return null;
}),
query: vi.fn((table: string) => {
if (table === "skills") {
return {
+24 -12
View File
@@ -14,7 +14,14 @@ import {
mutation,
query,
} from "./functions";
import { assertAdmin, assertModerator, requireUser, requireUserFromAction } from "./lib/access";
import {
assertAdmin,
assertModerator,
getOptionalActiveAuthUserId,
getOptionalActiveAuthUserIdFromAction,
requireUser,
requireUserFromAction,
} from "./lib/access";
import { getSkillBadgeMap, getSkillBadgeMaps, isSkillHighlighted } from "./lib/badges";
import { scheduleNextBatchIfNeeded } from "./lib/batching";
import { generateChangelogPreview as buildChangelogPreview } from "./lib/changelog";
@@ -87,13 +94,13 @@ import {
import { readCanonicalStat } from "./lib/skillStats";
import { runStaticPublishScan } from "./lib/staticPublishScan";
import { adjustUserSkillStatsForSkillChange } from "./lib/userSkillStats";
import { getLatestSkillRescanTarget, insertSkillRescanRequest } from "./model/skills/rescans";
import {
assertCanRequestRescan,
buildRescanState,
errorMessage,
finalizeInProgressRescanRequestsForTarget,
} from "./model/rescans/policy";
import { getLatestSkillRescanTarget, insertSkillRescanRequest } from "./model/skills/rescans";
import schema from "./schema";
export { publishVersionForUser } from "./lib/skillPublish";
@@ -1482,7 +1489,7 @@ export const getBySlug = query({
const skill = resolved.skill;
if (!skill) return null;
const userId = await getAuthUserId(ctx);
const userId = await getOptionalActiveAuthUserId(ctx);
const ownerPublisher = await getOwnerPublisher(ctx, {
ownerPublisherId: skill.ownerPublisherId,
ownerUserId: skill.ownerUserId,
@@ -2170,7 +2177,7 @@ export const list = query({
}
const ownerPublisherId = args.ownerPublisherId;
if (ownerPublisherId) {
const userId = await getAuthUserId(ctx);
const userId = await getOptionalActiveAuthUserId(ctx);
const ownerPublisher = await ctx.db.get(ownerPublisherId);
const membership =
userId &&
@@ -2219,7 +2226,7 @@ export const list = query({
}
const ownerUserId = args.ownerUserId;
if (ownerUserId) {
const userId = await getAuthUserId(ctx);
const userId = await getOptionalActiveAuthUserId(ctx);
const isOwnDashboard = Boolean(userId && userId === ownerUserId);
const entries = await ctx.db
.query("skills")
@@ -2870,7 +2877,9 @@ export const listPublicPageV4 = query({
},
});
function buildPublicSkillEntryFromDigest(digest: Doc<"skillSearchDigest">): PublicSkillEntry | null {
function buildPublicSkillEntryFromDigest(
digest: Doc<"skillSearchDigest">,
): PublicSkillEntry | null {
const hydratable = digestToHydratableSkill(digest);
const publicSkill = toPublicSkill(hydratable);
if (!publicSkill) return null;
@@ -4050,11 +4059,14 @@ async function markSkillRescanRequest(
status: "completed" | "failed",
error?: string,
) {
await ctx.runMutation(internal.rescanRequests.markStatusInternal as never, {
requestId,
status,
error,
} as never);
await ctx.runMutation(
internal.rescanRequests.markStatusInternal as never,
{
requestId,
status,
error,
} as never,
);
}
export const getRescanState = query({
@@ -5028,7 +5040,7 @@ async function canReadSkillVersionFiles(ctx: ActionCtx, version: Doc<"skillVersi
})) as Doc<"skills"> | null;
if (!skill) return false;
const authUserId = await getAuthUserId(ctx);
const authUserId = await getOptionalActiveAuthUserIdFromAction(ctx);
if (authUserId) {
if (authUserId === skill.ownerUserId && !skill.softDeletedAt && !version.softDeletedAt) {
return true;
+15 -5
View File
@@ -3,15 +3,20 @@ import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server";
import { internalAction, internalMutation, internalQuery, mutation, query } from "./functions";
import { assertAdmin, assertModerator, getOptionalActiveAuthUserId, requireUser } from "./lib/access";
import {
assertAdmin,
assertModerator,
getOptionalActiveAuthUserId,
requireUser,
} from "./lib/access";
import { syncGitHubProfile } from "./lib/githubAccount";
import { toPublicUser } from "./lib/public";
import {
ensurePersonalPublisherForUser,
getActiveUserByHandleOrPersonalPublisher,
getPublisherByHandle,
getUserByHandleOrPersonalPublisher,
} from "./lib/publishers";
import { toPublicUser } from "./lib/public";
import {
getLatestActiveReservedHandle,
isHandleReservedForAnotherUser,
@@ -296,7 +301,9 @@ export async function ensureHandler(ctx: MutationCtx) {
updates.updatedAt = Date.now();
await ctx.db.patch(userId, updates);
}
const ensuredUser = hasUpdates ? ({ ...user, ...updates } as Doc<"users">) : ((await ctx.db.get(userId)) ?? user);
const ensuredUser = hasUpdates
? ({ ...user, ...updates } as Doc<"users">)
: ((await ctx.db.get(userId)) ?? user);
await ensurePersonalPublisherForUser(ctx, ensuredUser);
return await ctx.db.get(userId);
}
@@ -445,7 +452,9 @@ async function queryUsersForPublicList(
: clampInt(args.limit * 6, args.limit, MAX_USER_SEARCH_SCAN);
const scannedUsers = await ctx.db
.query("users")
.withIndex("by_active_handle", (q) => q.eq("deletedAt", undefined).eq("deactivatedAt", undefined))
.withIndex("by_active_handle", (q) =>
q.eq("deletedAt", undefined).eq("deactivatedAt", undefined),
)
.order("desc")
.take(scanLimit);
const activeUsers = scannedUsers.filter((user) => Boolean(user.handle));
@@ -899,7 +908,8 @@ async function ensurePublisherHandleWithActor(
if (existing) {
const nextDisplayName =
args.displayName?.trim() && (!existing.displayName || existing.displayName === existing.handle)
args.displayName?.trim() &&
(!existing.displayName || existing.displayName === existing.handle)
? displayName
: existing.displayName;
await ctx.db.patch(existing._id, {
+2
View File
@@ -92,6 +92,7 @@ describe("version file access actions", () => {
it("allows owners to read hidden skill versions", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
const ctx = makeActionCtx({
actor: { _id: "users:owner", role: "user" },
version: makeSkillVersion(),
skill: {
_id: "skills:1",
@@ -133,6 +134,7 @@ describe("version file access actions", () => {
it("allows owners to read hidden skill files", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
const ctx = makeActionCtx({
actor: { _id: "users:owner", role: "user" },
version: makeSkillVersion(),
skill: {
_id: "skills:1",
+5 -1
View File
@@ -1,4 +1,4 @@
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
import { createFileRoute, notFound, Outlet, redirect, useRouterState } from "@tanstack/react-router";
import { SkillDetailPage } from "../../components/SkillDetailPage";
import { buildSkillMeta } from "../../lib/og";
import { fetchSkillPageData } from "../../lib/skillPage";
@@ -72,5 +72,9 @@ export const Route = createFileRoute("/$owner/$slug")({
function OwnerSkill() {
const { owner, slug } = Route.useParams();
const { initialData } = Route.useLoaderData();
const pathname = useRouterState({ select: (state) => state.location.pathname });
if (pathname.includes(`/${encodeURIComponent(slug)}/security/`)) {
return <Outlet />;
}
return <SkillDetailPage slug={slug} canonicalOwner={owner} initialData={initialData} />;
}
+12 -1
View File
@@ -1,4 +1,6 @@
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
import { useQuery } from "convex/react";
import { api } from "../../../../../convex/_generated/api";
import {
SecurityScannerPage,
type ScannerSlug,
@@ -74,10 +76,19 @@ export const Route = createFileRoute("/$owner/$slug/security/$scanner")({
function SkillSecurityScannerRoute() {
const { owner, slug, scanner } = Route.useParams();
const { initialData } = Route.useLoaderData();
const result = initialData?.result;
const liveResult = useQuery(api.skills.getBySlug, { slug });
const result = liveResult === undefined ? initialData?.result : liveResult;
const skill = result?.skill;
const latestVersion = result?.latestVersion;
if (result === undefined) {
return (
<main className="section">
<div className="card">Loading security details...</div>
</main>
);
}
if (!skill || !latestVersion) {
return (
<main className="section">
+6 -1
View File
@@ -1,4 +1,4 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { createFileRoute, Link, Outlet, useRouterState } from "@tanstack/react-router";
import { AlertTriangle, ExternalLink, Copy, Check, Download } from "lucide-react";
import { useState } from "react";
import { EmptyState } from "../../components/EmptyState";
@@ -234,6 +234,11 @@ function isEmptyObject(obj: unknown): boolean {
function PluginDetailRoute() {
const { name } = Route.useParams();
const { detail, version, readme, rateLimited } = Route.useLoaderData() as PluginDetailLoaderData;
const pathname = useRouterState({ select: (state) => state.location.pathname });
if (pathname.includes("/security/")) {
return <Outlet />;
}
if (rateLimited?.scope === "detail") {
return (