mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
* chore: update plugin inspector to 0.3.20 * feat: gate plugin compatibility emails on hard errors
12131 lines
398 KiB
TypeScript
12131 lines
398 KiB
TypeScript
import {
|
|
ServerPackagePublishRequestSchema,
|
|
validateClawPackageContents,
|
|
derivePluginCategoryTags,
|
|
getCatalogTopicSlugs,
|
|
getPackageScopeOwnerMismatch,
|
|
INTERNAL_UNCATEGORIZED_CATEGORY,
|
|
isPluginCategorySlug,
|
|
normalizeCatalogTopic,
|
|
normalizeCatalogTopics,
|
|
normalizePluginCategories,
|
|
parseArk,
|
|
resolvePluginCategories,
|
|
validateOpenClawExternalCodePluginPackageContents,
|
|
type PackageArtifactSummary,
|
|
type PackageChannel,
|
|
type PackageFamily,
|
|
type PluginCategorySlug,
|
|
type PackageModerationQueueStatus,
|
|
type PackageOfficialMigrationListPhase,
|
|
type PackageOfficialMigrationPhase,
|
|
type ServerPackagePublishRequest,
|
|
type PackageVerificationTier,
|
|
} from "clawhub-schema";
|
|
import { getPage, type IndexKey } from "convex-helpers/server/pagination";
|
|
import { paginationOptsValidator } from "convex/server";
|
|
import { ConvexError, v, type Value } from "convex/values";
|
|
import semver from "semver";
|
|
import { internal } from "./_generated/api";
|
|
import type { Doc, Id } from "./_generated/dataModel";
|
|
import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server";
|
|
import {
|
|
action,
|
|
internalAction,
|
|
internalMutation,
|
|
internalQuery,
|
|
mutation,
|
|
query,
|
|
} from "./functions";
|
|
import {
|
|
assertAdmin,
|
|
assertModerator,
|
|
assertRole,
|
|
getOptionalActiveAuthUserId,
|
|
requireUserFromAction,
|
|
requireUser,
|
|
} from "./lib/access";
|
|
import {
|
|
assertArtifactAppealFinalAction,
|
|
assertArtifactAppealTransition,
|
|
assertArtifactReportFinalAction,
|
|
assertArtifactReportTransition,
|
|
readArtifactReportStatus,
|
|
appendPackageModerationEventLog,
|
|
} from "./lib/artifactModeration";
|
|
import { generatePackageChangelogPreview } from "./lib/changelog";
|
|
import { sha256Hex } from "./lib/clawpack";
|
|
import {
|
|
ACTIVITY_TREND_DAYS,
|
|
buildDailyMetricTrends,
|
|
clampActivityTrendEndDay,
|
|
getActivityTrendRangeForEndDay,
|
|
} from "./lib/downloadTrend";
|
|
import {
|
|
buildPackageInspectorFindingsEmail,
|
|
buildPackageInspectorValidationUrl,
|
|
} from "./lib/emails";
|
|
import { experimentalClawsEnabled, isClawFamilyPubliclyVisible } from "./lib/experimentalClaws";
|
|
import { requireGitHubAccountAge } from "./lib/githubAccount";
|
|
import { normalizeGitHubRepository } from "./lib/githubActionsOidc";
|
|
import { readGlobalPublicPluginsCount } from "./lib/globalStats";
|
|
import { toDayKey } from "./lib/leaderboards";
|
|
import { isOfficialPublisher } from "./lib/officialPublishers";
|
|
import { getPackageReleaseArtifactSha256 } from "./lib/packageArtifacts";
|
|
import {
|
|
assertPackageVersion,
|
|
derivePluginManifestSummary,
|
|
ensurePluginNameMatchesPackage,
|
|
extractBundlePluginArtifacts,
|
|
extractCodePluginArtifacts,
|
|
maybeParseJson,
|
|
normalizePluginManifestIcon,
|
|
normalizePackageName,
|
|
normalizePublishFiles,
|
|
readStorageText,
|
|
readOptionalTextFile,
|
|
summarizePackageForSearch,
|
|
toConvexSafeJsonValue,
|
|
} from "./lib/packageRegistry";
|
|
import { extractPackageDigestFields, upsertPackageSearchDigest } from "./lib/packageSearchDigest";
|
|
import {
|
|
getPackageTrustReasons,
|
|
isPackageBlockedFromPublic,
|
|
normalizePackageScanStatus,
|
|
resolvePackageReleaseScanStatus,
|
|
} from "./lib/packageSecurity";
|
|
import { insertPackageInstallStatEvent } from "./lib/packageStatEvents";
|
|
import { toPublicPublisher } from "./lib/public";
|
|
import {
|
|
assertCanManageOwnedResource,
|
|
canAccessPublisherOwnerScope,
|
|
getPublisherByHandle,
|
|
getPersonalPublisherForUser,
|
|
getOwnerPublisher,
|
|
getPublisherMembership,
|
|
isPublisherActive,
|
|
isPublisherRoleAllowed,
|
|
PUBLISHER_HANDLE_PATTERN,
|
|
normalizePublisherHandle,
|
|
} from "./lib/publishers";
|
|
import {
|
|
findOversizedPublishFile,
|
|
getPublishFileSizeError,
|
|
getPublishTotalSizeError,
|
|
MAX_PUBLISH_TOTAL_BYTES,
|
|
} from "./lib/publishLimits";
|
|
import { assertRankingMetricWritesAllowed } from "./lib/rankingMetricsImportLock";
|
|
import {
|
|
computeRecommendationScore,
|
|
RECOMMENDATION_SCORE_VERSION,
|
|
} from "./lib/recommendationScore";
|
|
import { MAX_ACTIVE_REPORTS_PER_USER, MAX_REPORT_REASON_LENGTH } from "./lib/reporting";
|
|
import {
|
|
compareRankedSearchKeys,
|
|
isDemotedExactMatch,
|
|
rankedSearchKey,
|
|
verificationRank,
|
|
type SearchTrustSignals,
|
|
} from "./lib/searchRanking";
|
|
import { matchesAllTokens, matchesExploratoryTokenPrefixes, tokenize } from "./lib/searchText";
|
|
import { hashSkillFiles } from "./lib/skills";
|
|
import { buildDeterministicPackageZip } from "./lib/skillZip";
|
|
import { runStaticPublishScan } from "./lib/staticPublishScan";
|
|
import { PACKAGE_TRENDING_LEADERBOARD_KIND } from "./packageLeaderboards";
|
|
import schema from "./schema";
|
|
|
|
const MAX_PUBLIC_LIST_PAGE_SIZE = 200;
|
|
const MAX_PUBLIC_LIST_FILTER_SCAN_DOCUMENTS = 500;
|
|
const MAX_PUBLIC_LIST_FILTER_SCAN_PAGES = 6;
|
|
const MAX_PLUGIN_EXPORT_LIST_LIMIT = 250;
|
|
const MAX_PLUGIN_VALIDATION_REPORT_PAGE_SIZE = 50;
|
|
const MAX_SEARCH_PAGE_SIZE = 200;
|
|
const MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES = 20;
|
|
const MAX_DIRECT_PACKAGE_FULL_TEXT_CANDIDATES = 40;
|
|
const STABLE_PACKAGE_FAMILIES = ["skill", "code-plugin", "bundle-plugin"] as const;
|
|
const MAX_PACKAGE_VERSION_DELETE_LOOKUP_CANDIDATES = 4;
|
|
const MAX_POINTERLESS_RELEASE_SURVIVOR_SCAN = 100;
|
|
// Release rows can approach 1 MiB, and trigger-wrapped patches materialize full documents.
|
|
const PACKAGE_RELEASE_TAG_CLEANUP_BATCH_SIZE = 4;
|
|
const packageListScanStatusValidator = v.union(
|
|
v.literal("clean"),
|
|
v.literal("suspicious"),
|
|
v.literal("malicious"),
|
|
v.literal("pending"),
|
|
v.literal("not-run"),
|
|
);
|
|
type PackageListScanStatus = NonNullable<Doc<"packages">["scanStatus"]>;
|
|
const MAX_APPEAL_MESSAGE_LENGTH = 2_000;
|
|
const MAX_OFFICIAL_MIGRATION_BLOCKERS = 20;
|
|
const MAX_OFFICIAL_MIGRATION_FIELD_LENGTH = 300;
|
|
const MAX_OFFICIAL_MIGRATION_NOTES_LENGTH = 2_000;
|
|
const MAX_STORED_PACKAGE_METADATA_DEPTH = 10;
|
|
const REAL_BUNDLE_MANIFESTS = [
|
|
{ path: ".codex-plugin/plugin.json", format: "codex" },
|
|
{ path: ".claude-plugin/plugin.json", format: "claude" },
|
|
{ path: ".cursor-plugin/plugin.json", format: "cursor" },
|
|
] as const;
|
|
const INITIAL_PACKAGE_VT_SCAN_DELAY_MS = 30_000;
|
|
const PLUGIN_EXPORT_FAMILIES = ["code-plugin", "bundle-plugin"] as const;
|
|
const GET_PAGE_TIEBREAKER_FIELD_COUNT = 2;
|
|
|
|
function computePackageRecommendationScore(
|
|
stats: Doc<"packages">["stats"],
|
|
context?: { createdAt?: number; updatedAt?: number; now?: number },
|
|
) {
|
|
return computeRecommendationScore(
|
|
{
|
|
downloads: stats.downloads,
|
|
installs: stats.installs,
|
|
stars: stats.stars,
|
|
},
|
|
context,
|
|
);
|
|
}
|
|
|
|
function computePackageRecommendationPatch(
|
|
stats: Doc<"packages">["stats"],
|
|
context?: { createdAt?: number; updatedAt?: number; now?: number },
|
|
) {
|
|
return {
|
|
recommendedScore: computePackageRecommendationScore(stats, context),
|
|
recommendedScoreVersion: RECOMMENDATION_SCORE_VERSION,
|
|
};
|
|
}
|
|
|
|
function getPackageRecommendedScoreIndexName(family: Doc<"packages">["family"] | undefined) {
|
|
return family ? "by_active_family_recommended_score" : "by_active_recommended_score";
|
|
}
|
|
|
|
async function getPackageRecommendedIndexName(
|
|
ctx: Pick<QueryCtx, "db">,
|
|
family: Doc<"packages">["family"] | undefined,
|
|
) {
|
|
const missingScore = await hasMissingPackageRecommendedScore(ctx, family);
|
|
if (missingScore) return null;
|
|
return getPackageRecommendedScoreIndexName(family);
|
|
}
|
|
|
|
async function hasMissingPackageRecommendedScore(
|
|
ctx: Pick<QueryCtx, "db">,
|
|
family: Doc<"packages">["family"] | undefined,
|
|
) {
|
|
if (family) {
|
|
const missingScore = await ctx.db
|
|
.query("packages")
|
|
.withIndex("by_active_family_recommended_score", (q) =>
|
|
q.eq("softDeletedAt", undefined).eq("family", family).eq("recommendedScore", undefined),
|
|
)
|
|
.first();
|
|
if (missingScore) return true;
|
|
|
|
const missingVersion = await ctx.db
|
|
.query("packages")
|
|
.withIndex("by_active_family_recommended_score_version", (q) =>
|
|
q
|
|
.eq("softDeletedAt", undefined)
|
|
.eq("family", family)
|
|
.eq("recommendedScoreVersion", undefined),
|
|
)
|
|
.first();
|
|
if (missingVersion) return true;
|
|
|
|
const staleVersion = await ctx.db
|
|
.query("packages")
|
|
.withIndex("by_active_family_recommended_score_version", (q) =>
|
|
q
|
|
.eq("softDeletedAt", undefined)
|
|
.eq("family", family)
|
|
.lt("recommendedScoreVersion", RECOMMENDATION_SCORE_VERSION),
|
|
)
|
|
.first();
|
|
return Boolean(staleVersion);
|
|
}
|
|
|
|
const missingScore = await ctx.db
|
|
.query("packages")
|
|
.withIndex("by_active_recommended_score", (q) =>
|
|
q.eq("softDeletedAt", undefined).eq("recommendedScore", undefined),
|
|
)
|
|
.first();
|
|
if (missingScore) return true;
|
|
|
|
const missingVersion = await ctx.db
|
|
.query("packages")
|
|
.withIndex("by_active_recommended_score_version", (q) =>
|
|
q.eq("softDeletedAt", undefined).eq("recommendedScoreVersion", undefined),
|
|
)
|
|
.first();
|
|
if (missingVersion) return true;
|
|
|
|
const staleVersion = await ctx.db
|
|
.query("packages")
|
|
.withIndex("by_active_recommended_score_version", (q) =>
|
|
q.eq("softDeletedAt", undefined).lt("recommendedScoreVersion", RECOMMENDATION_SCORE_VERSION),
|
|
)
|
|
.first();
|
|
return Boolean(staleVersion);
|
|
}
|
|
|
|
const llmAgenticRiskEvidenceValidator = v.object({
|
|
path: v.string(),
|
|
snippet: v.string(),
|
|
explanation: v.string(),
|
|
});
|
|
|
|
const llmAgenticRiskFindingValidator = v.object({
|
|
categoryId: v.string(),
|
|
categoryLabel: v.string(),
|
|
riskBucket: v.union(
|
|
v.literal("abnormal_behavior_control"),
|
|
v.literal("permission_boundary"),
|
|
v.literal("sensitive_data_protection"),
|
|
),
|
|
status: v.union(v.literal("none"), v.literal("note"), v.literal("concern")),
|
|
severity: v.string(),
|
|
confidence: v.union(v.literal("high"), v.literal("medium"), v.literal("low")),
|
|
evidence: v.optional(llmAgenticRiskEvidenceValidator),
|
|
userImpact: v.string(),
|
|
recommendation: v.string(),
|
|
});
|
|
|
|
const llmRiskSummaryBucketValidator = v.object({
|
|
status: v.union(v.literal("none"), v.literal("note"), v.literal("concern")),
|
|
summary: v.string(),
|
|
highestSeverity: v.optional(v.string()),
|
|
});
|
|
const packageOfficialMigrationPhaseValidator = v.union(
|
|
v.literal("planned"),
|
|
v.literal("published"),
|
|
v.literal("clawpack-ready"),
|
|
v.literal("legacy-zip-only"),
|
|
v.literal("metadata-ready"),
|
|
v.literal("blocked"),
|
|
v.literal("ready-for-openclaw"),
|
|
);
|
|
const vtEngineStatsValidator = v.object({
|
|
malicious: v.optional(v.number()),
|
|
suspicious: v.optional(v.number()),
|
|
undetected: v.optional(v.number()),
|
|
harmless: v.optional(v.number()),
|
|
});
|
|
const vtAnalysisValidator = v.object({
|
|
status: v.string(),
|
|
verdict: v.optional(v.string()),
|
|
analysis: v.optional(v.string()),
|
|
source: v.optional(v.string()),
|
|
scanner: v.optional(v.string()),
|
|
engineStats: v.optional(vtEngineStatsValidator),
|
|
checkedAt: v.number(),
|
|
});
|
|
|
|
const skillSpectorIssueValidator = v.object({
|
|
issueId: v.string(),
|
|
category: v.optional(v.string()),
|
|
pattern: v.optional(v.string()),
|
|
severity: v.string(),
|
|
confidence: v.optional(v.number()),
|
|
file: v.optional(v.string()),
|
|
startLine: v.optional(v.number()),
|
|
endLine: v.optional(v.number()),
|
|
explanation: v.string(),
|
|
remediation: v.optional(v.string()),
|
|
finding: v.optional(v.string()),
|
|
codeSnippet: v.optional(v.string()),
|
|
});
|
|
|
|
const skillSpectorAnalysisValidator = v.object({
|
|
status: v.string(),
|
|
score: v.optional(v.number()),
|
|
severity: v.optional(v.string()),
|
|
recommendation: v.optional(v.string()),
|
|
issueCount: v.number(),
|
|
issues: v.array(skillSpectorIssueValidator),
|
|
scannerVersion: v.optional(v.string()),
|
|
summary: v.optional(v.string()),
|
|
error: v.optional(v.string()),
|
|
checkedAt: v.number(),
|
|
});
|
|
|
|
const PACKAGE_STAT_EVENT_BATCH_SIZE = 100;
|
|
export const PROCESSED_PACKAGE_STAT_EVENT_PRUNE_CONFIRMATION_TOKEN =
|
|
"PRUNE_PROCESSED_PACKAGE_STAT_EVENTS";
|
|
const DEFAULT_PROCESSED_PACKAGE_STAT_EVENT_RETENTION_DAYS = 7;
|
|
const MIN_PROCESSED_PACKAGE_STAT_EVENT_RETENTION_DAYS = 1;
|
|
const MAX_PROCESSED_PACKAGE_STAT_EVENT_RETENTION_DAYS = 90;
|
|
const DEFAULT_PROCESSED_PACKAGE_STAT_EVENT_PRUNE_BATCH_SIZE = 1_000;
|
|
const MAX_PROCESSED_PACKAGE_STAT_EVENT_PRUNE_BATCH_SIZE = 5_000;
|
|
const DEFAULT_PROCESSED_PACKAGE_STAT_EVENT_PRUNE_MAX_BATCHES = 20;
|
|
const MAX_PROCESSED_PACKAGE_STAT_EVENT_PRUNE_MAX_BATCHES = 100;
|
|
|
|
type ProcessedPackageStatEventPruneBatchResult = {
|
|
cutoffProcessedAt: number;
|
|
dryRun: boolean;
|
|
matched: number;
|
|
deleted: number;
|
|
hasMore: boolean;
|
|
};
|
|
|
|
type ProcessedPackageStatEventPruneResult = {
|
|
cutoffProcessedAt: number;
|
|
retentionDays: number;
|
|
dryRun: boolean;
|
|
batches: number;
|
|
matched: number;
|
|
deleted: number;
|
|
stoppedReason: "empty" | "max_batches";
|
|
scheduledContinuation: boolean;
|
|
};
|
|
|
|
function clampPackageStatInt(value: number, min: number, max: number) {
|
|
if (!Number.isFinite(value)) return min;
|
|
return Math.max(min, Math.min(Math.floor(value), max));
|
|
}
|
|
|
|
function normalizeProcessedPackageStatEventRetentionDays(retentionDays: number | undefined) {
|
|
return clampPackageStatInt(
|
|
retentionDays ?? DEFAULT_PROCESSED_PACKAGE_STAT_EVENT_RETENTION_DAYS,
|
|
MIN_PROCESSED_PACKAGE_STAT_EVENT_RETENTION_DAYS,
|
|
MAX_PROCESSED_PACKAGE_STAT_EVENT_RETENTION_DAYS,
|
|
);
|
|
}
|
|
|
|
function normalizeProcessedPackageStatEventPruneBatchSize(batchSize: number | undefined) {
|
|
return clampPackageStatInt(
|
|
batchSize ?? DEFAULT_PROCESSED_PACKAGE_STAT_EVENT_PRUNE_BATCH_SIZE,
|
|
1,
|
|
MAX_PROCESSED_PACKAGE_STAT_EVENT_PRUNE_BATCH_SIZE,
|
|
);
|
|
}
|
|
|
|
function normalizeProcessedPackageStatEventPruneMaxBatches(maxBatches: number | undefined) {
|
|
return clampPackageStatInt(
|
|
maxBatches ?? DEFAULT_PROCESSED_PACKAGE_STAT_EVENT_PRUNE_MAX_BATCHES,
|
|
1,
|
|
MAX_PROCESSED_PACKAGE_STAT_EVENT_PRUNE_MAX_BATCHES,
|
|
);
|
|
}
|
|
|
|
function inferOwnerHandleFromScopedPackageName(name: string) {
|
|
const match = /^@([^/]+)\//.exec(name);
|
|
return match?.[1] || undefined;
|
|
}
|
|
|
|
function getPackageSlugFromName(name: string) {
|
|
return name.split("/").pop()?.trim() || "plugin-name";
|
|
}
|
|
|
|
function getClawHubPublisherHandleSuggestion(handle: string) {
|
|
const suggestion = handle
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9._-]+/g, "-")
|
|
.replace(/-+/g, "-")
|
|
.replace(/^[._-]+|[._-]+$/g, "")
|
|
.slice(0, 40)
|
|
.replace(/[._-]+$/g, "");
|
|
return PUBLISHER_HANDLE_PATTERN.test(suggestion) ? suggestion : null;
|
|
}
|
|
|
|
function getScopedPackageMissingPublisherMessage(params: {
|
|
scopedOwnerHandle: string;
|
|
packageName: string;
|
|
legacyPersonalOwnerHandle?: string;
|
|
}) {
|
|
if (!PUBLISHER_HANDLE_PATTERN.test(params.scopedOwnerHandle)) {
|
|
const suggestedOwnerHandle = getClawHubPublisherHandleSuggestion(params.scopedOwnerHandle);
|
|
const packageSlug = getPackageSlugFromName(params.packageName);
|
|
const renameGuidance = suggestedOwnerHandle
|
|
? ` Rename package.json to a ClawHub-compatible scope, such as "@${suggestedOwnerHandle}/${packageSlug}", then publish again.`
|
|
: " Rename package.json to a ClawHub-compatible scope that starts and ends with a lowercase letter or number and uses lowercase letters, numbers, hyphens, dots, or underscores, then publish again.";
|
|
return `Cannot publish ${params.packageName}: package.json name is scoped to "@${params.scopedOwnerHandle}", but ClawHub publisher handles must start and end with a lowercase letter or number and may only use lowercase letters, numbers, hyphens, dots, or underscores.${renameGuidance}`;
|
|
}
|
|
if (params.legacyPersonalOwnerHandle) {
|
|
const displayName = params.scopedOwnerHandle
|
|
.split(/[-_]+/)
|
|
.filter(Boolean)
|
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
.join(" ");
|
|
return `Cannot publish ${params.packageName}: package.json name is scoped to "@${params.scopedOwnerHandle}", but ClawHub has no "@${params.scopedOwnerHandle}" publisher.\n\nThis package already exists under your personal publisher "@${params.legacyPersonalOwnerHandle}". To move it into the matching org publisher, run:\n\n clawhub publisher create ${params.scopedOwnerHandle} --display-name "${displayName || params.scopedOwnerHandle}"\n clawhub package transfer ${params.packageName} --to ${params.scopedOwnerHandle} --reason "Move legacy personal package into @${params.scopedOwnerHandle}"\n\nThen rerun publish.`;
|
|
}
|
|
return `Cannot publish ${params.packageName}: package.json name is scoped to "@${params.scopedOwnerHandle}", but ClawHub has no "@${params.scopedOwnerHandle}" publisher. Create it with "clawhub publisher create ${params.scopedOwnerHandle}".`;
|
|
}
|
|
|
|
function getScopedPackagePublishAccessMessage(params: {
|
|
scopedOwnerHandle: string;
|
|
packageName: string;
|
|
}) {
|
|
return [
|
|
`Cannot publish ${params.packageName}: package.json name is scoped to "@${params.scopedOwnerHandle}", but your account does not have publish rights to the "@${params.scopedOwnerHandle}" ClawHub organization.`,
|
|
"Create the matching ClawHub organization if it does not exist, get publish rights to that organization, or rename package.json name to use an organization scope you control.",
|
|
].join("\n\n");
|
|
}
|
|
|
|
function isTrustedOpenClawPluginPackage(params: {
|
|
family: PackageFamily;
|
|
normalizedName: string;
|
|
ownerPublisher?: Pick<Doc<"publishers">, "handle" | "deletedAt"> | null;
|
|
}) {
|
|
if (params.family !== "code-plugin" && params.family !== "bundle-plugin") return false;
|
|
if (!params.normalizedName.startsWith("@openclaw/")) return false;
|
|
const ownerHandle = params.ownerPublisher?.handle?.trim().toLowerCase();
|
|
return ownerHandle === "openclaw" && params.ownerPublisher?.deletedAt === undefined;
|
|
}
|
|
|
|
const internalRefs = internal as unknown as {
|
|
packages: {
|
|
backfillPackageReleaseScansInternal: unknown;
|
|
scanPackageReleaseStaticallyInternal: unknown;
|
|
insertReleaseInternal: unknown;
|
|
getPackageByNameInternal: unknown;
|
|
findPackagePublishResultInternal: unknown;
|
|
getTrustedPublisherByPackageIdInternal: unknown;
|
|
getByNameForViewerInternal: unknown;
|
|
getPackageByIdInternal: unknown;
|
|
getReleaseByIdInternal: unknown;
|
|
assertCanGenerateChangelogPreviewInternal: unknown;
|
|
getReleaseByPackageAndVersionInternal: unknown;
|
|
getPackageReleaseScanBackfillBatchInternal: unknown;
|
|
listVersionsForViewerInternal: unknown;
|
|
getVersionByNameForViewerInternal: unknown;
|
|
publishPackageForUserInternal: unknown;
|
|
insertAuditLogInternal: unknown;
|
|
updateReleaseStaticScanInternal: unknown;
|
|
backfillLatestPackageScanStatusInternal: unknown;
|
|
normalizeOfficialPublisherPackagesInternal: unknown;
|
|
insertPackageInspectorWarningsInternal: unknown;
|
|
sendPackageInspectorFindingsEmailInternal: unknown;
|
|
getPackageInspectorEmailContextInternal: unknown;
|
|
markPackageInspectorFindingsEmailedInternal: unknown;
|
|
claimPackageInspectorScanBatchInternal: unknown;
|
|
ingestPackageInspectorScanResultsInternal: unknown;
|
|
discardPendingPackagePublicationInternal: unknown;
|
|
cleanupReassignedPackageReleaseTagsInternal: unknown;
|
|
publishPendingReleaseInternal: unknown;
|
|
};
|
|
packageInspectorNode: {
|
|
runPackageInspectorForPublishInternal: unknown;
|
|
};
|
|
packagePublishTokens: {
|
|
createInternal: unknown;
|
|
getByIdInternal: unknown;
|
|
revokeInternal: unknown;
|
|
};
|
|
skills: {
|
|
getSkillBySlugInternal: unknown;
|
|
};
|
|
users: {
|
|
getByIdInternal: unknown;
|
|
getByHandleInternal: unknown;
|
|
};
|
|
publishers: {
|
|
getByIdInternal: unknown;
|
|
resolvePublishTargetForUserInternal: unknown;
|
|
};
|
|
publishAttempts: {
|
|
createPackagePublishAttemptInternal: unknown;
|
|
findExistingPublishAttemptForArtifactInternal: unknown;
|
|
claimPackagePublishAttemptForFinalizationInternal: unknown;
|
|
releasePackagePublishAttemptFinalizationClaimInternal: unknown;
|
|
recordPackagePublishAttemptFinalizedInternal: unknown;
|
|
};
|
|
securityScan: {
|
|
enqueuePackageReleaseScanInternal: unknown;
|
|
};
|
|
vt: {
|
|
scanPackageReleaseWithVirusTotal: unknown;
|
|
};
|
|
};
|
|
|
|
const packageInspectorWarningInputValidator = v.object({
|
|
id: v.optional(v.string()),
|
|
code: v.string(),
|
|
severity: v.optional(v.string()),
|
|
level: v.optional(v.string()),
|
|
issueClass: v.optional(v.string()),
|
|
compatStatus: v.optional(v.string()),
|
|
deprecated: v.optional(v.boolean()),
|
|
message: v.string(),
|
|
evidence: v.optional(v.array(v.string())),
|
|
authorRemediation: v.optional(
|
|
v.object({
|
|
summary: v.string(),
|
|
docsUrl: v.optional(v.string()),
|
|
}),
|
|
),
|
|
fixture: v.optional(v.string()),
|
|
decision: v.optional(v.string()),
|
|
});
|
|
|
|
const packageInspectorFindingInputValidator = v.object({
|
|
id: v.optional(v.string()),
|
|
code: v.string(),
|
|
severity: v.optional(v.string()),
|
|
level: v.optional(v.string()),
|
|
issueClass: v.optional(v.string()),
|
|
compatStatus: v.optional(v.string()),
|
|
deprecated: v.optional(v.boolean()),
|
|
message: v.string(),
|
|
evidence: v.optional(v.array(v.string())),
|
|
authorRemediation: v.optional(
|
|
v.object({
|
|
summary: v.string(),
|
|
docsUrl: v.optional(v.string()),
|
|
}),
|
|
),
|
|
fixture: v.optional(v.string()),
|
|
decision: v.optional(v.string()),
|
|
});
|
|
|
|
type PackageInspectorAuthorRemediation = {
|
|
summary: string;
|
|
docsUrl?: string;
|
|
};
|
|
|
|
type PackageInspectorFinding = {
|
|
id?: string;
|
|
code: string;
|
|
severity?: string;
|
|
level?: string;
|
|
issueClass?: string;
|
|
compatStatus?: string;
|
|
deprecated?: boolean;
|
|
message: string;
|
|
evidence?: string[];
|
|
authorRemediation?: PackageInspectorAuthorRemediation;
|
|
fixture?: string;
|
|
decision?: string;
|
|
};
|
|
|
|
type PackageInspectorPublishResult = {
|
|
status: "pass" | "fail";
|
|
summary: {
|
|
breakageCount: number;
|
|
warningCount: number;
|
|
deprecationWarningCount: number;
|
|
issueCount: number;
|
|
};
|
|
breakages: PackageInspectorFinding[];
|
|
warnings: PackageInspectorFinding[];
|
|
metadata?: {
|
|
inspectorVersion?: string;
|
|
targetOpenClawVersion?: string;
|
|
};
|
|
};
|
|
|
|
function hasAuthorRemediation(finding: PackageInspectorFinding) {
|
|
return Boolean(finding.authorRemediation?.summary);
|
|
}
|
|
type DbReaderCtx = Pick<QueryCtx | MutationCtx, "db">;
|
|
const BAN_USER_PACKAGES_BATCH_SIZE = 25;
|
|
const PACKAGE_PUBLISH_TOKEN_REVOKE_BATCH_SIZE = 25;
|
|
type PackageSoftDeletedReason = "user.banned" | "user.deactivated" | "publisher.deleted";
|
|
const ownedPackageScanScopeValidator = v.optional(
|
|
v.union(v.literal("ownerUserId"), v.literal("personalPublisher")),
|
|
);
|
|
const hardDeletePackageSourceValidator = v.union(
|
|
v.literal("admin"),
|
|
v.literal("account.delete"),
|
|
v.literal("publisher.delete"),
|
|
);
|
|
type OwnedPackageScanScope = "ownerUserId" | "personalPublisher";
|
|
type PackagePublishActor =
|
|
| {
|
|
kind: "user";
|
|
userId: Id<"users">;
|
|
}
|
|
| {
|
|
kind: "github-actions";
|
|
repository: string;
|
|
workflow: string;
|
|
runId: string;
|
|
runAttempt: string;
|
|
sha: string;
|
|
};
|
|
type PackagePublishAuthContext =
|
|
| {
|
|
kind: "user";
|
|
actorUserId: Id<"users">;
|
|
manualOverrideReason?: string;
|
|
}
|
|
| {
|
|
kind: "github-actions";
|
|
publishToken: Doc<"packagePublishTokens">;
|
|
};
|
|
type PackageTrustedPublisherDoc = Doc<"packageTrustedPublishers">;
|
|
type PackagePublishOptions = {
|
|
stagePrePublicationChecks?: boolean;
|
|
};
|
|
type PackageDoc = Doc<"packages">;
|
|
type PublicPackageListItem = {
|
|
name: string;
|
|
displayName: string;
|
|
family: PackageFamily;
|
|
runtimeId: string | null;
|
|
channel: PackageChannel;
|
|
isOfficial: boolean;
|
|
summary: string | null;
|
|
icon: string | null;
|
|
ownerHandle: string | null;
|
|
createdAt: number;
|
|
updatedAt: number;
|
|
latestVersion: string | null;
|
|
categories?: string[];
|
|
topics?: string[];
|
|
featuredAt?: number;
|
|
verificationTier: PackageVerificationTier | null;
|
|
stats: Doc<"packages">["stats"];
|
|
};
|
|
type PackageReleaseScanStatus = ReturnType<typeof resolvePackageReleaseScanStatus>;
|
|
type PackageReleaseModerationQueueDoc = Omit<Doc<"packageReleases">, "createdAt"> & {
|
|
createdAt?: number;
|
|
};
|
|
type PackageReportStatus = "open" | "confirmed" | "dismissed";
|
|
type PackageReportFinalAction = "none" | "quarantine" | "revoke";
|
|
type PackageAppealFinalAction = "none" | "approve";
|
|
type PackageModerationQueueItem = {
|
|
packageId: Id<"packages">;
|
|
releaseId: Id<"packageReleases">;
|
|
name: string;
|
|
displayName: string;
|
|
family: PackageFamily;
|
|
channel: PackageChannel;
|
|
isOfficial: boolean;
|
|
version: string;
|
|
createdAt: number;
|
|
artifactKind?: Doc<"packageReleases">["artifactKind"] | null;
|
|
scanStatus: PackageReleaseScanStatus;
|
|
moderationState?: NonNullable<Doc<"packageReleases">["manualModeration"]>["state"] | null;
|
|
moderationReason?: string | null;
|
|
sourceRepo?: string | null;
|
|
sourceCommit?: string | null;
|
|
reportCount: number;
|
|
lastReportedAt?: number | null;
|
|
reasons: string[];
|
|
};
|
|
type PackageReportListItem = {
|
|
reportId: Id<"packageReports">;
|
|
packageId: Id<"packages">;
|
|
releaseId?: Id<"packageReleases"> | null;
|
|
name: string;
|
|
displayName: string;
|
|
family: PackageFamily;
|
|
version?: string | null;
|
|
reason?: string | null;
|
|
status: PackageReportStatus;
|
|
createdAt: number;
|
|
reporter: {
|
|
userId: Id<"users">;
|
|
handle?: string | null;
|
|
displayName?: string | null;
|
|
};
|
|
triagedAt?: number | null;
|
|
triagedBy?: Id<"users"> | null;
|
|
triageNote?: string | null;
|
|
actionTaken?: PackageReportFinalAction | null;
|
|
};
|
|
type PackageAppealStatus = "open" | "accepted" | "rejected";
|
|
type PackageAppealListItem = {
|
|
appealId: Id<"packageAppeals">;
|
|
packageId: Id<"packages">;
|
|
releaseId: Id<"packageReleases">;
|
|
name: string;
|
|
displayName: string;
|
|
family: PackageFamily;
|
|
version: string;
|
|
message: string;
|
|
status: PackageAppealStatus;
|
|
createdAt: number;
|
|
submitter: {
|
|
userId: Id<"users">;
|
|
handle?: string | null;
|
|
displayName?: string | null;
|
|
};
|
|
resolvedAt?: number | null;
|
|
resolvedBy?: Id<"users"> | null;
|
|
resolutionNote?: string | null;
|
|
actionTaken?: PackageAppealFinalAction | null;
|
|
};
|
|
type PackageOfficialMigrationListItem = {
|
|
migrationId: Id<"officialPluginMigrations">;
|
|
bundledPluginId: string;
|
|
packageName: string;
|
|
packageId?: Id<"packages"> | null;
|
|
owner?: string | null;
|
|
sourceRepo?: string | null;
|
|
sourcePath?: string | null;
|
|
sourceCommit?: string | null;
|
|
phase: PackageOfficialMigrationPhase;
|
|
blockers: string[];
|
|
hostTargetsComplete: boolean;
|
|
scanClean: boolean;
|
|
moderationApproved: boolean;
|
|
runtimeBundlesReady: boolean;
|
|
notes?: string | null;
|
|
createdAt: number;
|
|
updatedAt: number;
|
|
};
|
|
type PackageModerationStatus = {
|
|
package: {
|
|
packageId: Id<"packages">;
|
|
name: string;
|
|
displayName: string;
|
|
family: PackageFamily;
|
|
channel: PackageChannel;
|
|
isOfficial: boolean;
|
|
reportCount: number;
|
|
lastReportedAt?: number | null;
|
|
scanStatus?: Doc<"packages">["scanStatus"];
|
|
};
|
|
latestRelease: {
|
|
releaseId: Id<"packageReleases">;
|
|
version: string;
|
|
artifactKind?: Doc<"packageReleases">["artifactKind"] | null;
|
|
scanStatus: PackageReleaseScanStatus;
|
|
moderationState?: NonNullable<Doc<"packageReleases">["manualModeration"]>["state"] | null;
|
|
moderationReason?: string | null;
|
|
blockedFromDownload: boolean;
|
|
reasons: string[];
|
|
createdAt: number;
|
|
} | null;
|
|
};
|
|
|
|
function getPackageOwnerKey(
|
|
pkg: Pick<PackageDoc, "ownerUserId" | "ownerPublisherId">,
|
|
options?: {
|
|
nextOwnerPublisherId?: Id<"publishers">;
|
|
ownerPublisher?: Doc<"publishers"> | null;
|
|
},
|
|
) {
|
|
if (pkg.ownerPublisherId) return `publisher:${pkg.ownerPublisherId}`;
|
|
if (
|
|
options?.nextOwnerPublisherId &&
|
|
options.ownerPublisher?.kind === "user" &&
|
|
options.ownerPublisher.linkedUserId === pkg.ownerUserId
|
|
) {
|
|
return `publisher:${options.nextOwnerPublisherId}`;
|
|
}
|
|
return `user:${pkg.ownerUserId}`;
|
|
}
|
|
|
|
function getRequestedPackageOwnerKey(args: {
|
|
ownerUserId: Id<"users">;
|
|
ownerPublisherId?: Id<"publishers">;
|
|
}) {
|
|
return args.ownerPublisherId ? `publisher:${args.ownerPublisherId}` : `user:${args.ownerUserId}`;
|
|
}
|
|
|
|
function derivePackagePublisherChannel(args: {
|
|
requestedChannel?: PackageChannel;
|
|
currentChannel?: PackageChannel;
|
|
currentIsReservation?: boolean;
|
|
publisherOfficial: boolean;
|
|
}) {
|
|
if (
|
|
args.currentChannel === "private" &&
|
|
!args.currentIsReservation &&
|
|
args.requestedChannel === undefined
|
|
) {
|
|
return "private";
|
|
}
|
|
if (args.publisherOfficial) {
|
|
return args.requestedChannel === "private" ? "private" : "official";
|
|
}
|
|
return args.requestedChannel ?? "community";
|
|
}
|
|
|
|
function isReservedPackagePlaceholder(pkg: PackageDoc | null | undefined) {
|
|
return Boolean(pkg && !pkg.latestReleaseId && !pkg.latestVersionSummary);
|
|
}
|
|
|
|
function shouldIncludePackageReportsInModerationQueue(
|
|
reportCount: number,
|
|
status: PackageModerationQueueStatus,
|
|
) {
|
|
return reportCount > 0 && (status === "open" || status === "all");
|
|
}
|
|
|
|
function shouldIncludeReleaseInModerationQueue(
|
|
release: Doc<"packageReleases">,
|
|
scanStatus: PackageReleaseScanStatus,
|
|
status: PackageModerationQueueStatus,
|
|
) {
|
|
const manualState = release.manualModeration?.state;
|
|
if (status === "manual") return Boolean(manualState);
|
|
if (status === "blocked") {
|
|
return manualState === "quarantined" || manualState === "revoked" || scanStatus === "malicious";
|
|
}
|
|
if (status === "all") return Boolean(manualState) || scanStatus !== "clean";
|
|
return (
|
|
manualState === "quarantined" ||
|
|
manualState === "revoked" ||
|
|
scanStatus === "suspicious" ||
|
|
scanStatus === "malicious" ||
|
|
scanStatus === "pending"
|
|
);
|
|
}
|
|
|
|
function getPackageReleaseCreatedAt(release: PackageReleaseModerationQueueDoc) {
|
|
return typeof release.createdAt === "number" ? release.createdAt : release._creationTime;
|
|
}
|
|
|
|
function toPackageModerationQueueItem(
|
|
pkg: Doc<"packages">,
|
|
release: PackageReleaseModerationQueueDoc,
|
|
): PackageModerationQueueItem {
|
|
const scanStatus = resolvePackageReleaseScanStatus(release);
|
|
const reportCount = pkg.reportCount ?? 0;
|
|
const source = (release.source && typeof release.source === "object" ? release.source : {}) as {
|
|
repo?: unknown;
|
|
commit?: unknown;
|
|
};
|
|
|
|
return {
|
|
packageId: pkg._id,
|
|
releaseId: release._id,
|
|
name: pkg.name,
|
|
displayName: pkg.displayName,
|
|
family: pkg.family,
|
|
channel: pkg.channel,
|
|
isOfficial: pkg.isOfficial,
|
|
version: release.version,
|
|
createdAt: getPackageReleaseCreatedAt(release),
|
|
artifactKind: release.artifactKind ?? null,
|
|
scanStatus,
|
|
moderationState: release.manualModeration?.state ?? null,
|
|
moderationReason: release.manualModeration?.reason ?? null,
|
|
sourceRepo: typeof source.repo === "string" ? source.repo : null,
|
|
sourceCommit: typeof source.commit === "string" ? source.commit : null,
|
|
reportCount,
|
|
lastReportedAt: pkg.lastReportedAt ?? null,
|
|
reasons: getPackageTrustReasons(release, scanStatus, reportCount),
|
|
};
|
|
}
|
|
|
|
type PackageBadgeKind = Doc<"packageBadges">["kind"];
|
|
type PackageDigestLike = Pick<
|
|
Doc<"packageSearchDigest">,
|
|
| "packageId"
|
|
| "name"
|
|
| "normalizedName"
|
|
| "displayName"
|
|
| "family"
|
|
| "runtimeId"
|
|
| "channel"
|
|
| "isOfficial"
|
|
| "ownerUserId"
|
|
| "ownerPublisherId"
|
|
| "summary"
|
|
| "icon"
|
|
| "ownerHandle"
|
|
| "ownerKind"
|
|
| "createdAt"
|
|
| "updatedAt"
|
|
| "latestVersion"
|
|
| "categories"
|
|
| "topics"
|
|
| "pluginCategoryTags"
|
|
| "verificationTier"
|
|
| "stats"
|
|
| "recommendedScore"
|
|
| "scanStatus"
|
|
| "softDeletedAt"
|
|
> & { pluginCategory?: string };
|
|
type PackageOwnerAccessRef = Pick<PackageDigestLike, "ownerUserId" | "ownerPublisherId"> &
|
|
Partial<Pick<PackageDigestLike, "ownerKind">>;
|
|
type PublicPageCursorState = {
|
|
cursor: string | null;
|
|
offset: number;
|
|
pageSize: number | null;
|
|
done: boolean;
|
|
mode?: "packages" | "digest";
|
|
sort?: "updated" | "downloads" | "recommended" | "installs" | "trending";
|
|
packageIndex?: "family-official-downloads";
|
|
};
|
|
const PUBLIC_PAGE_CURSOR_PREFIX = "pkgpage:";
|
|
type OfficialFirstPackageCategoryCursorState = {
|
|
phase: "official" | "community";
|
|
cursor: string | null;
|
|
};
|
|
type PublicPackageListPage = {
|
|
page: PublicPackageListItem[];
|
|
isDone: boolean;
|
|
continueCursor: string;
|
|
};
|
|
const STABLE_PACKAGE_DISCOVERY_CURSOR_PREFIX = "pkgstable:";
|
|
type StablePackageFamily = (typeof STABLE_PACKAGE_FAMILIES)[number];
|
|
type StablePackageDiscoverySourceState = { key: IndexKey | null; done: boolean };
|
|
type StablePackageDiscoveryCursorState = {
|
|
sort: "updated" | "created" | "downloads" | "recommended" | "installs";
|
|
sources: Record<StablePackageFamily, StablePackageDiscoverySourceState>;
|
|
};
|
|
const OFFICIAL_FIRST_PACKAGE_CATEGORY_CURSOR_PREFIX = "pkgofficialfirst:";
|
|
|
|
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 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);
|
|
}
|
|
|
|
type PublicPackageDoc = {
|
|
_id: Id<"packages">;
|
|
name: string;
|
|
displayName: string;
|
|
family: PackageFamily;
|
|
channel: PackageChannel;
|
|
isOfficial: boolean;
|
|
runtimeId?: string;
|
|
summary?: string;
|
|
icon: string | null;
|
|
tags: Record<string, Id<"packageReleases">>;
|
|
latestReleaseId?: Id<"packageReleases">;
|
|
latestVersion?: string | null;
|
|
categories?: string[];
|
|
topics?: string[];
|
|
compatibility?: Doc<"packages">["compatibility"];
|
|
verification?: Doc<"packages">["verification"];
|
|
artifact?: PackageArtifactSummary;
|
|
clawManifestSummary?: Doc<"packageReleases">["clawManifestSummary"];
|
|
scanStatus?: Doc<"packages">["scanStatus"];
|
|
stats: Doc<"packages">["stats"];
|
|
createdAt: number;
|
|
updatedAt: number;
|
|
};
|
|
|
|
type DashboardPackageListItem = {
|
|
_id: Id<"packages">;
|
|
name: string;
|
|
displayName: string;
|
|
family: PackageFamily;
|
|
channel: PackageChannel;
|
|
isOfficial: boolean;
|
|
runtimeId: string | null;
|
|
sourceRepo: string | null;
|
|
summary: string | null;
|
|
categories?: string[];
|
|
topics?: string[];
|
|
ownerUserId: Id<"users">;
|
|
ownerPublisherId?: Id<"publishers">;
|
|
latestVersion: string | null;
|
|
inspectorWarningCount: number;
|
|
topInspectorFinding?: {
|
|
message: string;
|
|
remediation?: string;
|
|
};
|
|
stats: Doc<"packages">["stats"];
|
|
verification: Doc<"packages">["verification"];
|
|
scanStatus: Doc<"packages">["scanStatus"];
|
|
createdAt: number;
|
|
updatedAt: number;
|
|
pendingReview?: true;
|
|
latestRelease: {
|
|
version: string;
|
|
createdAt: number;
|
|
vtStatus: string | null;
|
|
llmStatus: string | null;
|
|
staticScanStatus: "clean" | "suspicious" | "malicious" | null;
|
|
} | null;
|
|
};
|
|
|
|
function requiresPrivilegedPackageAccess(
|
|
digest: Pick<PackageDigestLike, "channel" | "scanStatus">,
|
|
) {
|
|
return digest.channel === "private" || isPackageBlockedFromPublic(digest.scanStatus);
|
|
}
|
|
|
|
async function viewerCanAccessPackageOwner(
|
|
ctx: DbReaderCtx,
|
|
digest: PackageOwnerAccessRef,
|
|
viewerUserId: Id<"users"> | undefined,
|
|
membershipCache?: Map<string, Promise<boolean>>,
|
|
) {
|
|
if (!viewerUserId) return false;
|
|
if (!digest.ownerPublisherId) return digest.ownerUserId === viewerUserId;
|
|
|
|
const ownerPublisherId = digest.ownerPublisherId;
|
|
const cacheKey = `${ownerPublisherId}:${digest.ownerUserId}`;
|
|
const cached = membershipCache?.get(cacheKey);
|
|
if (cached) return await cached;
|
|
|
|
const membershipPromise = (async () => {
|
|
const ownerPublisher = await ctx.db.get(ownerPublisherId);
|
|
return await canAccessPublisherOwnerScope(ctx, {
|
|
publisher: ownerPublisher,
|
|
userId: viewerUserId,
|
|
legacyOwnerUserId: digest.ownerUserId,
|
|
});
|
|
})();
|
|
membershipCache?.set(cacheKey, membershipPromise);
|
|
return await membershipPromise;
|
|
}
|
|
|
|
async function viewerCanManagePackageOwner(
|
|
ctx: DbReaderCtx,
|
|
digest: PackageOwnerAccessRef,
|
|
viewerUserId: Id<"users"> | undefined,
|
|
) {
|
|
if (!viewerUserId) return false;
|
|
if (!digest.ownerPublisherId) return digest.ownerUserId === viewerUserId;
|
|
|
|
const ownerPublisher = await ctx.db.get(digest.ownerPublisherId);
|
|
return await canAccessPublisherOwnerScope(ctx, {
|
|
publisher: ownerPublisher,
|
|
userId: viewerUserId,
|
|
allowedPublisherRoles: ["admin"],
|
|
legacyOwnerUserId: digest.ownerUserId,
|
|
});
|
|
}
|
|
|
|
async function canViewerReadPackage(
|
|
ctx: DbReaderCtx,
|
|
digest: Pick<PackageDigestLike, "channel" | "scanStatus" | "ownerUserId" | "ownerPublisherId"> &
|
|
Partial<Pick<PackageDigestLike, "ownerKind">>,
|
|
viewerUserId: Id<"users"> | undefined,
|
|
membershipCache?: Map<string, Promise<boolean>>,
|
|
) {
|
|
if (!requiresPrivilegedPackageAccess(digest)) return true;
|
|
const isPrivilegedViewer = await viewerCanAccessPackageOwner(
|
|
ctx,
|
|
digest,
|
|
viewerUserId,
|
|
membershipCache,
|
|
);
|
|
return (
|
|
(digest.channel !== "private" || isPrivilegedViewer) &&
|
|
(!isPackageBlockedFromPublic(digest.scanStatus) || isPrivilegedViewer)
|
|
);
|
|
}
|
|
|
|
function resolvePublicPackageScanStatus(
|
|
pkg: Pick<Doc<"packages">, "scanStatus">,
|
|
latestRelease?: Doc<"packageReleases"> | null,
|
|
) {
|
|
if (latestRelease && !latestRelease.softDeletedAt) {
|
|
const releaseScanStatus = resolvePackageReleaseScanStatus(latestRelease);
|
|
return releaseScanStatus === "not-run" ? pkg.scanStatus : releaseScanStatus;
|
|
}
|
|
return pkg.scanStatus;
|
|
}
|
|
|
|
function isPublishedPackageRelease(
|
|
release: Doc<"packageReleases"> | null | undefined,
|
|
): release is Doc<"packageReleases"> {
|
|
return Boolean(
|
|
release &&
|
|
!release.softDeletedAt &&
|
|
release.ownerDeletedAt === undefined &&
|
|
(release.publicationStatus === undefined || release.publicationStatus === "published"),
|
|
);
|
|
}
|
|
|
|
function hasNoPublishedPackageVersions(
|
|
pkg: Pick<Doc<"packages">, "latestReleaseId" | "latestVersionSummary" | "stats">,
|
|
) {
|
|
return !pkg.latestReleaseId && !pkg.latestVersionSummary && (pkg.stats?.versions ?? 0) <= 0;
|
|
}
|
|
|
|
function normalizePublicPackageSourcePath(sourcePath: unknown) {
|
|
if (typeof sourcePath !== "string") return undefined;
|
|
const trimmed = sourcePath.trim();
|
|
if (!trimmed || trimmed === ".") return undefined;
|
|
return trimmed.replace(/^\/+/, "").replace(/\/+$/, "") || undefined;
|
|
}
|
|
|
|
function getReleaseSourcePath(release?: Pick<Doc<"packageReleases">, "source"> | null) {
|
|
const source = release?.source;
|
|
if (!source || typeof source !== "object" || Array.isArray(source)) return undefined;
|
|
return normalizePublicPackageSourcePath((source as { path?: unknown }).path);
|
|
}
|
|
|
|
function resolvePublicPackageVerification(
|
|
pkg: Pick<Doc<"packages">, "verification" | "latestVersionSummary" | "scanStatus">,
|
|
latestRelease?: Doc<"packageReleases"> | null,
|
|
) {
|
|
const scanStatus = resolvePublicPackageScanStatus(pkg, latestRelease);
|
|
const source = pkg.verification ?? pkg.latestVersionSummary?.verification;
|
|
if (!source) return source;
|
|
const sourcePath = source.sourcePath ?? getReleaseSourcePath(latestRelease);
|
|
const verification = sourcePath ? { ...source, sourcePath } : source;
|
|
return scanStatus ? { ...verification, scanStatus } : verification;
|
|
}
|
|
|
|
function toPublicPackage(
|
|
pkg: Doc<"packages"> | null | undefined,
|
|
latestRelease?: Doc<"packageReleases"> | null,
|
|
): PublicPackageDoc | null {
|
|
if (!pkg || pkg.softDeletedAt) return null;
|
|
if (hasNoPublishedPackageVersions(pkg)) return null;
|
|
if (
|
|
latestRelease !== undefined &&
|
|
latestRelease &&
|
|
(latestRelease.publicationStatus === "pending" || latestRelease.publicationStatus === "blocked")
|
|
) {
|
|
return null;
|
|
}
|
|
const latestVersion =
|
|
latestRelease === undefined
|
|
? (pkg.latestVersionSummary?.version ?? null)
|
|
: isPublishedPackageRelease(latestRelease)
|
|
? latestRelease.version
|
|
: null;
|
|
const scanStatus = resolvePublicPackageScanStatus(pkg, latestRelease);
|
|
return {
|
|
_id: pkg._id,
|
|
name: pkg.name,
|
|
displayName: pkg.displayName,
|
|
family: pkg.family,
|
|
channel: pkg.channel,
|
|
isOfficial: pkg.isOfficial,
|
|
runtimeId: pkg.runtimeId,
|
|
summary: pkg.summary,
|
|
icon: pkg.icon ?? null,
|
|
tags: pkg.tags,
|
|
latestReleaseId: pkg.latestReleaseId,
|
|
latestVersion,
|
|
categories: pkg.categories,
|
|
topics: pkg.topics,
|
|
compatibility: pkg.compatibility,
|
|
verification: resolvePublicPackageVerification(pkg, latestRelease),
|
|
artifact:
|
|
latestRelease === undefined
|
|
? pkg.latestVersionSummary?.artifact
|
|
: isPublishedPackageRelease(latestRelease)
|
|
? packageArtifactSummary(latestRelease)
|
|
: undefined,
|
|
clawManifestSummary:
|
|
pkg.family === "claw" && isPublishedPackageRelease(latestRelease)
|
|
? latestRelease.clawManifestSummary
|
|
: undefined,
|
|
scanStatus,
|
|
stats: pkg.stats,
|
|
createdAt: pkg.createdAt,
|
|
updatedAt: pkg.updatedAt,
|
|
};
|
|
}
|
|
|
|
function toPublicPackageRelease(release: Doc<"packageReleases">, family: PackageFamily) {
|
|
// Package family owns this boundary; optional release metadata must not select a looser projection.
|
|
if (family === "claw") {
|
|
const sourcePath = release.verification?.sourcePath ?? getReleaseSourcePath(release);
|
|
return {
|
|
_id: release._id,
|
|
packageId: release.packageId,
|
|
version: release.version,
|
|
changelog: release.changelog,
|
|
summary: release.summary,
|
|
icon: release.icon,
|
|
distTags: release.distTags,
|
|
files: release.files.map((file) => ({
|
|
path: file.path,
|
|
size: file.size,
|
|
sha256: file.sha256,
|
|
contentType: file.contentType,
|
|
})),
|
|
integritySha256: release.integritySha256,
|
|
artifactKind: release.artifactKind,
|
|
clawpackSha256: release.clawpackSha256,
|
|
clawpackSize: release.clawpackSize,
|
|
clawpackFormat: release.clawpackFormat,
|
|
npmIntegrity: release.npmIntegrity,
|
|
npmShasum: release.npmShasum,
|
|
npmTarballName: release.npmTarballName,
|
|
npmUnpackedSize: release.npmUnpackedSize,
|
|
npmFileCount: release.npmFileCount,
|
|
clawManifestSummary: release.clawManifestSummary,
|
|
compatibility: release.compatibility,
|
|
runtimeId: release.runtimeId,
|
|
sourceRepo: release.sourceRepo,
|
|
verification:
|
|
release.verification && sourcePath
|
|
? { ...release.verification, sourcePath }
|
|
: release.verification,
|
|
sha256hash: release.sha256hash,
|
|
vtAnalysis: release.vtAnalysis,
|
|
skillSpectorAnalysis: release.skillSpectorAnalysis,
|
|
llmAnalysis: release.llmAnalysis,
|
|
staticScan: release.staticScan,
|
|
createdAt: release.createdAt,
|
|
};
|
|
}
|
|
const {
|
|
capabilities: _capabilities,
|
|
clawManifestSummary: _clawManifestSummary,
|
|
extractedClawManifest: _extractedClawManifest,
|
|
...publicRelease
|
|
} = release as Doc<"packageReleases"> & {
|
|
capabilities?: unknown;
|
|
extractedClawManifest?: unknown;
|
|
};
|
|
const sourcePath = release.verification?.sourcePath ?? getReleaseSourcePath(release);
|
|
if (!release.verification || !sourcePath) return publicRelease;
|
|
return {
|
|
...publicRelease,
|
|
verification: {
|
|
...release.verification,
|
|
sourcePath,
|
|
},
|
|
};
|
|
}
|
|
|
|
function toManagerPackageRelease(release: Doc<"packageReleases">, family: PackageFamily) {
|
|
return {
|
|
...toPublicPackageRelease(release, family),
|
|
softDeletedAt: release.softDeletedAt,
|
|
ownerDeletedAt: release.ownerDeletedAt,
|
|
};
|
|
}
|
|
|
|
async function paginatePublishedPackageReleases(
|
|
ctx: QueryCtx,
|
|
packageId: Id<"packages">,
|
|
paginationOpts: { cursor: string | null; numItems: number },
|
|
) {
|
|
const targetCount = Math.max(1, Math.min(paginationOpts.numItems, MAX_PUBLIC_LIST_PAGE_SIZE));
|
|
const page: Doc<"packageReleases">[] = [];
|
|
let cursor = paginationOpts.cursor;
|
|
let isDone = false;
|
|
let continueCursor = "";
|
|
let remainingScanBudget = Math.max(
|
|
targetCount,
|
|
Math.min(
|
|
MAX_PUBLIC_LIST_FILTER_SCAN_DOCUMENTS,
|
|
targetCount * MAX_PUBLIC_LIST_FILTER_SCAN_PAGES,
|
|
),
|
|
);
|
|
|
|
for (let scanPages = 0; scanPages < MAX_PUBLIC_LIST_FILTER_SCAN_PAGES; scanPages += 1) {
|
|
if (page.length >= targetCount || isDone || remainingScanBudget <= 0) break;
|
|
const pageSize = Math.min(remainingScanBudget, targetCount - page.length);
|
|
const result = await ctx.db
|
|
.query("packageReleases")
|
|
.withIndex("by_package_active_created", (q) =>
|
|
q.eq("packageId", packageId).eq("softDeletedAt", undefined),
|
|
)
|
|
.order("desc")
|
|
.paginate({ cursor, numItems: pageSize });
|
|
remainingScanBudget -= pageSize;
|
|
cursor = result.continueCursor;
|
|
continueCursor = result.continueCursor;
|
|
isDone = result.isDone;
|
|
for (const release of result.page) {
|
|
if (isPublishedPackageRelease(release)) {
|
|
page.push(release);
|
|
if (page.length >= targetCount) break;
|
|
}
|
|
}
|
|
if (result.page.length === 0) break;
|
|
}
|
|
|
|
return { page, isDone, continueCursor: isDone ? "" : continueCursor };
|
|
}
|
|
|
|
function packageArtifactSummary(
|
|
release: Pick<
|
|
Doc<"packageReleases">,
|
|
| "artifactKind"
|
|
| "clawpackSha256"
|
|
| "sha256hash"
|
|
| "clawpackSize"
|
|
| "clawpackFormat"
|
|
| "npmIntegrity"
|
|
| "npmShasum"
|
|
| "npmTarballName"
|
|
| "npmUnpackedSize"
|
|
| "npmFileCount"
|
|
>,
|
|
): PackageArtifactSummary {
|
|
if (release.artifactKind === "npm-pack") {
|
|
return {
|
|
kind: "npm-pack",
|
|
sha256: getPackageReleaseArtifactSha256(release) ?? undefined,
|
|
size: release.clawpackSize,
|
|
format: release.clawpackFormat ?? "tgz",
|
|
npmIntegrity: release.npmIntegrity,
|
|
npmShasum: release.npmShasum,
|
|
npmTarballName: release.npmTarballName,
|
|
npmUnpackedSize: release.npmUnpackedSize,
|
|
npmFileCount: release.npmFileCount,
|
|
};
|
|
}
|
|
return {
|
|
kind: "legacy-zip",
|
|
sha256: getPackageReleaseArtifactSha256(release) ?? undefined,
|
|
format: "zip",
|
|
};
|
|
}
|
|
|
|
function digestMatchesFilters(
|
|
digest: PackageDigestLike,
|
|
args: {
|
|
category?: string;
|
|
topic?: string;
|
|
excludedScanStatuses?: PackageListScanStatus[];
|
|
},
|
|
) {
|
|
if (!isClawFamilyPubliclyVisible(digest.family)) return false;
|
|
if (digest.scanStatus && args.excludedScanStatuses?.includes(digest.scanStatus)) return false;
|
|
if (args.category) {
|
|
if (digest.pluginCategory) {
|
|
if (digest.pluginCategory !== args.category) return false;
|
|
} else if (!(digest.pluginCategoryTags ?? []).includes(args.category)) {
|
|
return false;
|
|
}
|
|
}
|
|
if (args.topic && !getCatalogTopicSlugs(digest.topics).includes(args.topic)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function digestMatchesSearchFilters(
|
|
digest: PackageDigestLike,
|
|
args: {
|
|
family?: PackageFamily;
|
|
families?: PackageFamily[];
|
|
channel?: PackageChannel;
|
|
isOfficial?: boolean;
|
|
category?: string;
|
|
topic?: string;
|
|
excludedScanStatuses?: PackageListScanStatus[];
|
|
},
|
|
) {
|
|
if (args.family && digest.family !== args.family) return false;
|
|
if (args.families?.length && !args.families.includes(digest.family)) return false;
|
|
if (args.channel && digest.channel !== args.channel) return false;
|
|
if (typeof args.isOfficial === "boolean" && digest.isOfficial !== args.isOfficial) {
|
|
return false;
|
|
}
|
|
return digestMatchesFilters(digest, args);
|
|
}
|
|
|
|
function packageMatchesListFilters(
|
|
pkg: Doc<"packages">,
|
|
args: {
|
|
family?: PackageFamily;
|
|
channel?: PackageChannel;
|
|
isOfficial?: boolean;
|
|
category?: string;
|
|
topic?: string;
|
|
excludedScanStatuses?: PackageListScanStatus[];
|
|
},
|
|
) {
|
|
if (!isClawFamilyPubliclyVisible(pkg.family)) return false;
|
|
if (pkg.scanStatus && args.excludedScanStatuses?.includes(pkg.scanStatus)) return false;
|
|
if (args.family && pkg.family !== args.family) return false;
|
|
if (args.channel && pkg.channel !== args.channel) return false;
|
|
if (typeof args.isOfficial === "boolean" && pkg.isOfficial !== args.isOfficial) return false;
|
|
if (args.category) {
|
|
if (!(pkg.categories ?? []).includes(args.category)) return false;
|
|
}
|
|
if (args.topic && !getCatalogTopicSlugs(pkg.topics).includes(args.topic)) return false;
|
|
return true;
|
|
}
|
|
|
|
async function upsertPackageBadge(
|
|
ctx: MutationCtx,
|
|
packageId: Id<"packages">,
|
|
kind: PackageBadgeKind,
|
|
userId: Id<"users">,
|
|
at: number,
|
|
) {
|
|
const existing = await ctx.db
|
|
.query("packageBadges")
|
|
.withIndex("by_package_kind", (q) => q.eq("packageId", packageId).eq("kind", kind))
|
|
.unique();
|
|
if (existing) {
|
|
await ctx.db.patch(existing._id, { byUserId: userId, at });
|
|
return;
|
|
}
|
|
await ctx.db.insert("packageBadges", {
|
|
packageId,
|
|
kind,
|
|
byUserId: userId,
|
|
at,
|
|
});
|
|
}
|
|
|
|
async function removePackageBadge(
|
|
ctx: MutationCtx,
|
|
packageId: Id<"packages">,
|
|
kind: PackageBadgeKind,
|
|
) {
|
|
const existing = await ctx.db
|
|
.query("packageBadges")
|
|
.withIndex("by_package_kind", (q) => q.eq("packageId", packageId).eq("kind", kind))
|
|
.unique();
|
|
if (existing) await ctx.db.delete(existing._id);
|
|
}
|
|
|
|
function defaultPackageStats(): Doc<"packages">["stats"] {
|
|
return { downloads: 0, installs: 0, stars: 0, versions: 0 };
|
|
}
|
|
|
|
async function resolvePackageListStats(
|
|
ctx: DbReaderCtx,
|
|
digest: PackageDigestLike,
|
|
): Promise<Doc<"packages">["stats"]> {
|
|
const pkg = await ctx.db.get(digest.packageId);
|
|
return pkg?.stats ?? digest.stats ?? defaultPackageStats();
|
|
}
|
|
|
|
async function toPublicPackageListItem(
|
|
ctx: DbReaderCtx,
|
|
digest: PackageDigestLike,
|
|
featuredAt?: number,
|
|
): Promise<PublicPackageListItem> {
|
|
return {
|
|
name: digest.name,
|
|
displayName: digest.displayName,
|
|
family: digest.family,
|
|
runtimeId: digest.runtimeId ?? null,
|
|
channel: digest.channel,
|
|
isOfficial: digest.isOfficial,
|
|
summary: digest.summary ?? null,
|
|
icon: digest.icon ?? null,
|
|
ownerHandle: digest.ownerHandle || null,
|
|
createdAt: digest.createdAt,
|
|
updatedAt: digest.updatedAt,
|
|
latestVersion: digest.latestVersion ?? null,
|
|
categories: digest.categories,
|
|
topics: digest.topics,
|
|
...(featuredAt === undefined ? {} : { featuredAt }),
|
|
verificationTier: digest.verificationTier ?? null,
|
|
stats: await resolvePackageListStats(ctx, digest),
|
|
};
|
|
}
|
|
|
|
async function toPublicPackageListItemFromPackage(
|
|
ctx: DbReaderCtx,
|
|
pkg: Doc<"packages">,
|
|
): Promise<PublicPackageListItem> {
|
|
const catalogMetadata =
|
|
pkg.family === "code-plugin" || pkg.family === "bundle-plugin"
|
|
? extractPackageDigestFields(pkg)
|
|
: pkg;
|
|
const owner = toPublicPublisher(
|
|
await getOwnerPublisher(ctx, {
|
|
ownerPublisherId: pkg.ownerPublisherId,
|
|
ownerUserId: pkg.ownerUserId,
|
|
}),
|
|
);
|
|
return {
|
|
name: pkg.name,
|
|
displayName: pkg.displayName,
|
|
family: pkg.family,
|
|
runtimeId: pkg.runtimeId ?? null,
|
|
channel: pkg.channel,
|
|
isOfficial: pkg.isOfficial,
|
|
summary: pkg.summary ?? null,
|
|
icon: pkg.icon ?? null,
|
|
ownerHandle: owner?.handle ?? null,
|
|
createdAt: pkg.createdAt,
|
|
updatedAt: pkg.updatedAt,
|
|
latestVersion: pkg.latestVersionSummary?.version ?? null,
|
|
categories: catalogMetadata.categories,
|
|
topics: catalogMetadata.topics,
|
|
verificationTier: pkg.verification?.tier ?? null,
|
|
stats: pkg.stats,
|
|
};
|
|
}
|
|
|
|
async function toDashboardPackageListItem(
|
|
ctx: DbReaderCtx,
|
|
pkg: Doc<"packages">,
|
|
_viewerUserId: Id<"users">,
|
|
): Promise<DashboardPackageListItem | null> {
|
|
if (pkg.softDeletedAt) return null;
|
|
const latestRelease = pkg.latestReleaseId ? await ctx.db.get(pkg.latestReleaseId) : null;
|
|
const inspectorAttention = await getInspectorAttentionForRelease(ctx, pkg.latestReleaseId);
|
|
return {
|
|
_id: pkg._id,
|
|
name: pkg.name,
|
|
displayName: pkg.displayName,
|
|
family: pkg.family,
|
|
channel: pkg.channel,
|
|
isOfficial: pkg.isOfficial,
|
|
runtimeId: pkg.runtimeId ?? null,
|
|
sourceRepo: pkg.sourceRepo ?? null,
|
|
summary: pkg.summary ?? null,
|
|
ownerUserId: pkg.ownerUserId,
|
|
ownerPublisherId: pkg.ownerPublisherId,
|
|
latestVersion: pkg.latestVersionSummary?.version ?? null,
|
|
inspectorWarningCount: inspectorAttention.count,
|
|
topInspectorFinding: inspectorAttention.topFinding,
|
|
stats: pkg.stats,
|
|
verification: pkg.verification,
|
|
scanStatus: pkg.scanStatus,
|
|
createdAt: pkg.createdAt,
|
|
updatedAt: pkg.updatedAt,
|
|
pendingReview: pkg.scanStatus === "pending" ? true : undefined,
|
|
latestRelease:
|
|
latestRelease && !latestRelease.softDeletedAt
|
|
? {
|
|
version: latestRelease.version,
|
|
createdAt: latestRelease.createdAt,
|
|
vtStatus: latestRelease.vtAnalysis?.status ?? null,
|
|
llmStatus: latestRelease.llmAnalysis?.status ?? null,
|
|
staticScanStatus: latestRelease.staticScan?.status ?? null,
|
|
}
|
|
: null,
|
|
};
|
|
}
|
|
|
|
async function getInspectorAttentionForRelease(
|
|
ctx: DbReaderCtx,
|
|
latestReleaseId?: Id<"packageReleases">,
|
|
): Promise<{
|
|
count: number;
|
|
topFinding?: {
|
|
message: string;
|
|
remediation?: string;
|
|
};
|
|
}> {
|
|
if (!latestReleaseId) return { count: 0 };
|
|
const findings = await takeAuthorRemediationWarningsByRelease(ctx, latestReleaseId, 101);
|
|
const count = findings.length > 100 ? 100 : findings.length;
|
|
const top = findings[0];
|
|
if (!top) return { count };
|
|
const remediation = top.authorRemediation?.summary?.trim();
|
|
return {
|
|
count,
|
|
topFinding: {
|
|
message: top.message,
|
|
remediation: remediation || undefined,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function listDashboardPackagesForOwnerPublisher(
|
|
ctx: QueryCtx,
|
|
ownerPublisherId: Id<"publishers">,
|
|
viewerUserId: Id<"users">,
|
|
limit: number,
|
|
) {
|
|
const takeLimit = Math.min(limit * 5, 500);
|
|
const ownerPublisher = await ctx.db.get(ownerPublisherId);
|
|
const owner =
|
|
ownerPublisher?.kind === "user" && !ownerPublisher.linkedUserId
|
|
? await ctx.db.get(viewerUserId)
|
|
: null;
|
|
const isOwnDashboard =
|
|
(await canAccessPublisherOwnerScope(ctx, {
|
|
publisher: ownerPublisher,
|
|
userId: viewerUserId,
|
|
})) ||
|
|
(ownerPublisher?.kind === "user" &&
|
|
isPublisherActive(ownerPublisher) &&
|
|
!ownerPublisher.linkedUserId &&
|
|
owner?.personalPublisherId === ownerPublisherId);
|
|
if (!isOwnDashboard) return [];
|
|
|
|
const scopedEntries = await ctx.db
|
|
.query("packages")
|
|
.withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", ownerPublisherId))
|
|
.order("desc")
|
|
.take(takeLimit);
|
|
const legacyPersonalOwnerUserId =
|
|
ownerPublisher?.kind === "user"
|
|
? (ownerPublisher.linkedUserId ?? (isOwnDashboard ? viewerUserId : undefined))
|
|
: undefined;
|
|
const legacyEntries = legacyPersonalOwnerUserId
|
|
? await ctx.db
|
|
.query("packages")
|
|
.withIndex("by_owner", (q) => q.eq("ownerUserId", legacyPersonalOwnerUserId))
|
|
.order("desc")
|
|
.take(takeLimit)
|
|
: [];
|
|
|
|
const combined = [...scopedEntries, ...legacyEntries].filter(
|
|
(pkg, index, all) =>
|
|
!pkg.softDeletedAt &&
|
|
(!pkg.ownerPublisherId || pkg.ownerPublisherId === ownerPublisherId) &&
|
|
all.findIndex((candidate) => candidate._id === pkg._id) === index,
|
|
);
|
|
const limited = combined.slice(0, limit);
|
|
return (
|
|
await Promise.all(
|
|
limited.map(async (pkg) => await toDashboardPackageListItem(ctx, pkg, viewerUserId)),
|
|
)
|
|
).filter((pkg): pkg is DashboardPackageListItem => Boolean(pkg));
|
|
}
|
|
|
|
async function packageBelongsToOwnerUserDashboardScope(
|
|
ctx: Pick<QueryCtx, "db">,
|
|
pkg: Pick<Doc<"packages">, "ownerUserId" | "ownerPublisherId">,
|
|
ownerUserId: Id<"users">,
|
|
) {
|
|
if (pkg.ownerUserId !== ownerUserId) return false;
|
|
if (!pkg.ownerPublisherId) return true;
|
|
const ownerPublisher = await ctx.db.get(pkg.ownerPublisherId);
|
|
if (!ownerPublisher || !isPublisherActive(ownerPublisher) || ownerPublisher.kind !== "user") {
|
|
return false;
|
|
}
|
|
return ownerPublisher.linkedUserId ? ownerPublisher.linkedUserId === ownerUserId : true;
|
|
}
|
|
|
|
async function filterPackagesForOwnerUserDashboard(
|
|
ctx: Pick<QueryCtx, "db">,
|
|
packages: Doc<"packages">[],
|
|
ownerUserId: Id<"users">,
|
|
) {
|
|
const scoped = await Promise.all(
|
|
packages.map(async (pkg) =>
|
|
(await packageBelongsToOwnerUserDashboardScope(ctx, pkg, ownerUserId)) ? pkg : null,
|
|
),
|
|
);
|
|
return scoped.filter((pkg): pkg is Doc<"packages"> => Boolean(pkg));
|
|
}
|
|
|
|
async function listDashboardPackagesForOwnerUser(
|
|
ctx: QueryCtx,
|
|
ownerUserId: Id<"users">,
|
|
viewerUserId: Id<"users">,
|
|
limit: number,
|
|
) {
|
|
if (ownerUserId !== viewerUserId) return [];
|
|
const takeLimit = Math.min(limit * 5, 500);
|
|
const entries = await ctx.db
|
|
.query("packages")
|
|
.withIndex("by_owner", (q) => q.eq("ownerUserId", ownerUserId))
|
|
.order("desc")
|
|
.take(takeLimit);
|
|
const scoped = await filterPackagesForOwnerUserDashboard(ctx, entries, ownerUserId);
|
|
const filtered = scoped.filter((pkg) => !pkg.softDeletedAt).slice(0, limit);
|
|
return (
|
|
await Promise.all(
|
|
filtered.map(async (pkg) => await toDashboardPackageListItem(ctx, pkg, viewerUserId)),
|
|
)
|
|
).filter((pkg): pkg is DashboardPackageListItem => Boolean(pkg));
|
|
}
|
|
|
|
function encodePublicPageCursor(state: PublicPageCursorState) {
|
|
if (state.done && state.offset === 0) return "";
|
|
return `${PUBLIC_PAGE_CURSOR_PREFIX}${JSON.stringify(state)}`;
|
|
}
|
|
|
|
function decodePublicPageCursor(raw: string | null | undefined): PublicPageCursorState {
|
|
if (!raw) return { cursor: null, offset: 0, pageSize: null, done: false };
|
|
if (!raw.startsWith(PUBLIC_PAGE_CURSOR_PREFIX)) {
|
|
return { cursor: raw, offset: 0, pageSize: null, done: false };
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(
|
|
raw.slice(PUBLIC_PAGE_CURSOR_PREFIX.length),
|
|
) as Partial<PublicPageCursorState>;
|
|
return {
|
|
cursor: typeof parsed.cursor === "string" ? parsed.cursor : null,
|
|
offset: typeof parsed.offset === "number" && parsed.offset > 0 ? parsed.offset : 0,
|
|
pageSize: typeof parsed.pageSize === "number" && parsed.pageSize > 0 ? parsed.pageSize : null,
|
|
done: parsed.done === true,
|
|
mode: parsed.mode === "packages" || parsed.mode === "digest" ? parsed.mode : undefined,
|
|
sort:
|
|
parsed.sort === "updated" ||
|
|
parsed.sort === "downloads" ||
|
|
parsed.sort === "recommended" ||
|
|
parsed.sort === "installs" ||
|
|
parsed.sort === "trending"
|
|
? parsed.sort
|
|
: undefined,
|
|
packageIndex:
|
|
parsed.packageIndex === "family-official-downloads" ? parsed.packageIndex : undefined,
|
|
};
|
|
} catch {
|
|
return { cursor: null, offset: 0, pageSize: null, done: false };
|
|
}
|
|
}
|
|
|
|
function encodeOfficialFirstPackageCategoryCursor(state: OfficialFirstPackageCategoryCursorState) {
|
|
return `${OFFICIAL_FIRST_PACKAGE_CATEGORY_CURSOR_PREFIX}${JSON.stringify(state)}`;
|
|
}
|
|
|
|
function decodeOfficialFirstPackageCategoryCursor(
|
|
raw: string | null | undefined,
|
|
): OfficialFirstPackageCategoryCursorState {
|
|
if (!raw) return { phase: "official", cursor: null };
|
|
if (!raw.startsWith(OFFICIAL_FIRST_PACKAGE_CATEGORY_CURSOR_PREFIX)) {
|
|
return { phase: "community", cursor: raw };
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(
|
|
raw.slice(OFFICIAL_FIRST_PACKAGE_CATEGORY_CURSOR_PREFIX.length),
|
|
) as Partial<OfficialFirstPackageCategoryCursorState>;
|
|
return {
|
|
phase: parsed.phase === "community" ? "community" : "official",
|
|
cursor: typeof parsed.cursor === "string" ? parsed.cursor : null,
|
|
};
|
|
} catch {
|
|
return { phase: "official", cursor: null };
|
|
}
|
|
}
|
|
|
|
async function getOptionalViewerUserId(ctx: QueryCtx | MutationCtx) {
|
|
return await getOptionalActiveAuthUserId(ctx);
|
|
}
|
|
|
|
const EXPLORATORY_SEARCH_MIN_TOKEN_LENGTH = 3;
|
|
|
|
type PackageSearchMatch = {
|
|
rankTier: number;
|
|
score: number;
|
|
};
|
|
|
|
function packageSearchMatch(
|
|
digest: PackageDigestLike,
|
|
queryText: string,
|
|
): PackageSearchMatch | null {
|
|
const needle = queryText.toLowerCase();
|
|
const queryTokens = tokenize(queryText);
|
|
if (queryTokens.length === 0) return null;
|
|
const normalized = digest.normalizedName.toLowerCase();
|
|
const display = digest.displayName.toLowerCase();
|
|
const runtimeId = digest.runtimeId?.toLowerCase() ?? "";
|
|
const ownerHandle = digest.ownerHandle?.toLowerCase() ?? "";
|
|
const nameTokens = tokenize(normalized);
|
|
const displayTokens = tokenize(display);
|
|
const runtimeTokens = tokenize(runtimeId);
|
|
const ownerHandleTokens = tokenize(ownerHandle);
|
|
let score = 0;
|
|
let rankTier = Number.POSITIVE_INFINITY;
|
|
|
|
const setMatch = (tier: number, boost: number) => {
|
|
score += boost;
|
|
rankTier = Math.min(rankTier, tier);
|
|
};
|
|
|
|
if (normalized === needle) setMatch(0, 200);
|
|
else if (normalized.startsWith(needle)) setMatch(1, 120);
|
|
else if (normalized.includes(needle)) setMatch(1, 80);
|
|
|
|
if (display === needle) setMatch(0, 150);
|
|
else if (display.startsWith(needle)) setMatch(1, 70);
|
|
else if (display.includes(needle)) setMatch(1, 40);
|
|
|
|
if (runtimeId === needle) setMatch(0, 180);
|
|
else if (runtimeId.startsWith(needle)) setMatch(1, 90);
|
|
else if (runtimeId.includes(needle)) setMatch(1, 45);
|
|
|
|
if (ownerHandle === needle || `@${ownerHandle}` === needle) setMatch(1, 60);
|
|
else if (ownerHandle.startsWith(needle) || `@${ownerHandle}`.startsWith(needle)) setMatch(1, 35);
|
|
else if (ownerHandle.includes(needle)) setMatch(1, 20);
|
|
|
|
if (
|
|
matchesAllTokens(
|
|
queryTokens,
|
|
[...nameTokens, ...displayTokens, ...runtimeTokens, ...ownerHandleTokens],
|
|
(a, b) => a === b,
|
|
)
|
|
) {
|
|
setMatch(1, 65);
|
|
} else if (
|
|
matchesAllTokens(
|
|
queryTokens,
|
|
[...nameTokens, ...displayTokens, ...runtimeTokens, ...ownerHandleTokens],
|
|
(a, b) => a.startsWith(b),
|
|
)
|
|
) {
|
|
setMatch(1, 35);
|
|
}
|
|
|
|
const taxonomyQuery = normalizeCatalogTopic(queryText);
|
|
const categories = (digest.pluginCategoryTags ?? []).filter(
|
|
(category) => category !== INTERNAL_UNCATEGORIZED_CATEGORY,
|
|
);
|
|
const topicSlugs = getCatalogTopicSlugs(digest.topics);
|
|
if (taxonomyQuery && (categories.includes(taxonomyQuery) || topicSlugs.includes(taxonomyQuery))) {
|
|
setMatch(2, 25);
|
|
}
|
|
if (
|
|
matchesExploratoryTokenPrefixes(
|
|
queryTokens,
|
|
[...categories, ...(digest.topics ?? [])],
|
|
EXPLORATORY_SEARCH_MIN_TOKEN_LENGTH,
|
|
)
|
|
) {
|
|
setMatch(2, 20);
|
|
}
|
|
|
|
if (
|
|
matchesExploratoryTokenPrefixes(
|
|
queryTokens,
|
|
[digest.summary],
|
|
EXPLORATORY_SEARCH_MIN_TOKEN_LENGTH,
|
|
)
|
|
) {
|
|
setMatch(3, 20);
|
|
}
|
|
if (!Number.isFinite(rankTier)) return null;
|
|
return { rankTier, score };
|
|
}
|
|
|
|
function packageTrustSignals(pkg: {
|
|
isOfficial: boolean;
|
|
verificationTier?: PackageDigestLike["verificationTier"] | null;
|
|
stats?: { downloads: number; installs: number; stars: number } | null;
|
|
}): SearchTrustSignals {
|
|
return {
|
|
isOfficial: pkg.isOfficial,
|
|
verificationTier: pkg.verificationTier,
|
|
downloads: pkg.stats?.downloads,
|
|
installs: pkg.stats?.installs,
|
|
};
|
|
}
|
|
|
|
function comparePackageSearchMatches<
|
|
T extends PackageSearchMatch & {
|
|
package: {
|
|
isOfficial: boolean;
|
|
updatedAt: number;
|
|
verificationTier?: PackageDigestLike["verificationTier"] | null;
|
|
stats?: { downloads: number; installs: number; stars: number } | null;
|
|
};
|
|
},
|
|
>(a: T, b: T) {
|
|
return (
|
|
compareRankedSearchKeys(
|
|
rankedSearchKey(a, packageTrustSignals(a.package)),
|
|
rankedSearchKey(b, packageTrustSignals(b.package)),
|
|
) ||
|
|
Number(b.package.isOfficial) - Number(a.package.isOfficial) ||
|
|
verificationRank(b.package.verificationTier) - verificationRank(a.package.verificationTier) ||
|
|
(b.package.stats?.stars ?? 0) - (a.package.stats?.stars ?? 0) ||
|
|
(b.package.stats?.installs ?? 0) - (a.package.stats?.installs ?? 0) ||
|
|
(b.package.stats?.downloads ?? 0) - (a.package.stats?.downloads ?? 0) ||
|
|
b.package.updatedAt - a.package.updatedAt
|
|
);
|
|
}
|
|
|
|
function toPublicPackageSearchEntry(
|
|
entry: PackageSearchMatch & { package: PublicPackageListItem },
|
|
) {
|
|
return {
|
|
score: entry.score,
|
|
package: entry.package,
|
|
};
|
|
}
|
|
|
|
function prefixUpperBound(value: string) {
|
|
return `${value}\uffff`;
|
|
}
|
|
|
|
function maybeNormalizePackageQuery(value: string) {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return null;
|
|
try {
|
|
return normalizePackageName(trimmed);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function resolveDirectPackageSearchDigests(
|
|
ctx: DbReaderCtx,
|
|
queryText: string,
|
|
family?: PackageFamily,
|
|
): Promise<PackageDigestLike[]> {
|
|
const normalizedQuery = maybeNormalizePackageQuery(queryText);
|
|
const topicQuery = normalizeCatalogTopic(queryText);
|
|
const categoryQuery =
|
|
topicQuery !== INTERNAL_UNCATEGORIZED_CATEGORY && isPluginCategorySlug(topicQuery)
|
|
? topicQuery
|
|
: undefined;
|
|
const queryTokens = tokenize(queryText).filter((token) => token.length > 1);
|
|
const runtimePrefix = queryTokens.length === 1 ? queryTokens[0] : queryText;
|
|
const ownerHandlePrefix = queryTokens.length === 1 ? queryTokens[0] : null;
|
|
const [
|
|
nameDigests,
|
|
runtimeDigests,
|
|
displayNameDigests,
|
|
ownerHandleDigests,
|
|
exactTopicDigests,
|
|
categoryDigests,
|
|
] = await Promise.all([
|
|
normalizedQuery
|
|
? family
|
|
? ctx.db
|
|
.query("packageSearchDigest")
|
|
.withIndex("by_active_family_normalized_name", (q) =>
|
|
q
|
|
.eq("softDeletedAt", undefined)
|
|
.eq("family", family)
|
|
.gte("normalizedName", normalizedQuery)
|
|
.lt("normalizedName", prefixUpperBound(normalizedQuery)),
|
|
)
|
|
.take(MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES)
|
|
: ctx.db
|
|
.query("packageSearchDigest")
|
|
.withIndex("by_active_normalized_name", (q) =>
|
|
q
|
|
.eq("softDeletedAt", undefined)
|
|
.gte("normalizedName", normalizedQuery)
|
|
.lt("normalizedName", prefixUpperBound(normalizedQuery)),
|
|
)
|
|
.take(MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES)
|
|
: Promise.resolve([]),
|
|
runtimePrefix
|
|
? family
|
|
? ctx.db
|
|
.query("packageSearchDigest")
|
|
.withIndex("by_active_family_runtime_id", (q) =>
|
|
q
|
|
.eq("softDeletedAt", undefined)
|
|
.eq("family", family)
|
|
.gte("runtimeId", runtimePrefix)
|
|
.lt("runtimeId", prefixUpperBound(runtimePrefix)),
|
|
)
|
|
.take(MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES)
|
|
: ctx.db
|
|
.query("packageSearchDigest")
|
|
.withIndex("by_active_runtime_id", (q) =>
|
|
q
|
|
.eq("softDeletedAt", undefined)
|
|
.gte("runtimeId", runtimePrefix)
|
|
.lt("runtimeId", prefixUpperBound(runtimePrefix)),
|
|
)
|
|
.take(MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES)
|
|
: Promise.resolve([]),
|
|
ctx.db
|
|
.query("packageSearchDigest")
|
|
.withSearchIndex("search_by_display_name", (q) => {
|
|
const search = q.search("displayName", queryText).eq("softDeletedAt", undefined);
|
|
return family ? search.eq("family", family) : search;
|
|
})
|
|
.take(MAX_DIRECT_PACKAGE_FULL_TEXT_CANDIDATES),
|
|
ownerHandlePrefix
|
|
? family
|
|
? ctx.db
|
|
.query("packageSearchDigest")
|
|
.withIndex("by_active_family_owner_handle", (q) =>
|
|
q
|
|
.eq("softDeletedAt", undefined)
|
|
.eq("family", family)
|
|
.gte("ownerHandle", ownerHandlePrefix)
|
|
.lt("ownerHandle", prefixUpperBound(ownerHandlePrefix)),
|
|
)
|
|
.take(MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES)
|
|
: ctx.db
|
|
.query("packageSearchDigest")
|
|
.withIndex("by_active_owner_handle", (q) =>
|
|
q
|
|
.eq("softDeletedAt", undefined)
|
|
.gte("ownerHandle", ownerHandlePrefix)
|
|
.lt("ownerHandle", prefixUpperBound(ownerHandlePrefix)),
|
|
)
|
|
.take(MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES)
|
|
: Promise.resolve([]),
|
|
topicQuery
|
|
? ctx.db
|
|
.query("packageTopicSearchDigest")
|
|
.withIndex(family ? "by_active_family_topic_updated" : "by_active_topic_updated", (q) =>
|
|
family
|
|
? q.eq("softDeletedAt", undefined).eq("family", family).eq("topic", topicQuery)
|
|
: q.eq("softDeletedAt", undefined).eq("topic", topicQuery),
|
|
)
|
|
.order("desc")
|
|
.take(MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES)
|
|
: Promise.resolve([]),
|
|
categoryQuery
|
|
? ctx.db
|
|
.query("packagePluginCategorySearchDigest")
|
|
.withIndex(
|
|
family ? "by_active_family_category_updated" : "by_active_category_updated",
|
|
(q) =>
|
|
family
|
|
? q
|
|
.eq("softDeletedAt", undefined)
|
|
.eq("family", family)
|
|
.eq("pluginCategory", categoryQuery)
|
|
: q.eq("softDeletedAt", undefined).eq("pluginCategory", categoryQuery),
|
|
)
|
|
.order("desc")
|
|
.take(MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES)
|
|
: Promise.resolve([]),
|
|
]);
|
|
const prefixTopicDigests =
|
|
topicQuery && exactTopicDigests.length < MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES
|
|
? await ctx.db
|
|
.query("packageTopicSearchDigest")
|
|
.withIndex(family ? "by_active_family_topic_updated" : "by_active_topic_updated", (q) =>
|
|
family
|
|
? q
|
|
.eq("softDeletedAt", undefined)
|
|
.eq("family", family)
|
|
.gte("topic", topicQuery)
|
|
.lt("topic", prefixUpperBound(topicQuery))
|
|
: q
|
|
.eq("softDeletedAt", undefined)
|
|
.gte("topic", topicQuery)
|
|
.lt("topic", prefixUpperBound(topicQuery)),
|
|
)
|
|
.order("desc")
|
|
.take(MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES - exactTopicDigests.length)
|
|
: [];
|
|
return [
|
|
...nameDigests,
|
|
...runtimeDigests,
|
|
...displayNameDigests,
|
|
...ownerHandleDigests,
|
|
...exactTopicDigests,
|
|
...prefixTopicDigests,
|
|
...categoryDigests,
|
|
].filter(
|
|
(digest, index, all) =>
|
|
all.findIndex((candidate) => candidate?.packageId === digest?.packageId) === index,
|
|
) as PackageDigestLike[];
|
|
}
|
|
|
|
function buildPackageDigestQuery(
|
|
ctx: DbReaderCtx,
|
|
args: {
|
|
family?: PackageFamily;
|
|
channel?: PackageChannel;
|
|
isOfficial?: boolean;
|
|
},
|
|
) {
|
|
const family = args.family;
|
|
const channel = args.channel;
|
|
const isOfficial = args.isOfficial;
|
|
|
|
if (family && channel) {
|
|
return ctx.db
|
|
.query("packageSearchDigest")
|
|
.withIndex("by_active_family_channel_updated", (q) =>
|
|
q.eq("softDeletedAt", undefined).eq("family", family).eq("channel", channel),
|
|
);
|
|
}
|
|
if (family && typeof isOfficial === "boolean") {
|
|
return ctx.db
|
|
.query("packageSearchDigest")
|
|
.withIndex("by_active_family_official_updated", (q) =>
|
|
q.eq("softDeletedAt", undefined).eq("family", family).eq("isOfficial", isOfficial),
|
|
);
|
|
}
|
|
if (family) {
|
|
return ctx.db
|
|
.query("packageSearchDigest")
|
|
.withIndex("by_active_family_updated", (q) =>
|
|
q.eq("softDeletedAt", undefined).eq("family", family),
|
|
);
|
|
}
|
|
if (channel && typeof isOfficial === "boolean") {
|
|
return ctx.db
|
|
.query("packageSearchDigest")
|
|
.withIndex("by_active_channel_official_updated", (q) =>
|
|
q.eq("softDeletedAt", undefined).eq("channel", channel).eq("isOfficial", isOfficial),
|
|
);
|
|
}
|
|
if (channel) {
|
|
return ctx.db
|
|
.query("packageSearchDigest")
|
|
.withIndex("by_active_channel_updated", (q) =>
|
|
q.eq("softDeletedAt", undefined).eq("channel", channel),
|
|
);
|
|
}
|
|
if (typeof isOfficial === "boolean") {
|
|
return ctx.db
|
|
.query("packageSearchDigest")
|
|
.withIndex("by_active_official_updated", (q) =>
|
|
q.eq("softDeletedAt", undefined).eq("isOfficial", isOfficial),
|
|
);
|
|
}
|
|
return ctx.db
|
|
.query("packageSearchDigest")
|
|
.withIndex("by_active_updated", (q) => q.eq("softDeletedAt", undefined));
|
|
}
|
|
|
|
function buildPackagePluginCategoryDigestQuery(
|
|
ctx: DbReaderCtx,
|
|
args: {
|
|
category: PluginCategorySlug;
|
|
family?: PackageFamily;
|
|
channel?: PackageChannel;
|
|
isOfficial?: boolean;
|
|
sort?: "updated" | "downloads" | "recommended" | "installs" | "trending";
|
|
},
|
|
) {
|
|
const family = args.family;
|
|
const channel = args.channel;
|
|
const isOfficial = args.isOfficial;
|
|
if (args.sort === "downloads") {
|
|
if (family && channel && typeof isOfficial === "boolean") {
|
|
return ctx.db
|
|
.query("packagePluginCategorySearchDigest")
|
|
.withIndex("by_active_family_channel_official_category_downloads", (q) =>
|
|
q
|
|
.eq("softDeletedAt", undefined)
|
|
.eq("family", family)
|
|
.eq("channel", channel)
|
|
.eq("isOfficial", isOfficial)
|
|
.eq("pluginCategory", args.category),
|
|
);
|
|
}
|
|
if (family && channel) {
|
|
return ctx.db
|
|
.query("packagePluginCategorySearchDigest")
|
|
.withIndex("by_active_family_channel_category_downloads", (q) =>
|
|
q
|
|
.eq("softDeletedAt", undefined)
|
|
.eq("family", family)
|
|
.eq("channel", channel)
|
|
.eq("pluginCategory", args.category),
|
|
);
|
|
}
|
|
if (family && typeof isOfficial === "boolean") {
|
|
return ctx.db
|
|
.query("packagePluginCategorySearchDigest")
|
|
.withIndex("by_active_family_official_category_downloads", (q) =>
|
|
q
|
|
.eq("softDeletedAt", undefined)
|
|
.eq("family", family)
|
|
.eq("isOfficial", isOfficial)
|
|
.eq("pluginCategory", args.category),
|
|
);
|
|
}
|
|
if (channel && typeof isOfficial === "boolean") {
|
|
return ctx.db
|
|
.query("packagePluginCategorySearchDigest")
|
|
.withIndex("by_active_channel_official_category_downloads", (q) =>
|
|
q
|
|
.eq("softDeletedAt", undefined)
|
|
.eq("channel", channel)
|
|
.eq("isOfficial", isOfficial)
|
|
.eq("pluginCategory", args.category),
|
|
);
|
|
}
|
|
if (family) {
|
|
return ctx.db
|
|
.query("packagePluginCategorySearchDigest")
|
|
.withIndex("by_active_family_category_downloads", (q) =>
|
|
q.eq("softDeletedAt", undefined).eq("family", family).eq("pluginCategory", args.category),
|
|
);
|
|
}
|
|
if (channel) {
|
|
return ctx.db
|
|
.query("packagePluginCategorySearchDigest")
|
|
.withIndex("by_active_channel_category_downloads", (q) =>
|
|
q
|
|
.eq("softDeletedAt", undefined)
|
|
.eq("channel", channel)
|
|
.eq("pluginCategory", args.category),
|
|
);
|
|
}
|
|
if (typeof isOfficial === "boolean") {
|
|
return ctx.db
|
|
.query("packagePluginCategorySearchDigest")
|
|
.withIndex("by_active_official_category_downloads", (q) =>
|
|
q
|
|
.eq("softDeletedAt", undefined)
|
|
.eq("isOfficial", isOfficial)
|
|
.eq("pluginCategory", args.category),
|
|
);
|
|
}
|
|
return ctx.db
|
|
.query("packagePluginCategorySearchDigest")
|
|
.withIndex("by_active_category_downloads", (q) =>
|
|
q.eq("softDeletedAt", undefined).eq("pluginCategory", args.category),
|
|
);
|
|
}
|
|
if (args.sort === "installs") {
|
|
if (family && typeof isOfficial === "boolean") {
|
|
return ctx.db
|
|
.query("packagePluginCategorySearchDigest")
|
|
.withIndex("by_active_family_official_category_installs", (q) =>
|
|
q
|
|
.eq("softDeletedAt", undefined)
|
|
.eq("family", family)
|
|
.eq("isOfficial", isOfficial)
|
|
.eq("pluginCategory", args.category),
|
|
);
|
|
}
|
|
if (family) {
|
|
return ctx.db
|
|
.query("packagePluginCategorySearchDigest")
|
|
.withIndex("by_active_family_category_installs", (q) =>
|
|
q.eq("softDeletedAt", undefined).eq("family", family).eq("pluginCategory", args.category),
|
|
);
|
|
}
|
|
if (typeof isOfficial === "boolean") {
|
|
return ctx.db
|
|
.query("packagePluginCategorySearchDigest")
|
|
.withIndex("by_active_official_category_installs", (q) =>
|
|
q
|
|
.eq("softDeletedAt", undefined)
|
|
.eq("isOfficial", isOfficial)
|
|
.eq("pluginCategory", args.category),
|
|
);
|
|
}
|
|
return ctx.db
|
|
.query("packagePluginCategorySearchDigest")
|
|
.withIndex("by_active_category_installs", (q) =>
|
|
q.eq("softDeletedAt", undefined).eq("pluginCategory", args.category),
|
|
);
|
|
}
|
|
if (args.sort === "recommended") {
|
|
if (family && typeof isOfficial === "boolean") {
|
|
return ctx.db
|
|
.query("packagePluginCategorySearchDigest")
|
|
.withIndex("by_active_family_official_category_recommended_score", (q) =>
|
|
q
|
|
.eq("softDeletedAt", undefined)
|
|
.eq("family", family)
|
|
.eq("isOfficial", isOfficial)
|
|
.eq("pluginCategory", args.category),
|
|
);
|
|
}
|
|
if (family) {
|
|
return ctx.db
|
|
.query("packagePluginCategorySearchDigest")
|
|
.withIndex("by_active_family_category_recommended_score", (q) =>
|
|
q.eq("softDeletedAt", undefined).eq("family", family).eq("pluginCategory", args.category),
|
|
);
|
|
}
|
|
if (typeof isOfficial === "boolean") {
|
|
return ctx.db
|
|
.query("packagePluginCategorySearchDigest")
|
|
.withIndex("by_active_official_category_recommended_score", (q) =>
|
|
q
|
|
.eq("softDeletedAt", undefined)
|
|
.eq("isOfficial", isOfficial)
|
|
.eq("pluginCategory", args.category),
|
|
);
|
|
}
|
|
return ctx.db
|
|
.query("packagePluginCategorySearchDigest")
|
|
.withIndex("by_active_category_recommended_score", (q) =>
|
|
q.eq("softDeletedAt", undefined).eq("pluginCategory", args.category),
|
|
);
|
|
}
|
|
if (family && channel) {
|
|
return ctx.db
|
|
.query("packagePluginCategorySearchDigest")
|
|
.withIndex("by_active_family_channel_category_updated", (q) =>
|
|
q
|
|
.eq("softDeletedAt", undefined)
|
|
.eq("family", family)
|
|
.eq("channel", channel)
|
|
.eq("pluginCategory", args.category),
|
|
);
|
|
}
|
|
if (family && typeof isOfficial === "boolean") {
|
|
return ctx.db
|
|
.query("packagePluginCategorySearchDigest")
|
|
.withIndex("by_active_family_official_category_updated", (q) =>
|
|
q
|
|
.eq("softDeletedAt", undefined)
|
|
.eq("family", family)
|
|
.eq("isOfficial", isOfficial)
|
|
.eq("pluginCategory", args.category),
|
|
);
|
|
}
|
|
if (channel && typeof isOfficial === "boolean") {
|
|
return ctx.db
|
|
.query("packagePluginCategorySearchDigest")
|
|
.withIndex("by_active_channel_official_category_updated", (q) =>
|
|
q
|
|
.eq("softDeletedAt", undefined)
|
|
.eq("channel", channel)
|
|
.eq("isOfficial", isOfficial)
|
|
.eq("pluginCategory", args.category),
|
|
);
|
|
}
|
|
if (family) {
|
|
return ctx.db
|
|
.query("packagePluginCategorySearchDigest")
|
|
.withIndex("by_active_family_category_updated", (q) =>
|
|
q.eq("softDeletedAt", undefined).eq("family", family).eq("pluginCategory", args.category),
|
|
);
|
|
}
|
|
if (channel) {
|
|
return ctx.db
|
|
.query("packagePluginCategorySearchDigest")
|
|
.withIndex("by_active_channel_category_updated", (q) =>
|
|
q.eq("softDeletedAt", undefined).eq("channel", channel).eq("pluginCategory", args.category),
|
|
);
|
|
}
|
|
if (typeof isOfficial === "boolean") {
|
|
return ctx.db
|
|
.query("packagePluginCategorySearchDigest")
|
|
.withIndex("by_active_official_category_updated", (q) =>
|
|
q
|
|
.eq("softDeletedAt", undefined)
|
|
.eq("isOfficial", isOfficial)
|
|
.eq("pluginCategory", args.category),
|
|
);
|
|
}
|
|
return ctx.db
|
|
.query("packagePluginCategorySearchDigest")
|
|
.withIndex("by_active_category_updated", (q) =>
|
|
q.eq("softDeletedAt", undefined).eq("pluginCategory", args.category),
|
|
);
|
|
}
|
|
|
|
function buildPackageTopicDigestQuery(
|
|
ctx: DbReaderCtx,
|
|
args: {
|
|
topic: string;
|
|
family?: PackageFamily;
|
|
channel?: PackageChannel;
|
|
isOfficial?: boolean;
|
|
sort?: "updated" | "downloads" | "recommended" | "installs" | "trending";
|
|
},
|
|
) {
|
|
const family = args.family;
|
|
const channel = args.channel;
|
|
const isOfficial = args.isOfficial;
|
|
if (args.sort === "downloads") {
|
|
if (family && channel && typeof isOfficial === "boolean") {
|
|
return ctx.db
|
|
.query("packageTopicSearchDigest")
|
|
.withIndex("by_active_family_channel_official_topic_downloads", (q) =>
|
|
q
|
|
.eq("softDeletedAt", undefined)
|
|
.eq("family", family)
|
|
.eq("channel", channel)
|
|
.eq("isOfficial", isOfficial)
|
|
.eq("topic", args.topic),
|
|
);
|
|
}
|
|
if (family && channel) {
|
|
return ctx.db
|
|
.query("packageTopicSearchDigest")
|
|
.withIndex("by_active_family_channel_topic_downloads", (q) =>
|
|
q
|
|
.eq("softDeletedAt", undefined)
|
|
.eq("family", family)
|
|
.eq("channel", channel)
|
|
.eq("topic", args.topic),
|
|
);
|
|
}
|
|
if (family && typeof isOfficial === "boolean") {
|
|
return ctx.db
|
|
.query("packageTopicSearchDigest")
|
|
.withIndex("by_active_family_official_topic_downloads", (q) =>
|
|
q
|
|
.eq("softDeletedAt", undefined)
|
|
.eq("family", family)
|
|
.eq("isOfficial", isOfficial)
|
|
.eq("topic", args.topic),
|
|
);
|
|
}
|
|
if (channel && typeof isOfficial === "boolean") {
|
|
return ctx.db
|
|
.query("packageTopicSearchDigest")
|
|
.withIndex("by_active_channel_official_topic_downloads", (q) =>
|
|
q
|
|
.eq("softDeletedAt", undefined)
|
|
.eq("channel", channel)
|
|
.eq("isOfficial", isOfficial)
|
|
.eq("topic", args.topic),
|
|
);
|
|
}
|
|
if (family) {
|
|
return ctx.db
|
|
.query("packageTopicSearchDigest")
|
|
.withIndex("by_active_family_topic_downloads", (q) =>
|
|
q.eq("softDeletedAt", undefined).eq("family", family).eq("topic", args.topic),
|
|
);
|
|
}
|
|
if (channel) {
|
|
return ctx.db
|
|
.query("packageTopicSearchDigest")
|
|
.withIndex("by_active_channel_topic_downloads", (q) =>
|
|
q.eq("softDeletedAt", undefined).eq("channel", channel).eq("topic", args.topic),
|
|
);
|
|
}
|
|
if (typeof isOfficial === "boolean") {
|
|
return ctx.db
|
|
.query("packageTopicSearchDigest")
|
|
.withIndex("by_active_official_topic_downloads", (q) =>
|
|
q.eq("softDeletedAt", undefined).eq("isOfficial", isOfficial).eq("topic", args.topic),
|
|
);
|
|
}
|
|
return ctx.db
|
|
.query("packageTopicSearchDigest")
|
|
.withIndex("by_active_topic_downloads", (q) =>
|
|
q.eq("softDeletedAt", undefined).eq("topic", args.topic),
|
|
);
|
|
}
|
|
if (args.sort === "installs") {
|
|
if (typeof isOfficial === "boolean") {
|
|
return ctx.db
|
|
.query("packageTopicSearchDigest")
|
|
.withIndex("by_active_official_topic_installs", (q) =>
|
|
q.eq("softDeletedAt", undefined).eq("isOfficial", isOfficial).eq("topic", args.topic),
|
|
);
|
|
}
|
|
return ctx.db
|
|
.query("packageTopicSearchDigest")
|
|
.withIndex("by_active_topic_installs", (q) =>
|
|
q.eq("softDeletedAt", undefined).eq("topic", args.topic),
|
|
);
|
|
}
|
|
if (args.sort === "recommended") {
|
|
if (typeof isOfficial === "boolean") {
|
|
return ctx.db
|
|
.query("packageTopicSearchDigest")
|
|
.withIndex("by_active_official_topic_recommended_score", (q) =>
|
|
q.eq("softDeletedAt", undefined).eq("isOfficial", isOfficial).eq("topic", args.topic),
|
|
);
|
|
}
|
|
return ctx.db
|
|
.query("packageTopicSearchDigest")
|
|
.withIndex("by_active_topic_recommended_score", (q) =>
|
|
q.eq("softDeletedAt", undefined).eq("topic", args.topic),
|
|
);
|
|
}
|
|
if (family && channel) {
|
|
return ctx.db
|
|
.query("packageTopicSearchDigest")
|
|
.withIndex("by_active_family_channel_topic_updated", (q) =>
|
|
q
|
|
.eq("softDeletedAt", undefined)
|
|
.eq("family", family)
|
|
.eq("channel", channel)
|
|
.eq("topic", args.topic),
|
|
);
|
|
}
|
|
if (family && typeof isOfficial === "boolean") {
|
|
return ctx.db
|
|
.query("packageTopicSearchDigest")
|
|
.withIndex("by_active_family_official_topic_updated", (q) =>
|
|
q
|
|
.eq("softDeletedAt", undefined)
|
|
.eq("family", family)
|
|
.eq("isOfficial", isOfficial)
|
|
.eq("topic", args.topic),
|
|
);
|
|
}
|
|
if (channel && typeof isOfficial === "boolean") {
|
|
return ctx.db
|
|
.query("packageTopicSearchDigest")
|
|
.withIndex("by_active_channel_official_topic_updated", (q) =>
|
|
q
|
|
.eq("softDeletedAt", undefined)
|
|
.eq("channel", channel)
|
|
.eq("isOfficial", isOfficial)
|
|
.eq("topic", args.topic),
|
|
);
|
|
}
|
|
if (family) {
|
|
return ctx.db
|
|
.query("packageTopicSearchDigest")
|
|
.withIndex("by_active_family_topic_updated", (q) =>
|
|
q.eq("softDeletedAt", undefined).eq("family", family).eq("topic", args.topic),
|
|
);
|
|
}
|
|
if (channel) {
|
|
return ctx.db
|
|
.query("packageTopicSearchDigest")
|
|
.withIndex("by_active_channel_topic_updated", (q) =>
|
|
q.eq("softDeletedAt", undefined).eq("channel", channel).eq("topic", args.topic),
|
|
);
|
|
}
|
|
if (typeof isOfficial === "boolean") {
|
|
return ctx.db
|
|
.query("packageTopicSearchDigest")
|
|
.withIndex("by_active_official_topic_updated", (q) =>
|
|
q.eq("softDeletedAt", undefined).eq("isOfficial", isOfficial).eq("topic", args.topic),
|
|
);
|
|
}
|
|
return ctx.db
|
|
.query("packageTopicSearchDigest")
|
|
.withIndex("by_active_topic_updated", (q) =>
|
|
q.eq("softDeletedAt", undefined).eq("topic", args.topic),
|
|
);
|
|
}
|
|
|
|
async function mayHaveVisiblePackageCategoryDigest(
|
|
ctx: DbReaderCtx,
|
|
args: {
|
|
family?: PackageFamily;
|
|
channel?: PackageChannel;
|
|
isOfficial?: boolean;
|
|
category: PluginCategorySlug;
|
|
topic?: string;
|
|
excludedScanStatuses?: PackageListScanStatus[];
|
|
sort?: "updated" | "downloads" | "recommended" | "installs" | "trending";
|
|
viewerUserId?: Id<"users">;
|
|
},
|
|
) {
|
|
const membershipCache = new Map<string, Promise<boolean>>();
|
|
const digests = await (
|
|
args.topic
|
|
? buildPackageTopicDigestQuery(ctx, {
|
|
topic: args.topic,
|
|
family: args.family,
|
|
channel: args.channel,
|
|
isOfficial: args.isOfficial,
|
|
sort: args.sort,
|
|
})
|
|
: buildPackagePluginCategoryDigestQuery(ctx, {
|
|
category: args.category,
|
|
family: args.family,
|
|
channel: args.channel,
|
|
isOfficial: args.isOfficial,
|
|
sort: args.sort,
|
|
})
|
|
)
|
|
.order("desc")
|
|
.take(MAX_PUBLIC_LIST_PAGE_SIZE);
|
|
|
|
for (const digest of digests as PackageDigestLike[]) {
|
|
if (args.family && digest.family !== args.family) continue;
|
|
if (args.channel && digest.channel !== args.channel) continue;
|
|
if (typeof args.isOfficial === "boolean" && digest.isOfficial !== args.isOfficial) continue;
|
|
if (!digestMatchesFilters(digest, args)) continue;
|
|
if (!(await canViewerReadPackage(ctx, digest, args.viewerUserId, membershipCache))) continue;
|
|
return true;
|
|
}
|
|
// A saturated bounded probe cannot prove that later rows are also invisible.
|
|
return digests.length >= MAX_PUBLIC_LIST_PAGE_SIZE;
|
|
}
|
|
|
|
async function takeVisiblePackageCategoryDigestPage(
|
|
ctx: DbReaderCtx,
|
|
args: {
|
|
family?: PackageFamily;
|
|
channel?: PackageChannel;
|
|
isOfficial?: boolean;
|
|
category: PluginCategorySlug;
|
|
topic?: string;
|
|
excludedScanStatuses?: PackageListScanStatus[];
|
|
sort?: "updated" | "downloads" | "recommended" | "installs" | "trending";
|
|
viewerUserId?: Id<"users">;
|
|
numItems: number;
|
|
},
|
|
): Promise<PublicPackageListPage> {
|
|
const targetCount = Math.max(0, Math.min(args.numItems, MAX_PUBLIC_LIST_PAGE_SIZE));
|
|
if (targetCount === 0) {
|
|
return { page: [], isDone: true, continueCursor: "" };
|
|
}
|
|
const membershipCache = new Map<string, Promise<boolean>>();
|
|
const scanPageSize = MAX_PUBLIC_LIST_PAGE_SIZE;
|
|
const digests = await (
|
|
args.topic
|
|
? buildPackageTopicDigestQuery(ctx, {
|
|
topic: args.topic,
|
|
family: args.family,
|
|
channel: args.channel,
|
|
isOfficial: args.isOfficial,
|
|
sort: args.sort,
|
|
})
|
|
: buildPackagePluginCategoryDigestQuery(ctx, {
|
|
category: args.category,
|
|
family: args.family,
|
|
channel: args.channel,
|
|
isOfficial: args.isOfficial,
|
|
sort: args.sort,
|
|
})
|
|
)
|
|
.order("desc")
|
|
.take(scanPageSize);
|
|
|
|
const page: PublicPackageListItem[] = [];
|
|
let consumed = 0;
|
|
for (let index = 0; index < digests.length; index += 1) {
|
|
consumed = index + 1;
|
|
const digest = digests[index] as PackageDigestLike;
|
|
if (args.family && digest.family !== args.family) continue;
|
|
if (args.channel && digest.channel !== args.channel) continue;
|
|
if (typeof args.isOfficial === "boolean" && digest.isOfficial !== args.isOfficial) continue;
|
|
if (!digestMatchesFilters(digest, args)) continue;
|
|
if (!(await canViewerReadPackage(ctx, digest, args.viewerUserId, membershipCache))) continue;
|
|
page.push(await toPublicPackageListItem(ctx, digest));
|
|
if (page.length >= targetCount) break;
|
|
}
|
|
|
|
const hasMore = consumed < digests.length || digests.length >= scanPageSize;
|
|
return {
|
|
page,
|
|
isDone: !hasMore,
|
|
continueCursor: hasMore
|
|
? encodePublicPageCursor({
|
|
cursor: null,
|
|
offset: consumed,
|
|
pageSize: scanPageSize,
|
|
done: false,
|
|
mode: "digest",
|
|
sort: args.sort,
|
|
})
|
|
: "",
|
|
};
|
|
}
|
|
|
|
async function fetchHighlightedPackageEntries(
|
|
ctx: DbReaderCtx,
|
|
args: {
|
|
family?: PackageFamily;
|
|
families?: PackageFamily[];
|
|
channel?: PackageChannel;
|
|
isOfficial?: boolean;
|
|
category?: string;
|
|
topic?: string;
|
|
viewerUserId?: Id<"users">;
|
|
},
|
|
) {
|
|
const viewerUserId = args.viewerUserId;
|
|
const membershipCache = new Map<string, Promise<boolean>>();
|
|
const badges = await ctx.db
|
|
.query("packageBadges")
|
|
.withIndex("by_kind_at", (q) => q.eq("kind", "highlighted"))
|
|
.order("desc")
|
|
.take(MAX_PUBLIC_LIST_PAGE_SIZE);
|
|
const entries: Array<{ digest: PackageDigestLike; featuredAt: number }> = [];
|
|
for (const badge of badges) {
|
|
const digest = await ctx.db
|
|
.query("packageSearchDigest")
|
|
.withIndex("by_package", (q) => q.eq("packageId", badge.packageId))
|
|
.unique();
|
|
if (!digest || digest.softDeletedAt) continue;
|
|
if (!(await canViewerReadPackage(ctx, digest, viewerUserId, membershipCache))) continue;
|
|
if (!digestMatchesSearchFilters(digest, args)) continue;
|
|
entries.push({ digest, featuredAt: badge.at });
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
async function fetchHighlightedPackagePage(
|
|
ctx: DbReaderCtx,
|
|
args: {
|
|
family?: PackageFamily;
|
|
families?: PackageFamily[];
|
|
channel?: PackageChannel;
|
|
isOfficial?: boolean;
|
|
category?: string;
|
|
topic?: string;
|
|
officialFirst?: boolean;
|
|
sort?: "updated" | "downloads" | "recommended" | "installs" | "trending";
|
|
viewerUserId?: Id<"users">;
|
|
numItems: number;
|
|
},
|
|
) {
|
|
const entries = await fetchHighlightedPackageEntries(ctx, args);
|
|
const items = await Promise.all(
|
|
entries.map(
|
|
async ({ digest, featuredAt }) => await toPublicPackageListItem(ctx, digest, featuredAt),
|
|
),
|
|
);
|
|
// fetchHighlightedPackageEntries follows the badge timestamp index newest-first.
|
|
// Preserve that editorial order instead of re-ranking Featured by popularity.
|
|
if (!args.officialFirst) {
|
|
return items.slice(0, args.numItems);
|
|
}
|
|
const official = items.filter((item) => item.isOfficial);
|
|
const community = items.filter((item) => !item.isOfficial);
|
|
return [...official, ...community].slice(0, args.numItems);
|
|
}
|
|
|
|
async function getPackageByNormalizedName(ctx: DbReaderCtx, normalizedName: string) {
|
|
return (await ctx.db
|
|
.query("packages")
|
|
.withIndex("by_name", (q) => q.eq("normalizedName", normalizedName))
|
|
.unique()) as Doc<"packages"> | null;
|
|
}
|
|
|
|
async function getReadablePackageByName(
|
|
ctx: DbReaderCtx,
|
|
name: string,
|
|
viewerUserId?: Id<"users">,
|
|
) {
|
|
const normalizedName = normalizePackageName(name);
|
|
const pkg = await getPackageByNormalizedName(ctx, normalizedName);
|
|
if (!pkg || pkg.softDeletedAt) return null;
|
|
if (!isClawFamilyPubliclyVisible(pkg.family)) return null;
|
|
if (pkg.channel === "private" || isPackageBlockedFromPublic(pkg.scanStatus)) {
|
|
const canAccessOwner = await viewerCanAccessPackageOwner(ctx, pkg, viewerUserId);
|
|
if (pkg.channel === "private" && !canAccessOwner) return null;
|
|
|
|
if (isPackageBlockedFromPublic(pkg.scanStatus)) {
|
|
const latestRelease = pkg.latestReleaseId ? await ctx.db.get(pkg.latestReleaseId) : null;
|
|
const scanStatus = resolvePublicPackageScanStatus(pkg, latestRelease);
|
|
if (isPackageBlockedFromPublic(scanStatus) && !canAccessOwner) return null;
|
|
}
|
|
}
|
|
return pkg;
|
|
}
|
|
|
|
async function getPackageReadableForPublicTrust(
|
|
ctx: DbReaderCtx,
|
|
name: string,
|
|
viewerUserId?: Id<"users">,
|
|
) {
|
|
const pkg = await getPackageByNormalizedName(ctx, normalizePackageName(name));
|
|
if (!pkg || pkg.softDeletedAt) return null;
|
|
if (!isClawFamilyPubliclyVisible(pkg.family)) return null;
|
|
if (pkg.channel === "private" && !(await viewerCanAccessPackageOwner(ctx, pkg, viewerUserId))) {
|
|
return null;
|
|
}
|
|
return pkg;
|
|
}
|
|
|
|
async function getPackageTrustedPublisherByPackageId(ctx: DbReaderCtx, packageId: Id<"packages">) {
|
|
return await ctx.db
|
|
.query("packageTrustedPublishers")
|
|
.withIndex("by_package", (q) => q.eq("packageId", packageId))
|
|
.unique();
|
|
}
|
|
|
|
function normalizeWorkflowFilenameOrThrow(value: string) {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) throw new ConvexError("Workflow filename is required");
|
|
if (trimmed.includes("/") || trimmed.includes("\\")) {
|
|
throw new ConvexError("Workflow filename must not include a path");
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
function normalizeManualOverrideReason(reason: string | undefined) {
|
|
const normalized = reason?.trim();
|
|
return normalized || undefined;
|
|
}
|
|
|
|
async function requireTrustedPublisherEditor(
|
|
ctx: Pick<MutationCtx, "db">,
|
|
pkg: Doc<"packages">,
|
|
actorUserId: Id<"users">,
|
|
) {
|
|
await assertCanManageOwnedResource(ctx, {
|
|
actor: { _id: actorUserId },
|
|
ownerUserId: pkg.ownerUserId,
|
|
ownerPublisherId: pkg.ownerPublisherId,
|
|
allowPlatformAdmin: false,
|
|
});
|
|
}
|
|
|
|
type PackageManageContext = {
|
|
package: Pick<Doc<"packages">, "_id" | "name" | "displayName" | "categories" | "topics">;
|
|
latestRelease: Pick<Doc<"packageReleases">, "_id" | "version">;
|
|
suggestedCategories: PluginCategorySlug[];
|
|
};
|
|
|
|
function toPackageManageContext(
|
|
pkg: Doc<"packages">,
|
|
latestRelease: Doc<"packageReleases">,
|
|
): PackageManageContext {
|
|
return {
|
|
package: {
|
|
_id: pkg._id,
|
|
name: pkg.name,
|
|
displayName: pkg.displayName,
|
|
categories: pkg.categories,
|
|
topics: pkg.topics,
|
|
},
|
|
latestRelease: {
|
|
_id: latestRelease._id,
|
|
version: latestRelease.version,
|
|
},
|
|
suggestedCategories: derivePluginCategoryTags({
|
|
family: pkg.family,
|
|
pluginManifest: latestRelease.extractedPluginManifest,
|
|
}),
|
|
};
|
|
}
|
|
|
|
export const getByName = query({
|
|
args: { name: v.string() },
|
|
handler: async (ctx, args) => {
|
|
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 = toPublicPublisher(
|
|
await getOwnerPublisher(ctx, {
|
|
ownerPublisherId: pkg.ownerPublisherId,
|
|
ownerUserId: pkg.ownerUserId,
|
|
}),
|
|
);
|
|
return {
|
|
package: publicPackage,
|
|
latestRelease: isPublishedPackageRelease(latestRelease)
|
|
? toPublicPackageRelease(latestRelease, pkg.family)
|
|
: null,
|
|
owner,
|
|
};
|
|
},
|
|
});
|
|
|
|
export const getManageContext = query({
|
|
args: {
|
|
name: v.string(),
|
|
candidateNames: v.optional(v.array(v.string())),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const viewerUserId = await getOptionalViewerUserId(ctx);
|
|
if (!viewerUserId) return null;
|
|
|
|
const candidates = [args.name, ...(args.candidateNames ?? [])]
|
|
.map((name) => normalizePackageName(name))
|
|
.filter(Boolean);
|
|
const uniqueCandidates = Array.from(new Set(candidates));
|
|
|
|
let pkg: Doc<"packages"> | null = null;
|
|
for (const candidate of uniqueCandidates) {
|
|
pkg = await getPackageByNormalizedName(ctx, candidate);
|
|
if (pkg && !pkg.softDeletedAt && pkg.family !== "skill") break;
|
|
pkg = null;
|
|
}
|
|
if (!pkg || !pkg.latestReleaseId) return null;
|
|
|
|
const actor = await ctx.db.get(viewerUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) return null;
|
|
if (actor.role !== "admin" && actor.role !== "moderator") {
|
|
const canAccess = await viewerCanManagePackageOwner(ctx, pkg, viewerUserId);
|
|
if (!canAccess) return null;
|
|
}
|
|
|
|
const latestRelease = await ctx.db.get(pkg.latestReleaseId);
|
|
if (!latestRelease || latestRelease.softDeletedAt) return null;
|
|
|
|
return toPackageManageContext(pkg, latestRelease);
|
|
},
|
|
});
|
|
|
|
export const assertCanGenerateChangelogPreviewInternal = internalQuery({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
name: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const actor = await ctx.db.get(args.actorUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) {
|
|
throw new ConvexError("Unauthorized");
|
|
}
|
|
|
|
const pkg = await getPackageByNormalizedName(ctx, args.name);
|
|
if (!pkg || pkg.softDeletedAt) return { ok: true as const, latestReleaseId: null };
|
|
if (pkg.family === "skill") throw new ConvexError("Forbidden");
|
|
const result = { ok: true as const, latestReleaseId: pkg.latestReleaseId ?? null };
|
|
if (actor.role === "admin" || actor.role === "moderator") return result;
|
|
|
|
const canPublish = await viewerCanAccessPackageOwner(ctx, pkg, args.actorUserId);
|
|
if (!canPublish) throw new ConvexError("Forbidden");
|
|
return result;
|
|
},
|
|
});
|
|
|
|
export const canDeleteVersions = query({
|
|
args: {
|
|
name: v.string(),
|
|
candidateNames: v.optional(v.array(v.string())),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const viewerUserId = await getOptionalViewerUserId(ctx);
|
|
if (!viewerUserId) return false;
|
|
|
|
const candidates = [args.name, ...(args.candidateNames ?? [])]
|
|
.map((name) => normalizePackageName(name))
|
|
.filter(Boolean);
|
|
const uniqueCandidates = Array.from(new Set(candidates)).slice(
|
|
0,
|
|
MAX_PACKAGE_VERSION_DELETE_LOOKUP_CANDIDATES,
|
|
);
|
|
|
|
let pkg: Doc<"packages"> | null = null;
|
|
for (const candidate of uniqueCandidates) {
|
|
pkg = await getPackageByNormalizedName(ctx, candidate);
|
|
if (pkg && !pkg.softDeletedAt && pkg.family !== "skill") break;
|
|
pkg = null;
|
|
}
|
|
if (!pkg || isPackageBlockedFromPublic(pkg.scanStatus)) return false;
|
|
|
|
const actor = await ctx.db.get(viewerUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) return false;
|
|
|
|
return await viewerCanManagePackageOwner(ctx, pkg, viewerUserId);
|
|
},
|
|
});
|
|
|
|
function toPublicPackageInspectorFinding(warning: Doc<"packageInspectorWarnings">) {
|
|
const findingKind =
|
|
warning.findingKind ??
|
|
(warning.level === "breakage" || warning.severity === "P0" ? "error" : "warning");
|
|
return {
|
|
_id: warning._id,
|
|
packageName: warning.packageName,
|
|
version: warning.version,
|
|
findingKind,
|
|
code: warning.code,
|
|
severity: warning.severity,
|
|
level: warning.level,
|
|
issueClass: warning.issueClass,
|
|
compatStatus: warning.compatStatus,
|
|
deprecated: warning.deprecated,
|
|
message: warning.message,
|
|
evidence: warning.evidence ?? [],
|
|
authorRemediation: warning.authorRemediation,
|
|
fixture: warning.fixture,
|
|
decision: warning.decision,
|
|
inspectorVersion: warning.inspectorVersion,
|
|
targetOpenClawVersion: warning.targetOpenClawVersion,
|
|
scanSource: warning.scanSource ?? "publish",
|
|
createdAt: warning.createdAt,
|
|
};
|
|
}
|
|
|
|
export const listPackageInspectorFindingsPublic = query({
|
|
args: {
|
|
name: v.string(),
|
|
limit: v.optional(v.number()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const viewerUserId = await getOptionalActiveAuthUserId(ctx);
|
|
if (!viewerUserId) return [];
|
|
const pkg = await getReadablePackageByName(ctx, args.name, viewerUserId);
|
|
if (!pkg || pkg.softDeletedAt || pkg.family === "skill") return [];
|
|
const actor = await ctx.db.get(viewerUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) return [];
|
|
if (actor.role !== "admin" && actor.role !== "moderator") {
|
|
const canManage = await viewerCanManagePackageOwner(ctx, pkg, viewerUserId);
|
|
if (!canManage) return [];
|
|
}
|
|
|
|
if (!pkg.latestReleaseId) return [];
|
|
const limit = Math.max(1, Math.min(args.limit ?? 50, 100));
|
|
const warnings = await takeAuthorRemediationWarningsByRelease(ctx, pkg.latestReleaseId, limit);
|
|
return warnings.map(toPublicPackageInspectorFinding);
|
|
},
|
|
});
|
|
|
|
export const getPackageInspectorValidationSummaryPublic = query({
|
|
args: {
|
|
name: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const viewerUserId = await getOptionalActiveAuthUserId(ctx);
|
|
const pkg = await getReadablePackageByName(ctx, args.name, viewerUserId ?? undefined);
|
|
if (!pkg || pkg.softDeletedAt || pkg.family === "skill") {
|
|
return {
|
|
findingCount: 0,
|
|
errorCount: 0,
|
|
warningCount: 0,
|
|
incompatibleAfterOpenClawVersion: null,
|
|
};
|
|
}
|
|
|
|
const findings = pkg.latestReleaseId
|
|
? await takeAuthorRemediationWarningsByRelease(ctx, pkg.latestReleaseId, 100)
|
|
: [];
|
|
const errorFindings = findings.filter((finding) => {
|
|
const kind =
|
|
finding.findingKind ??
|
|
(finding.level === "breakage" || finding.severity === "P0" ? "error" : "warning");
|
|
return kind === "error";
|
|
});
|
|
return {
|
|
findingCount: findings.length,
|
|
errorCount: errorFindings.length,
|
|
warningCount: findings.length - errorFindings.length,
|
|
incompatibleAfterOpenClawVersion:
|
|
errorFindings.find((finding) => finding.targetOpenClawVersion)?.targetOpenClawVersion ??
|
|
null,
|
|
};
|
|
},
|
|
});
|
|
|
|
export const listPackageInspectorWarningsForManager = query({
|
|
args: {
|
|
name: v.string(),
|
|
limit: v.optional(v.number()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const viewerUserId = await getOptionalActiveAuthUserId(ctx);
|
|
if (!viewerUserId) return [];
|
|
const pkg = await getPackageByNormalizedName(ctx, normalizePackageName(args.name));
|
|
if (!pkg || pkg.softDeletedAt || pkg.family === "skill") return [];
|
|
const actor = await ctx.db.get(viewerUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) return [];
|
|
if (actor.role !== "admin" && actor.role !== "moderator") {
|
|
const canManage = await viewerCanManagePackageOwner(ctx, pkg, viewerUserId);
|
|
if (!canManage) return [];
|
|
}
|
|
if (!pkg.latestReleaseId) return [];
|
|
const limit = Math.max(1, Math.min(args.limit ?? 50, 100));
|
|
const warnings = await takeAuthorRemediationWarningsByRelease(ctx, pkg.latestReleaseId, limit);
|
|
return warnings.map(toPublicPackageInspectorFinding);
|
|
},
|
|
});
|
|
|
|
async function takeAuthorRemediationWarningsByRelease(
|
|
ctx: DbReaderCtx,
|
|
releaseId: Id<"packageReleases">,
|
|
limit: number,
|
|
) {
|
|
const findings = await ctx.db
|
|
.query("packageInspectorWarnings")
|
|
.withIndex("by_release_created", (q) => q.eq("releaseId", releaseId))
|
|
.order("desc")
|
|
.take(authorRemediationScanLimit(limit));
|
|
return findings.filter(hasStoredAuthorRemediation).slice(0, limit);
|
|
}
|
|
|
|
async function takeExactNightlyAuthorRemediationWarningsByRelease(
|
|
ctx: DbReaderCtx,
|
|
releaseId: Id<"packageReleases">,
|
|
inspectorVersion: string,
|
|
targetOpenClawVersion: string,
|
|
limit: number,
|
|
) {
|
|
const findings = await ctx.db
|
|
.query("packageInspectorWarnings")
|
|
.withIndex("by_release_created", (q) => q.eq("releaseId", releaseId))
|
|
.filter((q) =>
|
|
q.and(
|
|
q.eq(q.field("scanSource"), "nightly"),
|
|
q.eq(q.field("inspectorVersion"), inspectorVersion),
|
|
q.eq(q.field("targetOpenClawVersion"), targetOpenClawVersion),
|
|
),
|
|
)
|
|
.order("desc")
|
|
.take(authorRemediationScanLimit(limit));
|
|
return findings.filter(hasStoredAuthorRemediation).slice(0, limit);
|
|
}
|
|
|
|
function authorRemediationScanLimit(limit: number) {
|
|
return Math.min(500, Math.max(limit * 5, 50));
|
|
}
|
|
|
|
function hasStoredAuthorRemediation(warning: Doc<"packageInspectorWarnings">) {
|
|
return Boolean(warning.authorRemediation?.summary);
|
|
}
|
|
|
|
export const getByNameForStaff = query({
|
|
args: { name: v.string() },
|
|
handler: async (ctx, args) => {
|
|
const { user } = await requireUser(ctx);
|
|
assertModerator(user);
|
|
|
|
const pkg = await getPackageByNormalizedName(ctx, normalizePackageName(args.name));
|
|
if (!pkg || pkg.softDeletedAt || pkg.family === "skill") return null;
|
|
|
|
const highlighted = await ctx.db
|
|
.query("packageBadges")
|
|
.withIndex("by_package_kind", (q) => q.eq("packageId", pkg._id).eq("kind", "highlighted"))
|
|
.unique();
|
|
const latestRelease = pkg.latestReleaseId ? await ctx.db.get(pkg.latestReleaseId) : null;
|
|
const owner = toPublicPublisher(
|
|
await getOwnerPublisher(ctx, {
|
|
ownerPublisherId: pkg.ownerPublisherId,
|
|
ownerUserId: pkg.ownerUserId,
|
|
}),
|
|
);
|
|
|
|
return {
|
|
package: pkg,
|
|
latestRelease: isPublishedPackageRelease(latestRelease)
|
|
? toPublicPackageRelease(latestRelease, pkg.family)
|
|
: null,
|
|
owner,
|
|
highlighted: highlighted
|
|
? {
|
|
byUserId: highlighted.byUserId,
|
|
at: highlighted.at,
|
|
}
|
|
: null,
|
|
};
|
|
},
|
|
});
|
|
|
|
export const getByNameForViewerInternal = internalQuery({
|
|
args: {
|
|
name: v.string(),
|
|
viewerUserId: v.optional(v.id("users")),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const pkg = await getReadablePackageByName(ctx, args.name, args.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 = toPublicPublisher(
|
|
await getOwnerPublisher(ctx, {
|
|
ownerPublisherId: pkg.ownerPublisherId,
|
|
ownerUserId: pkg.ownerUserId,
|
|
}),
|
|
);
|
|
return {
|
|
package: publicPackage,
|
|
latestRelease: isPublishedPackageRelease(latestRelease)
|
|
? toPublicPackageRelease(latestRelease, pkg.family)
|
|
: null,
|
|
owner,
|
|
};
|
|
},
|
|
});
|
|
|
|
export const listVersions = query({
|
|
args: {
|
|
name: v.string(),
|
|
paginationOpts: paginationOptsValidator,
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const viewerUserId = await getOptionalViewerUserId(ctx);
|
|
const pkg = await getReadablePackageByName(ctx, args.name, viewerUserId);
|
|
if (!pkg) return { page: [], isDone: true, continueCursor: "" };
|
|
const result = await paginatePublishedPackageReleases(ctx, pkg._id, args.paginationOpts);
|
|
return {
|
|
...result,
|
|
page: result.page.map((release) => toPublicPackageRelease(release, pkg.family)),
|
|
};
|
|
},
|
|
});
|
|
|
|
export const listVersionsForViewerInternal = internalQuery({
|
|
args: {
|
|
name: v.string(),
|
|
viewerUserId: v.optional(v.id("users")),
|
|
paginationOpts: paginationOptsValidator,
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const pkg = await getReadablePackageByName(ctx, args.name, args.viewerUserId);
|
|
if (!pkg) return { page: [], isDone: true, continueCursor: "" };
|
|
const result = await paginatePublishedPackageReleases(ctx, pkg._id, args.paginationOpts);
|
|
return {
|
|
...result,
|
|
page: result.page.map((release) => toPublicPackageRelease(release, pkg.family)),
|
|
};
|
|
},
|
|
});
|
|
|
|
export const listVersionsForManager = query({
|
|
args: {
|
|
name: v.string(),
|
|
paginationOpts: paginationOptsValidator,
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const viewerUserId = await getOptionalViewerUserId(ctx);
|
|
if (!viewerUserId) return { page: [], isDone: true, continueCursor: "" };
|
|
const actor = await ctx.db.get(viewerUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) {
|
|
return { page: [], isDone: true, continueCursor: "" };
|
|
}
|
|
|
|
const pkg = await getPackageByNormalizedName(ctx, normalizePackageName(args.name));
|
|
if (
|
|
!pkg ||
|
|
pkg.softDeletedAt ||
|
|
pkg.family === "skill" ||
|
|
isPackageBlockedFromPublic(pkg.scanStatus)
|
|
) {
|
|
return { page: [], isDone: true, continueCursor: "" };
|
|
}
|
|
if (!(await viewerCanManagePackageOwner(ctx, pkg, viewerUserId))) {
|
|
return { page: [], isDone: true, continueCursor: "" };
|
|
}
|
|
|
|
const result = await ctx.db
|
|
.query("packageReleases")
|
|
.withIndex("by_package_owner_deleted_created", (q) =>
|
|
q.eq("packageId", pkg._id).eq("ownerDeletedBy", actor._id),
|
|
)
|
|
.order("desc")
|
|
.paginate(args.paginationOpts);
|
|
return {
|
|
...result,
|
|
page: result.page
|
|
.filter((release) => isPackageReleaseRestorableByOwner(release, pkg._id, actor._id))
|
|
.map((release) => toManagerPackageRelease(release, pkg.family)),
|
|
};
|
|
},
|
|
});
|
|
|
|
export const getVersionByName = query({
|
|
args: {
|
|
name: v.string(),
|
|
version: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const viewerUserId = await getOptionalViewerUserId(ctx);
|
|
const pkg = await getReadablePackageByName(ctx, args.name, viewerUserId);
|
|
if (!pkg) return null;
|
|
const release = await ctx.db
|
|
.query("packageReleases")
|
|
.withIndex("by_package_version", (q) =>
|
|
q.eq("packageId", pkg._id).eq("version", args.version),
|
|
)
|
|
.unique();
|
|
if (!isPublishedPackageRelease(release)) return null;
|
|
const latestRelease =
|
|
pkg.latestReleaseId === release._id
|
|
? release
|
|
: pkg.latestReleaseId
|
|
? await ctx.db.get(pkg.latestReleaseId)
|
|
: null;
|
|
const publicPackage = toPublicPackage(pkg, latestRelease);
|
|
if (!publicPackage) return null;
|
|
return {
|
|
package: publicPackage,
|
|
version: toPublicPackageRelease(release, pkg.family),
|
|
};
|
|
},
|
|
});
|
|
|
|
export const getVersionByNameForViewerInternal = internalQuery({
|
|
args: {
|
|
name: v.string(),
|
|
version: v.string(),
|
|
viewerUserId: v.optional(v.id("users")),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const pkg = await getReadablePackageByName(ctx, args.name, args.viewerUserId);
|
|
if (!pkg) return null;
|
|
const release = await ctx.db
|
|
.query("packageReleases")
|
|
.withIndex("by_package_version", (q) =>
|
|
q.eq("packageId", pkg._id).eq("version", args.version),
|
|
)
|
|
.unique();
|
|
if (!isPublishedPackageRelease(release)) return null;
|
|
const latestRelease =
|
|
pkg.latestReleaseId === release._id
|
|
? release
|
|
: pkg.latestReleaseId
|
|
? await ctx.db.get(pkg.latestReleaseId)
|
|
: null;
|
|
const publicPackage = toPublicPackage(pkg, latestRelease);
|
|
if (!publicPackage) return null;
|
|
return {
|
|
package: publicPackage,
|
|
version: toPublicPackageRelease(release, pkg.family),
|
|
};
|
|
},
|
|
});
|
|
|
|
export const getVersionSecurityByNameForViewerInternal = internalQuery({
|
|
args: {
|
|
name: v.string(),
|
|
version: v.string(),
|
|
viewerUserId: v.optional(v.id("users")),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const pkg = await getPackageReadableForPublicTrust(ctx, args.name, args.viewerUserId);
|
|
if (!pkg) return null;
|
|
const release = await ctx.db
|
|
.query("packageReleases")
|
|
.withIndex("by_package_version", (q) =>
|
|
q.eq("packageId", pkg._id).eq("version", args.version),
|
|
)
|
|
.unique();
|
|
if (!isPublishedPackageRelease(release)) return null;
|
|
const latestRelease =
|
|
pkg.latestReleaseId === release._id
|
|
? release
|
|
: pkg.latestReleaseId
|
|
? await ctx.db.get(pkg.latestReleaseId)
|
|
: null;
|
|
const publicPackage = toPublicPackage(pkg, latestRelease);
|
|
if (!publicPackage) return null;
|
|
const publicDownloadBlocked =
|
|
isPackageBlockedFromPublic(publicPackage.scanStatus) &&
|
|
!(await viewerCanAccessPackageOwner(ctx, pkg, args.viewerUserId));
|
|
return {
|
|
package: {
|
|
...publicPackage,
|
|
publicDownloadBlocked,
|
|
},
|
|
version: toPublicPackageRelease(release, pkg.family),
|
|
};
|
|
},
|
|
});
|
|
|
|
export const list = query({
|
|
args: {
|
|
ownerUserId: v.optional(v.id("users")),
|
|
ownerPublisherId: v.optional(v.id("publishers")),
|
|
limit: v.optional(v.number()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const viewerUserId = await getOptionalActiveAuthUserId(ctx);
|
|
if (!viewerUserId) return [];
|
|
const limit = Math.max(1, Math.min(args.limit ?? 50, 100));
|
|
if (args.ownerPublisherId) {
|
|
return await listDashboardPackagesForOwnerPublisher(
|
|
ctx,
|
|
args.ownerPublisherId,
|
|
viewerUserId,
|
|
limit,
|
|
);
|
|
}
|
|
if (args.ownerUserId) {
|
|
return await listDashboardPackagesForOwnerUser(ctx, args.ownerUserId, viewerUserId, limit);
|
|
}
|
|
return await listDashboardPackagesForOwnerUser(ctx, viewerUserId, viewerUserId, limit);
|
|
},
|
|
});
|
|
|
|
export const listPublicPage = query({
|
|
args: {
|
|
family: v.optional(
|
|
v.union(
|
|
v.literal("skill"),
|
|
v.literal("code-plugin"),
|
|
v.literal("bundle-plugin"),
|
|
v.literal("claw"),
|
|
),
|
|
),
|
|
channel: v.optional(
|
|
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
|
|
),
|
|
isOfficial: v.optional(v.boolean()),
|
|
highlightedOnly: v.optional(v.boolean()),
|
|
category: v.optional(v.string()),
|
|
topic: v.optional(v.string()),
|
|
officialFirst: v.optional(v.boolean()),
|
|
excludedScanStatuses: v.optional(v.array(packageListScanStatusValidator)),
|
|
sort: v.optional(
|
|
v.union(
|
|
v.literal("updated"),
|
|
v.literal("downloads"),
|
|
v.literal("recommended"),
|
|
v.literal("installs"),
|
|
v.literal("trending"),
|
|
),
|
|
),
|
|
paginationOpts: paginationOptsValidator,
|
|
},
|
|
handler: async (ctx, args) => {
|
|
return await listPackagePageImpl(ctx, args);
|
|
},
|
|
});
|
|
|
|
export const listPublicNewPluginsPage = query({
|
|
args: {
|
|
category: v.optional(v.string()),
|
|
createdAfter: v.number(),
|
|
paginationOpts: paginationOptsValidator,
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const category = isPluginCategorySlug(args.category) ? args.category : undefined;
|
|
if (args.category && !category) {
|
|
return { page: [], isDone: true, continueCursor: "" };
|
|
}
|
|
return await listStablePackageDiscoveryPage(ctx, {
|
|
families: ["code-plugin", "bundle-plugin"],
|
|
category,
|
|
sort: "created",
|
|
createdAfter: args.createdAfter,
|
|
paginationOpts: args.paginationOpts,
|
|
});
|
|
},
|
|
});
|
|
|
|
export const listAuditPage = query({
|
|
args: {
|
|
paginationOpts: paginationOptsValidator,
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const numItems = Math.max(1, Math.min(args.paginationOpts.numItems, MAX_PUBLIC_LIST_PAGE_SIZE));
|
|
const result = await ctx.db
|
|
.query("packages")
|
|
.withIndex("by_active_downloads", (q) => q.eq("softDeletedAt", undefined))
|
|
.order("desc")
|
|
.paginate({ cursor: args.paginationOpts.cursor, numItems });
|
|
|
|
const page = [];
|
|
const membershipCache = new Map<string, Promise<boolean>>();
|
|
for (const pkg of result.page) {
|
|
if (pkg.family !== "code-plugin" && pkg.family !== "bundle-plugin") continue;
|
|
if (!(await canViewerReadPackage(ctx, pkg, undefined, membershipCache))) continue;
|
|
|
|
const owner = toPublicPublisher(
|
|
await getOwnerPublisher(ctx, {
|
|
ownerPublisherId: pkg.ownerPublisherId,
|
|
ownerUserId: pkg.ownerUserId,
|
|
}),
|
|
);
|
|
const latestRelease = pkg.latestReleaseId ? await ctx.db.get(pkg.latestReleaseId) : null;
|
|
page.push({
|
|
kind: "plugin" as const,
|
|
package: {
|
|
name: pkg.name,
|
|
displayName: pkg.displayName,
|
|
family: pkg.family,
|
|
channel: pkg.channel,
|
|
isOfficial: pkg.isOfficial,
|
|
summary: pkg.summary ?? null,
|
|
icon: pkg.icon ?? null,
|
|
ownerHandle: owner?.handle ?? null,
|
|
createdAt: pkg.createdAt,
|
|
updatedAt: pkg.updatedAt,
|
|
latestVersion: pkg.latestVersionSummary?.version ?? null,
|
|
stats: pkg.stats,
|
|
verificationTier: pkg.verification?.tier ?? null,
|
|
},
|
|
owner,
|
|
latestRelease: isPublishedPackageRelease(latestRelease)
|
|
? {
|
|
version: latestRelease.version,
|
|
createdAt: latestRelease.createdAt,
|
|
vtAnalysis: latestRelease.vtAnalysis,
|
|
llmAnalysis: latestRelease.llmAnalysis,
|
|
staticScan: latestRelease.staticScan
|
|
? {
|
|
status: latestRelease.staticScan.status,
|
|
reasonCodes: latestRelease.staticScan.reasonCodes,
|
|
findings: (latestRelease.staticScan.findings ?? []).map((finding) => ({
|
|
code: finding.code,
|
|
severity: finding.severity,
|
|
file: finding.file,
|
|
line: finding.line,
|
|
message: finding.message,
|
|
evidence: "",
|
|
})),
|
|
summary: latestRelease.staticScan.summary,
|
|
engineVersion: latestRelease.staticScan.engineVersion,
|
|
checkedAt: latestRelease.staticScan.checkedAt,
|
|
}
|
|
: null,
|
|
}
|
|
: null,
|
|
});
|
|
}
|
|
|
|
return {
|
|
page,
|
|
isDone: result.isDone,
|
|
continueCursor: result.isDone ? "" : result.continueCursor,
|
|
};
|
|
},
|
|
});
|
|
|
|
type PluginExportFamily = (typeof PLUGIN_EXPORT_FAMILIES)[number];
|
|
|
|
type PluginExportDigest = {
|
|
packageId: Id<"packages">;
|
|
name: string;
|
|
displayName: string;
|
|
family: PluginExportFamily;
|
|
latestReleaseId?: Id<"packageReleases">;
|
|
latestVersion?: string | null;
|
|
createdAt: number;
|
|
updatedAt: number;
|
|
stats?: Doc<"packages">["stats"] | null;
|
|
ownerUserId: Id<"users">;
|
|
ownerHandle?: string | null;
|
|
};
|
|
|
|
type PluginExportFamilyPage = {
|
|
page: PluginExportDigest[];
|
|
nextCursor: string | null;
|
|
hasMore: boolean;
|
|
};
|
|
|
|
type PluginExportSourceState = {
|
|
cursor: string | null;
|
|
offset: number;
|
|
pageSize?: number;
|
|
done: boolean;
|
|
};
|
|
|
|
type PluginExportMergedCursor = {
|
|
codePlugins: PluginExportSourceState;
|
|
bundlePlugins: PluginExportSourceState;
|
|
};
|
|
|
|
function emptyPluginExportSourceState(): PluginExportSourceState {
|
|
return { cursor: null, offset: 0, done: false };
|
|
}
|
|
|
|
function emptyPluginExportMergedCursor(): PluginExportMergedCursor {
|
|
return {
|
|
codePlugins: emptyPluginExportSourceState(),
|
|
bundlePlugins: emptyPluginExportSourceState(),
|
|
};
|
|
}
|
|
|
|
function encodePackageIndexKeyValue(val: Value | undefined): Value {
|
|
return val === undefined ? { __undef: 1 } : val;
|
|
}
|
|
|
|
function decodePackageIndexKeyValue(val: unknown): Value | undefined {
|
|
if (val !== null && typeof val === "object" && "__undef" in (val as Record<string, unknown>)) {
|
|
return undefined;
|
|
}
|
|
return val as Value;
|
|
}
|
|
|
|
function emptyStablePackageDiscoveryCursor(
|
|
sort: StablePackageDiscoveryCursorState["sort"],
|
|
): StablePackageDiscoveryCursorState {
|
|
return {
|
|
sort,
|
|
sources: {
|
|
skill: { key: null, done: false },
|
|
"code-plugin": { key: null, done: false },
|
|
"bundle-plugin": { key: null, done: false },
|
|
},
|
|
};
|
|
}
|
|
|
|
function encodeStablePackageDiscoveryCursor(state: StablePackageDiscoveryCursorState) {
|
|
const sources = Object.fromEntries(
|
|
STABLE_PACKAGE_FAMILIES.map((family) => [
|
|
family,
|
|
{
|
|
key: state.sources[family].key?.map(encodePackageIndexKeyValue) ?? null,
|
|
done: state.sources[family].done,
|
|
},
|
|
]),
|
|
);
|
|
return `${STABLE_PACKAGE_DISCOVERY_CURSOR_PREFIX}${JSON.stringify({
|
|
v: 1,
|
|
sort: state.sort,
|
|
sources,
|
|
})}`;
|
|
}
|
|
|
|
function readStablePackageDiscoveryCursorSort(
|
|
raw: string | null | undefined,
|
|
): "updated" | "created" | "recommended" | null {
|
|
if (!raw?.startsWith(STABLE_PACKAGE_DISCOVERY_CURSOR_PREFIX)) return null;
|
|
try {
|
|
const parsed = JSON.parse(raw.slice(STABLE_PACKAGE_DISCOVERY_CURSOR_PREFIX.length)) as {
|
|
v?: unknown;
|
|
sort?: unknown;
|
|
};
|
|
return parsed.v === 1 &&
|
|
(parsed.sort === "updated" || parsed.sort === "created" || parsed.sort === "recommended")
|
|
? parsed.sort
|
|
: null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function decodeStablePackageDiscoveryCursor(
|
|
raw: string | null | undefined,
|
|
sort: StablePackageDiscoveryCursorState["sort"],
|
|
): StablePackageDiscoveryCursorState {
|
|
const fallback = emptyStablePackageDiscoveryCursor(sort);
|
|
if (!raw?.startsWith(STABLE_PACKAGE_DISCOVERY_CURSOR_PREFIX)) return fallback;
|
|
try {
|
|
const parsed = JSON.parse(raw.slice(STABLE_PACKAGE_DISCOVERY_CURSOR_PREFIX.length)) as {
|
|
v?: unknown;
|
|
sort?: unknown;
|
|
sources?: unknown;
|
|
};
|
|
if (
|
|
parsed.v !== 1 ||
|
|
parsed.sort !== sort ||
|
|
!parsed.sources ||
|
|
typeof parsed.sources !== "object"
|
|
) {
|
|
return fallback;
|
|
}
|
|
const sources = parsed.sources as Record<string, unknown>;
|
|
const decoded = emptyStablePackageDiscoveryCursor(sort);
|
|
for (const family of STABLE_PACKAGE_FAMILIES) {
|
|
const value = sources[family];
|
|
if (!value || typeof value !== "object") return fallback;
|
|
const source = value as { key?: unknown; done?: unknown };
|
|
if (source.key !== null && !Array.isArray(source.key)) return fallback;
|
|
decoded.sources[family] = {
|
|
key: Array.isArray(source.key) ? source.key.map(decodePackageIndexKeyValue) : null,
|
|
done: source.done === true,
|
|
};
|
|
}
|
|
return decoded;
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function encodePackageIndexCursor(indexName: string, key: IndexKey): string {
|
|
return JSON.stringify({
|
|
v: 1,
|
|
index: indexName,
|
|
key: key.map(encodePackageIndexKeyValue),
|
|
});
|
|
}
|
|
|
|
function packageIndexKeyStartsWithPrefix(key: IndexKey, prefix: IndexKey): boolean {
|
|
if (key.length < prefix.length) return false;
|
|
return prefix.every((value, index) => key[index] === value);
|
|
}
|
|
|
|
function decodePackageIndexCursor({
|
|
cursor,
|
|
indexName,
|
|
maxIndexKeyLength,
|
|
eqPrefix,
|
|
}: {
|
|
cursor?: string | null;
|
|
indexName: string;
|
|
maxIndexKeyLength: number;
|
|
eqPrefix: IndexKey;
|
|
}): IndexKey | null {
|
|
if (!cursor) return null;
|
|
try {
|
|
const parsed = JSON.parse(cursor) as unknown;
|
|
const isSelfDescribingCursor =
|
|
parsed !== null &&
|
|
typeof parsed === "object" &&
|
|
!Array.isArray(parsed) &&
|
|
(parsed as { v?: unknown }).v === 1 &&
|
|
(parsed as { index?: unknown }).index === indexName &&
|
|
Array.isArray((parsed as { key?: unknown }).key);
|
|
const arr = Array.isArray(parsed)
|
|
? parsed
|
|
: isSelfDescribingCursor
|
|
? (parsed as { key: unknown[] }).key
|
|
: null;
|
|
if (!Array.isArray(arr)) return null;
|
|
const key = arr.map(decodePackageIndexKeyValue);
|
|
const maxLength = isSelfDescribingCursor
|
|
? maxIndexKeyLength + GET_PAGE_TIEBREAKER_FIELD_COUNT
|
|
: maxIndexKeyLength;
|
|
if (key.length > maxLength) return null;
|
|
if (!packageIndexKeyStartsWithPrefix(key, eqPrefix)) return null;
|
|
return key;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function encodePluginExportMergedCursor(state: PluginExportMergedCursor): string {
|
|
return `pkgpluginexport:${JSON.stringify({ v: 1, ...state })}`;
|
|
}
|
|
|
|
function parsePluginExportSourceState(value: unknown): PluginExportSourceState | null {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
const record = value as Record<string, unknown>;
|
|
if (record.cursor !== null && typeof record.cursor !== "string") return null;
|
|
if (typeof record.offset !== "number" || record.offset < 0) return null;
|
|
if (record.pageSize !== undefined && typeof record.pageSize !== "number") return null;
|
|
if (typeof record.done !== "boolean") return null;
|
|
return {
|
|
cursor: record.cursor,
|
|
offset: Math.floor(record.offset),
|
|
pageSize: record.pageSize === undefined ? undefined : Math.floor(record.pageSize),
|
|
done: record.done,
|
|
};
|
|
}
|
|
|
|
function decodePluginExportMergedCursor(cursor?: string | null): PluginExportMergedCursor | null {
|
|
if (!cursor) return emptyPluginExportMergedCursor();
|
|
if (!cursor.startsWith("pkgpluginexport:")) return null;
|
|
try {
|
|
const parsed = JSON.parse(cursor.slice("pkgpluginexport:".length)) as unknown;
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
const record = parsed as Record<string, unknown>;
|
|
if (record.v !== 1) return null;
|
|
const codePlugins = parsePluginExportSourceState(record.codePlugins);
|
|
const bundlePlugins = parsePluginExportSourceState(record.bundlePlugins);
|
|
if (!codePlugins || !bundlePlugins) return null;
|
|
return { codePlugins, bundlePlugins };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function comparePluginExportDigests(a: PluginExportDigest, b: PluginExportDigest) {
|
|
const updatedDiff = b.updatedAt - a.updatedAt;
|
|
if (updatedDiff !== 0) return updatedDiff;
|
|
return a.name.localeCompare(b.name);
|
|
}
|
|
|
|
function getPluginExportSourcePageSize(source: PluginExportSourceState, targetCount: number) {
|
|
return Math.min(
|
|
MAX_PLUGIN_EXPORT_LIST_LIMIT,
|
|
Math.max(targetCount, source.pageSize ?? 0, source.offset + targetCount),
|
|
);
|
|
}
|
|
|
|
function finalizePluginExportSourceState(params: {
|
|
source: PluginExportSourceState;
|
|
index: number;
|
|
pageLength: number;
|
|
pageSize: number;
|
|
nextCursor: string | null;
|
|
hasMore: boolean;
|
|
}): PluginExportSourceState {
|
|
if (params.index < params.pageLength) {
|
|
return {
|
|
cursor: params.source.cursor,
|
|
offset: params.index,
|
|
pageSize: params.pageSize,
|
|
done: false,
|
|
};
|
|
}
|
|
return {
|
|
cursor: params.nextCursor,
|
|
offset: 0,
|
|
pageSize: params.pageSize,
|
|
done: !params.hasMore,
|
|
};
|
|
}
|
|
|
|
async function listPluginExportFamilyPage(
|
|
ctx: DbReaderCtx,
|
|
args: {
|
|
family: PluginExportFamily;
|
|
startDate: number;
|
|
endDate: number;
|
|
cursor?: string | null;
|
|
numItems: number;
|
|
},
|
|
): Promise<PluginExportFamilyPage> {
|
|
const indexName = "by_active_family_updated";
|
|
const eqPrefix: IndexKey = [undefined, args.family];
|
|
const decodedCursor = args.cursor
|
|
? decodePackageIndexCursor({
|
|
cursor: args.cursor,
|
|
indexName,
|
|
maxIndexKeyLength: 3,
|
|
eqPrefix,
|
|
})
|
|
: null;
|
|
if (args.cursor && !decodedCursor) {
|
|
throw new Error("Invalid cursor format");
|
|
}
|
|
|
|
const result = await getPage(ctx, {
|
|
table: "packageSearchDigest",
|
|
index: indexName,
|
|
startIndexKey: decodedCursor ?? [undefined, args.family, args.endDate],
|
|
startInclusive: !decodedCursor,
|
|
endIndexKey: [undefined, args.family, args.startDate],
|
|
endInclusive: true,
|
|
order: "desc",
|
|
absoluteMaxRows: args.numItems,
|
|
schema,
|
|
});
|
|
|
|
const page: PluginExportDigest[] = [];
|
|
const membershipCache = new Map<string, Promise<boolean>>();
|
|
for (const digest of result.page) {
|
|
if (!(await canViewerReadPackage(ctx, digest, undefined, membershipCache))) continue;
|
|
const pkg = await ctx.db.get(digest.packageId);
|
|
if (!pkg || pkg.softDeletedAt || pkg.family !== args.family || !pkg.latestReleaseId) continue;
|
|
page.push({
|
|
packageId: digest.packageId,
|
|
name: digest.name,
|
|
displayName: digest.displayName,
|
|
family: args.family,
|
|
latestReleaseId: pkg.latestReleaseId,
|
|
latestVersion: digest.latestVersion ?? pkg.latestVersionSummary?.version ?? null,
|
|
createdAt: digest.createdAt,
|
|
updatedAt: digest.updatedAt,
|
|
stats: pkg.stats ?? digest.stats ?? null,
|
|
ownerUserId: digest.ownerUserId,
|
|
ownerHandle: digest.ownerHandle ?? null,
|
|
});
|
|
}
|
|
|
|
const nextCursor =
|
|
result.hasMore && result.indexKeys.length > 0
|
|
? encodePackageIndexCursor(indexName, result.indexKeys[result.indexKeys.length - 1])
|
|
: null;
|
|
return { page, nextCursor, hasMore: result.hasMore };
|
|
}
|
|
|
|
async function listMergedPluginExportPage(
|
|
ctx: DbReaderCtx,
|
|
args: {
|
|
startDate: number;
|
|
endDate: number;
|
|
cursor?: string | null;
|
|
numItems: number;
|
|
},
|
|
) {
|
|
const decodedCursor = decodePluginExportMergedCursor(args.cursor);
|
|
if (!decodedCursor) throw new Error("Invalid cursor format");
|
|
|
|
const codePageSize = getPluginExportSourcePageSize(decodedCursor.codePlugins, args.numItems);
|
|
const bundlePageSize = getPluginExportSourcePageSize(decodedCursor.bundlePlugins, args.numItems);
|
|
const [codePlugins, bundlePlugins] = await Promise.all([
|
|
decodedCursor.codePlugins.done
|
|
? Promise.resolve({
|
|
page: [],
|
|
nextCursor: null,
|
|
hasMore: false,
|
|
} satisfies PluginExportFamilyPage)
|
|
: listPluginExportFamilyPage(ctx, {
|
|
family: "code-plugin",
|
|
startDate: args.startDate,
|
|
endDate: args.endDate,
|
|
cursor: decodedCursor.codePlugins.cursor,
|
|
numItems: codePageSize,
|
|
}),
|
|
decodedCursor.bundlePlugins.done
|
|
? Promise.resolve({
|
|
page: [],
|
|
nextCursor: null,
|
|
hasMore: false,
|
|
} satisfies PluginExportFamilyPage)
|
|
: listPluginExportFamilyPage(ctx, {
|
|
family: "bundle-plugin",
|
|
startDate: args.startDate,
|
|
endDate: args.endDate,
|
|
cursor: decodedCursor.bundlePlugins.cursor,
|
|
numItems: bundlePageSize,
|
|
}),
|
|
]);
|
|
|
|
let codeIndex = decodedCursor.codePlugins.offset;
|
|
let bundleIndex = decodedCursor.bundlePlugins.offset;
|
|
const page: PluginExportDigest[] = [];
|
|
while (page.length < args.numItems) {
|
|
const codeCandidate = codePlugins.page[codeIndex];
|
|
const bundleCandidate = bundlePlugins.page[bundleIndex];
|
|
if (!codeCandidate && !bundleCandidate) break;
|
|
if (
|
|
!bundleCandidate ||
|
|
(codeCandidate && comparePluginExportDigests(codeCandidate, bundleCandidate) <= 0)
|
|
) {
|
|
page.push(codeCandidate);
|
|
codeIndex += 1;
|
|
} else {
|
|
page.push(bundleCandidate);
|
|
bundleIndex += 1;
|
|
}
|
|
}
|
|
|
|
const nextState: PluginExportMergedCursor = {
|
|
codePlugins: finalizePluginExportSourceState({
|
|
source: decodedCursor.codePlugins,
|
|
index: codeIndex,
|
|
pageLength: codePlugins.page.length,
|
|
pageSize: codePageSize,
|
|
nextCursor: codePlugins.nextCursor,
|
|
hasMore: codePlugins.hasMore,
|
|
}),
|
|
bundlePlugins: finalizePluginExportSourceState({
|
|
source: decodedCursor.bundlePlugins,
|
|
index: bundleIndex,
|
|
pageLength: bundlePlugins.page.length,
|
|
pageSize: bundlePageSize,
|
|
nextCursor: bundlePlugins.nextCursor,
|
|
hasMore: bundlePlugins.hasMore,
|
|
}),
|
|
};
|
|
const isDone =
|
|
nextState.codePlugins.done &&
|
|
nextState.codePlugins.offset === 0 &&
|
|
nextState.bundlePlugins.done &&
|
|
nextState.bundlePlugins.offset === 0;
|
|
return {
|
|
page,
|
|
nextCursor: isDone ? null : encodePluginExportMergedCursor(nextState),
|
|
hasMore: !isDone,
|
|
};
|
|
}
|
|
|
|
export const listPluginExportPageInternal = internalQuery({
|
|
args: {
|
|
startDate: v.number(),
|
|
endDate: v.number(),
|
|
cursor: v.optional(v.string()),
|
|
numItems: v.optional(v.number()),
|
|
family: v.optional(v.union(v.literal("code-plugin"), v.literal("bundle-plugin"))),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const numItems = Math.max(
|
|
1,
|
|
Math.min(args.numItems ?? MAX_PLUGIN_EXPORT_LIST_LIMIT, MAX_PLUGIN_EXPORT_LIST_LIMIT),
|
|
);
|
|
if (args.family) {
|
|
return await listPluginExportFamilyPage(ctx, {
|
|
family: args.family,
|
|
startDate: args.startDate,
|
|
endDate: args.endDate,
|
|
cursor: args.cursor,
|
|
numItems,
|
|
});
|
|
}
|
|
return await listMergedPluginExportPage(ctx, {
|
|
startDate: args.startDate,
|
|
endDate: args.endDate,
|
|
cursor: args.cursor,
|
|
numItems,
|
|
});
|
|
},
|
|
});
|
|
|
|
function normalizePackageValidationFinding(warning: Doc<"packageInspectorWarnings">) {
|
|
const severity =
|
|
warning.findingKind === "error" ||
|
|
warning.level === "breakage" ||
|
|
warning.level === "error" ||
|
|
warning.severity === "P0"
|
|
? ("error" as const)
|
|
: warning.severity?.toLowerCase() === "info"
|
|
? ("info" as const)
|
|
: ("warning" as const);
|
|
return { severity, code: warning.code, message: warning.message };
|
|
}
|
|
|
|
export const listPluginValidationReportPageInternal = internalQuery({
|
|
args: {
|
|
cursor: v.optional(v.string()),
|
|
numItems: v.optional(v.number()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const numItems = Math.max(
|
|
1,
|
|
Math.min(
|
|
args.numItems ?? MAX_PLUGIN_VALIDATION_REPORT_PAGE_SIZE,
|
|
MAX_PLUGIN_VALIDATION_REPORT_PAGE_SIZE,
|
|
),
|
|
);
|
|
const result = await listMergedPluginExportPage(ctx, {
|
|
startDate: 0,
|
|
endDate: Number.MAX_SAFE_INTEGER,
|
|
cursor: args.cursor,
|
|
numItems,
|
|
});
|
|
const items = [];
|
|
for (const digest of result.page) {
|
|
if (!digest.latestReleaseId) continue;
|
|
const release = await ctx.db.get(digest.latestReleaseId);
|
|
if (!release || release.softDeletedAt || release.packageId !== digest.packageId) continue;
|
|
const scanState = await ctx.db
|
|
.query("packageInspectorScanStates")
|
|
.withIndex("by_release_and_completed_at", (q) => q.eq("releaseId", release._id))
|
|
.order("desc")
|
|
.first();
|
|
// CLAW-626 reconciles nightly findings per release and preserves publish/static findings.
|
|
// Matching the selected tuple also fails closed if stale nightly rows survive a partial rollout.
|
|
const storedWarnings = await ctx.db
|
|
.query("packageInspectorWarnings")
|
|
.withIndex("by_release_created", (q) => q.eq("releaseId", release._id))
|
|
.order("asc")
|
|
.collect();
|
|
const warnings = storedWarnings.filter(
|
|
(warning) =>
|
|
warning.scanSource !== "nightly" ||
|
|
(scanState !== null &&
|
|
warning.inspectorVersion === scanState.inspectorVersion &&
|
|
warning.targetOpenClawVersion === scanState.targetOpenClawVersion),
|
|
);
|
|
const findings = warnings.map(normalizePackageValidationFinding);
|
|
const status = findings.some((finding) => finding.severity === "error")
|
|
? ("error" as const)
|
|
: findings.length > 0
|
|
? ("warning" as const)
|
|
: !scanState
|
|
? ("not-scanned" as const)
|
|
: ("clean" as const);
|
|
items.push({
|
|
package: {
|
|
id: String(digest.packageId),
|
|
name: digest.name,
|
|
displayName: digest.displayName,
|
|
},
|
|
release: {
|
|
id: String(release._id),
|
|
version: release.version,
|
|
createdAt: release.createdAt,
|
|
},
|
|
references: {
|
|
packagePage: `/plugins/${encodeURIComponent(digest.name)}`,
|
|
release: `${digest.name}@${release.version}`,
|
|
},
|
|
scan: {
|
|
status,
|
|
scannedAt: scanState?.completedAt ?? null,
|
|
target: scanState
|
|
? { channel: "beta" as const, version: scanState.targetOpenClawVersion }
|
|
: null,
|
|
inspectorVersion: scanState?.inspectorVersion ?? null,
|
|
skipReason: null,
|
|
},
|
|
findings,
|
|
});
|
|
}
|
|
return {
|
|
items,
|
|
nextCursor: result.nextCursor,
|
|
done: !result.hasMore,
|
|
};
|
|
},
|
|
});
|
|
|
|
export const listPageForViewerInternal = internalQuery({
|
|
args: {
|
|
family: v.optional(
|
|
v.union(
|
|
v.literal("skill"),
|
|
v.literal("code-plugin"),
|
|
v.literal("bundle-plugin"),
|
|
v.literal("claw"),
|
|
),
|
|
),
|
|
families: v.optional(
|
|
v.array(v.union(v.literal("skill"), v.literal("code-plugin"), v.literal("bundle-plugin"))),
|
|
),
|
|
channel: v.optional(
|
|
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
|
|
),
|
|
isOfficial: v.optional(v.boolean()),
|
|
highlightedOnly: v.optional(v.boolean()),
|
|
category: v.optional(v.string()),
|
|
topic: v.optional(v.string()),
|
|
officialFirst: v.optional(v.boolean()),
|
|
excludedScanStatuses: v.optional(v.array(packageListScanStatusValidator)),
|
|
sort: v.optional(
|
|
v.union(
|
|
v.literal("updated"),
|
|
v.literal("downloads"),
|
|
v.literal("recommended"),
|
|
v.literal("installs"),
|
|
v.literal("trending"),
|
|
),
|
|
),
|
|
viewerUserId: v.optional(v.id("users")),
|
|
paginationOpts: paginationOptsValidator,
|
|
},
|
|
handler: async (ctx, args) => {
|
|
return await listPackagePageImpl(ctx, args);
|
|
},
|
|
});
|
|
|
|
export const countPublicPluginsInternal = internalQuery({
|
|
args: {},
|
|
handler: async (ctx) => {
|
|
return await readGlobalPublicPluginsCount(ctx);
|
|
},
|
|
});
|
|
|
|
export const hasMissingRecommendationScoresInternal = internalQuery({
|
|
args: {
|
|
families: v.optional(
|
|
v.array(v.union(v.literal("skill"), v.literal("code-plugin"), v.literal("bundle-plugin"))),
|
|
),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
if (!args.families || args.families.length === 0) {
|
|
return await hasMissingPackageRecommendedScore(ctx, undefined);
|
|
}
|
|
for (const family of args.families) {
|
|
if (await hasMissingPackageRecommendedScore(ctx, family)) return true;
|
|
}
|
|
return false;
|
|
},
|
|
});
|
|
|
|
export const countPublicPlugins = query({
|
|
args: {},
|
|
handler: async (ctx) => {
|
|
const statsCount = await readGlobalPublicPluginsCount(ctx);
|
|
return statsCount ?? 0;
|
|
},
|
|
});
|
|
|
|
function compareStablePackageDiscoveryCandidates(
|
|
a: PackageDigestLike,
|
|
b: PackageDigestLike,
|
|
sort: "updated" | "created" | "downloads" | "recommended" | "installs",
|
|
) {
|
|
const metric = (candidate: PackageDigestLike) => {
|
|
if (sort === "downloads") return candidate.stats?.downloads ?? 0;
|
|
if (sort === "installs") return candidate.stats?.installs ?? 0;
|
|
if (sort === "recommended") return candidate.recommendedScore ?? 0;
|
|
if (sort === "created") return candidate.createdAt;
|
|
return candidate.updatedAt;
|
|
};
|
|
const metricDiff = metric(b) - metric(a);
|
|
if (metricDiff !== 0) return metricDiff;
|
|
const updatedDiff = b.updatedAt - a.updatedAt;
|
|
if (updatedDiff !== 0) return updatedDiff;
|
|
const familyDiff = a.family.localeCompare(b.family);
|
|
if (familyDiff !== 0) return familyDiff;
|
|
return a.name.localeCompare(b.name);
|
|
}
|
|
|
|
async function listStablePackageDiscoveryPage(
|
|
ctx: DbReaderCtx,
|
|
args: {
|
|
families?: StablePackageFamily[];
|
|
channel?: PackageChannel;
|
|
isOfficial?: boolean;
|
|
category?: PluginCategorySlug;
|
|
topic?: string;
|
|
excludedScanStatuses?: PackageListScanStatus[];
|
|
sort?: "updated" | "created" | "downloads" | "recommended" | "installs";
|
|
createdAfter?: number;
|
|
viewerUserId?: Id<"users">;
|
|
paginationOpts: { cursor: string | null; numItems: number };
|
|
},
|
|
): Promise<PublicPackageListPage> {
|
|
const targetCount = Math.max(
|
|
1,
|
|
Math.min(args.paginationOpts.numItems, MAX_PUBLIC_LIST_PAGE_SIZE),
|
|
);
|
|
const requestedSort = args.sort ?? "updated";
|
|
const cursorSort =
|
|
requestedSort === "recommended"
|
|
? readStablePackageDiscoveryCursorSort(args.paginationOpts.cursor)
|
|
: null;
|
|
let sort = cursorSort ?? requestedSort;
|
|
if (!cursorSort && requestedSort === "recommended") {
|
|
const recommendationScoresMissing = (
|
|
await Promise.all(
|
|
STABLE_PACKAGE_FAMILIES.map(
|
|
async (family) => await hasMissingPackageRecommendedScore(ctx, family),
|
|
),
|
|
)
|
|
).some(Boolean);
|
|
if (recommendationScoresMissing) sort = "updated";
|
|
}
|
|
const cursor = decodeStablePackageDiscoveryCursor(args.paginationOpts.cursor, sort);
|
|
const families = args.families ?? [...STABLE_PACKAGE_FAMILIES];
|
|
for (const family of STABLE_PACKAGE_FAMILIES) {
|
|
if (!families.includes(family)) cursor.sources[family].done = true;
|
|
}
|
|
const scanLimit = Math.min(MAX_PUBLIC_LIST_PAGE_SIZE, Math.max(targetCount * 5, 50));
|
|
const loadFamily = async (family: StablePackageFamily) => {
|
|
const state = cursor.sources[family];
|
|
if (state.done) {
|
|
return { family, rows: [] as PackageDigestLike[], keys: [] as IndexKey[], hasMore: false };
|
|
}
|
|
const eqPrefix: IndexKey = [undefined, family];
|
|
if (sort === "updated" || sort === "created") {
|
|
const index = sort === "created" ? "by_active_family_created" : "by_active_family_updated";
|
|
const result = await getPage(ctx, {
|
|
table: "packageSearchDigest",
|
|
index,
|
|
startIndexKey: state.key ?? eqPrefix,
|
|
startInclusive: state.key === null,
|
|
endIndexKey:
|
|
sort === "created" && args.createdAfter !== undefined
|
|
? [undefined, family, args.createdAfter]
|
|
: eqPrefix,
|
|
endInclusive: true,
|
|
order: "desc",
|
|
absoluteMaxRows: scanLimit,
|
|
schema,
|
|
});
|
|
return {
|
|
family,
|
|
rows: result.page as PackageDigestLike[],
|
|
keys: result.indexKeys,
|
|
hasMore: result.hasMore,
|
|
};
|
|
}
|
|
const index =
|
|
sort === "downloads"
|
|
? "by_active_family_downloads"
|
|
: sort === "installs"
|
|
? "by_active_family_installs"
|
|
: "by_active_family_recommended_score";
|
|
const result = await getPage(ctx, {
|
|
table: "packages",
|
|
index,
|
|
startIndexKey: state.key ?? eqPrefix,
|
|
startInclusive: state.key === null,
|
|
endIndexKey: eqPrefix,
|
|
endInclusive: true,
|
|
order: "desc",
|
|
absoluteMaxRows: scanLimit,
|
|
schema,
|
|
});
|
|
return {
|
|
family,
|
|
rows: result.page.map(extractPackageDigestFields),
|
|
keys: result.indexKeys,
|
|
hasMore: result.hasMore,
|
|
};
|
|
};
|
|
const familyPages = await Promise.all(families.map(async (family) => await loadFamily(family)));
|
|
const membershipCache = new Map<string, Promise<boolean>>();
|
|
const page: PublicPackageListItem[] = [];
|
|
const consumedByFamily = new Map<StablePackageFamily, number>();
|
|
while (page.length < targetCount) {
|
|
// Merge only the current head from each source so every persisted source
|
|
// cursor advances through a contiguous prefix of that source's index order.
|
|
const candidate = familyPages
|
|
.flatMap((source) => {
|
|
const index = consumedByFamily.get(source.family) ?? 0;
|
|
const row = source.rows[index];
|
|
return row ? [{ family: source.family, row, key: source.keys[index] }] : [];
|
|
})
|
|
.sort((a, b) => compareStablePackageDiscoveryCandidates(a.row, b.row, sort))[0];
|
|
if (!candidate) break;
|
|
const digest = candidate.row;
|
|
consumedByFamily.set(candidate.family, (consumedByFamily.get(candidate.family) ?? 0) + 1);
|
|
cursor.sources[candidate.family].key = candidate.key;
|
|
if (!digestMatchesSearchFilters(digest, args)) continue;
|
|
if (!(await canViewerReadPackage(ctx, digest, args.viewerUserId, membershipCache))) continue;
|
|
page.push(await toPublicPackageListItem(ctx, digest));
|
|
}
|
|
for (const source of familyPages) {
|
|
const consumed = consumedByFamily.get(source.family) ?? 0;
|
|
cursor.sources[source.family].done = consumed === source.rows.length && !source.hasMore;
|
|
}
|
|
const hasMore = families.some((family) => !cursor.sources[family].done);
|
|
return {
|
|
page,
|
|
isDone: !hasMore,
|
|
continueCursor: hasMore ? encodeStablePackageDiscoveryCursor(cursor) : "",
|
|
};
|
|
}
|
|
|
|
async function listPackagePageImpl(
|
|
ctx: DbReaderCtx,
|
|
args: {
|
|
family?: PackageFamily;
|
|
families?: PackageFamily[];
|
|
channel?: PackageChannel;
|
|
isOfficial?: boolean;
|
|
highlightedOnly?: boolean;
|
|
category?: string;
|
|
topic?: string;
|
|
officialFirst?: boolean;
|
|
excludedScanStatuses?: PackageListScanStatus[];
|
|
sort?: "updated" | "downloads" | "recommended" | "installs" | "trending";
|
|
viewerUserId?: Id<"users">;
|
|
paginationOpts: { cursor: string | null; numItems: number };
|
|
},
|
|
): Promise<PublicPackageListPage> {
|
|
if (args.channel === "private" && !args.viewerUserId) {
|
|
return { page: [], isDone: true, continueCursor: "" };
|
|
}
|
|
if (args.families?.length && !args.highlightedOnly) {
|
|
throw new Error("families is only supported for highlighted package pages");
|
|
}
|
|
if (args.category && !isPluginCategorySlug(args.category)) {
|
|
return { page: [], isDone: true, continueCursor: "" };
|
|
}
|
|
const viewerUserId = args.viewerUserId;
|
|
const membershipCache = new Map<string, Promise<boolean>>();
|
|
const canViewPackage = async (digest: PackageDigestLike) =>
|
|
await canViewerReadPackage(ctx, digest, viewerUserId, membershipCache);
|
|
const targetCount = args.paginationOpts.numItems;
|
|
const category = isPluginCategorySlug(args.category) ? args.category : undefined;
|
|
const topic = args.topic ? normalizeCatalogTopic(args.topic) : undefined;
|
|
const hasCatalogMetadataFilter = Boolean(category || topic);
|
|
if (args.topic !== undefined && !topic) {
|
|
return { page: [], isDone: true, continueCursor: "" };
|
|
}
|
|
|
|
if (args.sort === "trending") {
|
|
const leaderboard = await ctx.db
|
|
.query("packageLeaderboards")
|
|
.withIndex("by_kind", (q) => q.eq("kind", PACKAGE_TRENDING_LEADERBOARD_KIND))
|
|
.order("desc")
|
|
.first();
|
|
if (!leaderboard) return { page: [], isDone: true, continueCursor: "" };
|
|
|
|
const cursorState = decodePublicPageCursor(args.paginationOpts.cursor);
|
|
const startIndex = cursorState.sort === "trending" ? cursorState.offset : 0;
|
|
const page: PublicPackageListItem[] = [];
|
|
let nextOffset = startIndex;
|
|
for (let index = startIndex; index < leaderboard.items.length; index += 1) {
|
|
const entry = leaderboard.items[index];
|
|
nextOffset = index + 1;
|
|
const pkg = await ctx.db.get(entry.packageId);
|
|
if (!pkg || pkg.softDeletedAt) continue;
|
|
if (!(await canViewerReadPackage(ctx, pkg, viewerUserId, membershipCache))) continue;
|
|
if (!packageMatchesListFilters(pkg, { ...args, category, topic })) continue;
|
|
page.push(await toPublicPackageListItemFromPackage(ctx, pkg));
|
|
if (page.length >= targetCount) break;
|
|
}
|
|
const isDone = nextOffset >= leaderboard.items.length;
|
|
return {
|
|
page,
|
|
isDone,
|
|
continueCursor: isDone
|
|
? ""
|
|
: encodePublicPageCursor({
|
|
cursor: null,
|
|
offset: nextOffset,
|
|
pageSize: targetCount,
|
|
done: false,
|
|
mode: "packages",
|
|
sort: "trending",
|
|
}),
|
|
};
|
|
}
|
|
|
|
if (args.officialFirst && category && typeof args.isOfficial !== "boolean") {
|
|
return await listOfficialFirstPackageCategoryPage(ctx, {
|
|
...args,
|
|
category,
|
|
topic,
|
|
});
|
|
}
|
|
|
|
if (args.highlightedOnly) {
|
|
const page = await fetchHighlightedPackagePage(ctx, {
|
|
...args,
|
|
category,
|
|
topic,
|
|
numItems: targetCount,
|
|
});
|
|
return { page, isDone: true, continueCursor: "" };
|
|
}
|
|
|
|
if (!args.family && !experimentalClawsEnabled()) {
|
|
return await listStablePackageDiscoveryPage(ctx, {
|
|
channel: args.channel,
|
|
isOfficial: args.isOfficial,
|
|
category,
|
|
topic,
|
|
excludedScanStatuses: args.excludedScanStatuses,
|
|
sort: args.sort,
|
|
viewerUserId: args.viewerUserId,
|
|
paginationOpts: args.paginationOpts,
|
|
});
|
|
}
|
|
|
|
const collected: PublicPackageListItem[] = [];
|
|
const family = args.family;
|
|
const channel = args.channel;
|
|
const isOfficial = args.isOfficial;
|
|
const decodedCursor = decodePublicPageCursor(args.paginationOpts.cursor);
|
|
if (decodedCursor.done && decodedCursor.offset === 0) {
|
|
return { page: collected, isDone: true, continueCursor: "" };
|
|
}
|
|
const pageCursor = decodedCursor.cursor;
|
|
const offset = decodedCursor.offset;
|
|
const sortedPackageIndex =
|
|
family &&
|
|
args.sort === "downloads" &&
|
|
typeof isOfficial === "boolean" &&
|
|
(!args.paginationOpts.cursor || decodedCursor.packageIndex === "family-official-downloads")
|
|
? ("family-official-downloads" as const)
|
|
: undefined;
|
|
const effectivePageSize = Math.min(
|
|
MAX_PUBLIC_LIST_PAGE_SIZE,
|
|
Math.max(
|
|
targetCount,
|
|
decodedCursor.pageSize ?? 0,
|
|
offset > 0 ? offset + targetCount : targetCount,
|
|
),
|
|
);
|
|
|
|
const keepDigestCursor = args.sort === "recommended" && decodedCursor.mode === "digest";
|
|
const keepRecommendedPackageCursor =
|
|
args.sort === "recommended" &&
|
|
Boolean(args.paginationOpts.cursor) &&
|
|
decodedCursor.mode !== "digest";
|
|
const recommendedIndexName =
|
|
args.sort === "recommended" && !keepDigestCursor
|
|
? keepRecommendedPackageCursor
|
|
? getPackageRecommendedScoreIndexName(family)
|
|
: await getPackageRecommendedIndexName(ctx, family)
|
|
: null;
|
|
// Digest cursors created before sort persistence always came from updated indexes.
|
|
const effectiveDigestSort =
|
|
decodedCursor.mode === "digest"
|
|
? (decodedCursor.sort ?? "updated")
|
|
: args.sort === "recommended"
|
|
? recommendedIndexName
|
|
? "recommended"
|
|
: "updated"
|
|
: args.sort;
|
|
|
|
if (
|
|
!hasCatalogMetadataFilter &&
|
|
(args.sort === "downloads" || args.sort === "installs" || recommendedIndexName)
|
|
) {
|
|
let cursor = pageCursor;
|
|
let pageOffset = offset;
|
|
let pageSize: number | null = decodedCursor.pageSize ?? null;
|
|
let done = decodedCursor.done;
|
|
const buildSortedQuery = () => {
|
|
if (family) {
|
|
if (sortedPackageIndex === "family-official-downloads" && typeof isOfficial === "boolean") {
|
|
return ctx.db
|
|
.query("packages")
|
|
.withIndex("by_active_family_official_downloads", (q) =>
|
|
q.eq("softDeletedAt", undefined).eq("family", family).eq("isOfficial", isOfficial),
|
|
);
|
|
}
|
|
if (args.sort === "installs" && typeof isOfficial === "boolean") {
|
|
return ctx.db
|
|
.query("packages")
|
|
.withIndex("by_active_family_official_installs", (q) =>
|
|
q.eq("softDeletedAt", undefined).eq("family", family).eq("isOfficial", isOfficial),
|
|
);
|
|
}
|
|
const indexName =
|
|
args.sort === "installs"
|
|
? "by_active_family_installs"
|
|
: (recommendedIndexName ?? "by_active_family_downloads");
|
|
return ctx.db
|
|
.query("packages")
|
|
.withIndex(indexName, (q) => q.eq("softDeletedAt", undefined).eq("family", family));
|
|
}
|
|
const indexName =
|
|
args.sort === "installs"
|
|
? "by_active_installs"
|
|
: (recommendedIndexName ?? "by_active_downloads");
|
|
return ctx.db.query("packages").withIndex(indexName, (q) => q.eq("softDeletedAt", undefined));
|
|
};
|
|
|
|
if (pageOffset > 0 || !done) {
|
|
const scanPageSize = Math.min(
|
|
MAX_PUBLIC_LIST_PAGE_SIZE,
|
|
pageOffset > 0 && pageSize
|
|
? Math.max(pageSize, pageOffset + targetCount)
|
|
: Math.max(targetCount * 5, targetCount, 50),
|
|
);
|
|
const currentCursor = cursor;
|
|
const page = await buildSortedQuery()
|
|
.order("desc")
|
|
.paginate({ cursor: currentCursor, numItems: scanPageSize });
|
|
|
|
for (let index = pageOffset; index < page.page.length; index += 1) {
|
|
const pkg = page.page[index];
|
|
if (!(await canViewerReadPackage(ctx, pkg, viewerUserId, membershipCache))) continue;
|
|
if (!packageMatchesListFilters(pkg, { ...args, category, topic })) continue;
|
|
collected.push(await toPublicPackageListItemFromPackage(ctx, pkg));
|
|
if (collected.length >= targetCount) {
|
|
const nextOffset = index + 1;
|
|
const nextState =
|
|
nextOffset < page.page.length
|
|
? {
|
|
cursor: currentCursor,
|
|
offset: nextOffset,
|
|
pageSize: scanPageSize,
|
|
done: page.isDone,
|
|
mode: "packages" as const,
|
|
packageIndex: sortedPackageIndex,
|
|
}
|
|
: {
|
|
cursor: page.continueCursor,
|
|
offset: 0,
|
|
pageSize: scanPageSize,
|
|
done: page.isDone,
|
|
mode: "packages" as const,
|
|
packageIndex: sortedPackageIndex,
|
|
};
|
|
return {
|
|
page: collected,
|
|
isDone: nextState.done && nextState.offset === 0,
|
|
continueCursor: encodePublicPageCursor(nextState),
|
|
};
|
|
}
|
|
}
|
|
|
|
done = page.isDone;
|
|
cursor = page.continueCursor;
|
|
pageOffset = 0;
|
|
pageSize = scanPageSize;
|
|
}
|
|
|
|
return {
|
|
page: collected,
|
|
isDone: done,
|
|
continueCursor: encodePublicPageCursor({
|
|
cursor,
|
|
offset: pageOffset,
|
|
pageSize,
|
|
done,
|
|
mode: "packages",
|
|
packageIndex: sortedPackageIndex,
|
|
}),
|
|
};
|
|
}
|
|
|
|
const buildDigestQuery = () =>
|
|
topic
|
|
? buildPackageTopicDigestQuery(ctx, {
|
|
topic,
|
|
family,
|
|
channel,
|
|
isOfficial,
|
|
sort: effectiveDigestSort,
|
|
})
|
|
: category
|
|
? buildPackagePluginCategoryDigestQuery(ctx, {
|
|
category,
|
|
family,
|
|
channel,
|
|
isOfficial,
|
|
sort: effectiveDigestSort,
|
|
})
|
|
: buildPackageDigestQuery(ctx, {
|
|
family,
|
|
channel,
|
|
isOfficial,
|
|
});
|
|
let cursor = pageCursor;
|
|
let pageOffset = offset;
|
|
let pageSize: number | null = decodedCursor.pageSize ?? null;
|
|
let done = decodedCursor.done;
|
|
const requiresDigestPostFilterScan =
|
|
hasCatalogMetadataFilter || Boolean(args.excludedScanStatuses?.length);
|
|
let digestScanPages = 0;
|
|
let remainingDigestScanBudget = requiresDigestPostFilterScan
|
|
? MAX_PUBLIC_LIST_FILTER_SCAN_DOCUMENTS
|
|
: MAX_PUBLIC_LIST_PAGE_SIZE;
|
|
|
|
if (
|
|
(pageOffset > 0 || !done) &&
|
|
collected.length < targetCount &&
|
|
digestScanPages < MAX_PUBLIC_LIST_FILTER_SCAN_PAGES &&
|
|
remainingDigestScanBudget > 0
|
|
) {
|
|
const scanPageSize = Math.min(
|
|
remainingDigestScanBudget,
|
|
MAX_PUBLIC_LIST_PAGE_SIZE,
|
|
pageOffset > 0 && pageSize
|
|
? Math.max(pageSize, pageOffset + targetCount)
|
|
: Math.max(effectivePageSize, targetCount),
|
|
);
|
|
if (scanPageSize > 0) {
|
|
digestScanPages += 1;
|
|
remainingDigestScanBudget -= scanPageSize;
|
|
const currentCursor = cursor;
|
|
const page: {
|
|
page: PackageDigestLike[];
|
|
isDone: boolean;
|
|
continueCursor: string;
|
|
} = await buildDigestQuery()
|
|
.order("desc")
|
|
.paginate({ cursor: currentCursor, numItems: scanPageSize });
|
|
|
|
for (let index = pageOffset; index < page.page.length; index += 1) {
|
|
const digest = page.page[index] as PackageDigestLike;
|
|
if (!(await canViewPackage(digest))) continue;
|
|
if (family && digest.family !== family) continue;
|
|
if (channel && digest.channel !== channel) continue;
|
|
if (typeof isOfficial === "boolean" && digest.isOfficial !== isOfficial) {
|
|
continue;
|
|
}
|
|
if (!digestMatchesFilters(digest, { ...args, category, topic })) continue;
|
|
collected.push(await toPublicPackageListItem(ctx, digest));
|
|
if (collected.length >= targetCount) {
|
|
const nextOffset = index + 1;
|
|
const nextState =
|
|
nextOffset < page.page.length
|
|
? {
|
|
cursor: currentCursor,
|
|
offset: nextOffset,
|
|
pageSize: scanPageSize,
|
|
done: page.isDone,
|
|
mode: "digest" as const,
|
|
sort: effectiveDigestSort,
|
|
}
|
|
: {
|
|
cursor: page.continueCursor,
|
|
offset: 0,
|
|
pageSize: scanPageSize,
|
|
done: page.isDone,
|
|
mode: "digest" as const,
|
|
sort: effectiveDigestSort,
|
|
};
|
|
return {
|
|
page: collected,
|
|
isDone: nextState.done && nextState.offset === 0,
|
|
continueCursor: encodePublicPageCursor(nextState),
|
|
};
|
|
}
|
|
}
|
|
|
|
done = page.isDone;
|
|
cursor = page.continueCursor;
|
|
pageOffset = 0;
|
|
pageSize = scanPageSize;
|
|
}
|
|
}
|
|
|
|
return {
|
|
page: collected,
|
|
isDone: done,
|
|
continueCursor: encodePublicPageCursor({
|
|
cursor,
|
|
offset: pageOffset,
|
|
pageSize,
|
|
done,
|
|
mode: "digest",
|
|
sort: effectiveDigestSort,
|
|
}),
|
|
};
|
|
}
|
|
|
|
async function listOfficialFirstPackageCategoryPage(
|
|
ctx: DbReaderCtx,
|
|
args: {
|
|
family?: PackageFamily;
|
|
channel?: PackageChannel;
|
|
highlightedOnly?: boolean;
|
|
category: PluginCategorySlug;
|
|
topic?: string;
|
|
excludedScanStatuses?: PackageListScanStatus[];
|
|
sort?: "updated" | "downloads" | "recommended" | "installs" | "trending";
|
|
viewerUserId?: Id<"users">;
|
|
paginationOpts: { cursor: string | null; numItems: number };
|
|
},
|
|
): Promise<PublicPackageListPage> {
|
|
const state = decodeOfficialFirstPackageCategoryCursor(args.paginationOpts.cursor);
|
|
const targetCount = args.paginationOpts.numItems;
|
|
const collected: PublicPackageListItem[] = [];
|
|
|
|
if (state.phase === "official") {
|
|
const officialPage = await listPackagePageImpl(ctx, {
|
|
...args,
|
|
officialFirst: false,
|
|
isOfficial: true,
|
|
paginationOpts: {
|
|
cursor: state.cursor,
|
|
numItems: targetCount,
|
|
},
|
|
});
|
|
collected.push(...officialPage.page);
|
|
if (!officialPage.isDone) {
|
|
return {
|
|
page: collected,
|
|
isDone: false,
|
|
continueCursor: encodeOfficialFirstPackageCategoryCursor({
|
|
phase: "official",
|
|
cursor: officialPage.continueCursor,
|
|
}),
|
|
};
|
|
}
|
|
if (collected.length >= targetCount) {
|
|
const mayHaveCommunityPage = args.highlightedOnly
|
|
? (
|
|
await listPackagePageImpl(ctx, {
|
|
...args,
|
|
officialFirst: false,
|
|
isOfficial: false,
|
|
paginationOpts: {
|
|
cursor: null,
|
|
numItems: 1,
|
|
},
|
|
})
|
|
).page.length > 0
|
|
: await mayHaveVisiblePackageCategoryDigest(ctx, {
|
|
...args,
|
|
isOfficial: false,
|
|
});
|
|
return {
|
|
page: collected,
|
|
isDone: !mayHaveCommunityPage,
|
|
continueCursor: mayHaveCommunityPage
|
|
? encodeOfficialFirstPackageCategoryCursor({
|
|
phase: "community",
|
|
cursor: null,
|
|
})
|
|
: "",
|
|
};
|
|
}
|
|
const communityPage = args.highlightedOnly
|
|
? await listPackagePageImpl(ctx, {
|
|
...args,
|
|
officialFirst: false,
|
|
isOfficial: false,
|
|
paginationOpts: {
|
|
cursor: null,
|
|
numItems: targetCount - collected.length,
|
|
},
|
|
})
|
|
: await takeVisiblePackageCategoryDigestPage(ctx, {
|
|
...args,
|
|
isOfficial: false,
|
|
numItems: targetCount - collected.length,
|
|
});
|
|
collected.push(...communityPage.page);
|
|
return {
|
|
page: collected,
|
|
isDone: communityPage.isDone,
|
|
continueCursor: communityPage.isDone
|
|
? ""
|
|
: encodeOfficialFirstPackageCategoryCursor({
|
|
phase: "community",
|
|
cursor: communityPage.continueCursor,
|
|
}),
|
|
};
|
|
}
|
|
|
|
const communityPage = await listPackagePageImpl(ctx, {
|
|
...args,
|
|
officialFirst: false,
|
|
isOfficial: false,
|
|
paginationOpts: {
|
|
cursor: state.phase === "community" ? state.cursor : null,
|
|
numItems: targetCount - collected.length,
|
|
},
|
|
});
|
|
collected.push(...communityPage.page);
|
|
return {
|
|
page: collected,
|
|
isDone: communityPage.isDone,
|
|
continueCursor: communityPage.isDone
|
|
? ""
|
|
: encodeOfficialFirstPackageCategoryCursor({
|
|
phase: "community",
|
|
cursor: communityPage.continueCursor,
|
|
}),
|
|
};
|
|
}
|
|
|
|
export const searchPublic = query({
|
|
args: {
|
|
query: v.string(),
|
|
limit: v.optional(v.number()),
|
|
family: v.optional(
|
|
v.union(
|
|
v.literal("skill"),
|
|
v.literal("code-plugin"),
|
|
v.literal("bundle-plugin"),
|
|
v.literal("claw"),
|
|
),
|
|
),
|
|
channel: v.optional(
|
|
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
|
|
),
|
|
isOfficial: v.optional(v.boolean()),
|
|
highlightedOnly: v.optional(v.boolean()),
|
|
category: v.optional(v.string()),
|
|
topic: v.optional(v.string()),
|
|
excludedScanStatuses: v.optional(v.array(packageListScanStatusValidator)),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
return (await searchPackagesImpl(ctx, args)).map(toPublicPackageSearchEntry);
|
|
},
|
|
});
|
|
|
|
export const searchForViewerInternal = internalQuery({
|
|
args: {
|
|
query: v.string(),
|
|
limit: v.optional(v.number()),
|
|
family: v.optional(
|
|
v.union(
|
|
v.literal("skill"),
|
|
v.literal("code-plugin"),
|
|
v.literal("bundle-plugin"),
|
|
v.literal("claw"),
|
|
),
|
|
),
|
|
channel: v.optional(
|
|
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
|
|
),
|
|
isOfficial: v.optional(v.boolean()),
|
|
highlightedOnly: v.optional(v.boolean()),
|
|
category: v.optional(v.string()),
|
|
topic: v.optional(v.string()),
|
|
excludedScanStatuses: v.optional(v.array(packageListScanStatusValidator)),
|
|
viewerUserId: v.optional(v.id("users")),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
return await searchPackagesImpl(ctx, args);
|
|
},
|
|
});
|
|
|
|
async function searchPackagesImpl(
|
|
ctx: DbReaderCtx,
|
|
args: {
|
|
query: string;
|
|
limit?: number;
|
|
family?: PackageFamily;
|
|
channel?: PackageChannel;
|
|
isOfficial?: boolean;
|
|
highlightedOnly?: boolean;
|
|
category?: string;
|
|
topic?: string;
|
|
excludedScanStatuses?: PackageListScanStatus[];
|
|
viewerUserId?: Id<"users">;
|
|
},
|
|
) {
|
|
const queryText = args.query.trim().toLowerCase();
|
|
if (!queryText) return [];
|
|
if (args.category && !isPluginCategorySlug(args.category)) return [];
|
|
if (args.channel === "private" && !args.viewerUserId) return [];
|
|
const targetCount = Math.max(1, Math.min(args.limit ?? 20, 100));
|
|
const viewerUserId = args.viewerUserId;
|
|
const membershipCache = new Map<string, Promise<boolean>>();
|
|
const canViewPackage = async (digest: PackageDigestLike) =>
|
|
await canViewerReadPackage(ctx, digest, viewerUserId, membershipCache);
|
|
const category = isPluginCategorySlug(args.category) ? args.category : undefined;
|
|
const topic = args.topic ? normalizeCatalogTopic(args.topic) : undefined;
|
|
if (args.topic !== undefined && !topic) return [];
|
|
if (args.highlightedOnly) {
|
|
const highlightedEntries = await fetchHighlightedPackageEntries(ctx, {
|
|
...args,
|
|
category,
|
|
topic,
|
|
});
|
|
const entries = highlightedEntries
|
|
.filter(({ digest }) => isClawFamilyPubliclyVisible(digest.family))
|
|
.filter(
|
|
({ digest }) =>
|
|
!digest.scanStatus || !args.excludedScanStatuses?.includes(digest.scanStatus),
|
|
)
|
|
.map(({ digest, featuredAt }) => {
|
|
const match = packageSearchMatch(digest, queryText);
|
|
return match ? { ...match, package: digest, featuredAt } : null;
|
|
})
|
|
.filter(
|
|
(
|
|
entry,
|
|
): entry is PackageSearchMatch & {
|
|
package: PackageDigestLike;
|
|
featuredAt: number;
|
|
} => Boolean(entry),
|
|
)
|
|
.sort(comparePackageSearchMatches)
|
|
.slice(0, targetCount);
|
|
const results: Array<PackageSearchMatch & { package: PublicPackageListItem }> = [];
|
|
for (const entry of entries) {
|
|
results.push({
|
|
score: entry.score,
|
|
rankTier: entry.rankTier,
|
|
package: await toPublicPackageListItem(ctx, entry.package, entry.featuredAt),
|
|
});
|
|
}
|
|
return results;
|
|
}
|
|
|
|
const searchFamilies =
|
|
!args.family && !experimentalClawsEnabled()
|
|
? ([...STABLE_PACKAGE_FAMILIES] as PackageFamily[])
|
|
: [args.family];
|
|
const buildSearchDigestQuery = (family: PackageFamily | undefined) =>
|
|
topic
|
|
? buildPackageTopicDigestQuery(ctx, {
|
|
topic,
|
|
family,
|
|
channel: args.channel,
|
|
isOfficial: args.isOfficial,
|
|
})
|
|
: category
|
|
? buildPackagePluginCategoryDigestQuery(ctx, {
|
|
category,
|
|
family,
|
|
channel: args.channel,
|
|
isOfficial: args.isOfficial,
|
|
})
|
|
: buildPackageDigestQuery(ctx, {
|
|
family,
|
|
channel: args.channel,
|
|
isOfficial: args.isOfficial,
|
|
});
|
|
const matches: Array<PackageSearchMatch & { package: PublicPackageListItem }> = [];
|
|
const seen = new Set<string>();
|
|
const directDigests =
|
|
category && !topic
|
|
? []
|
|
: (
|
|
await Promise.all(
|
|
searchFamilies.map(
|
|
async (family) => await resolveDirectPackageSearchDigests(ctx, queryText, family),
|
|
),
|
|
)
|
|
).flat();
|
|
for (const digest of directDigests) {
|
|
if (!(await canViewPackage(digest))) continue;
|
|
if (!digestMatchesSearchFilters(digest, { ...args, topic })) continue;
|
|
const match = packageSearchMatch(digest, queryText);
|
|
if (!match || seen.has(digest.packageId)) continue;
|
|
seen.add(digest.packageId);
|
|
matches.push({
|
|
...match,
|
|
package: await toPublicPackageListItem(ctx, digest),
|
|
});
|
|
}
|
|
|
|
// Demoted exact hits never satisfy the collection quota: the fallback scan
|
|
// must still gather the adopted lexical alternatives they are ranked against
|
|
// (top-1 queries would otherwise return a name squat unchallenged).
|
|
const authoritativeMatchCount = () =>
|
|
matches.filter((entry) => !isDemotedExactMatch(entry, packageTrustSignals(entry.package)))
|
|
.length;
|
|
|
|
if (authoritativeMatchCount() < targetCount) {
|
|
const scanLimit = Math.min(MAX_SEARCH_PAGE_SIZE, Math.max(targetCount * 5, 50));
|
|
const collectDigestMatches = async (digests: PackageDigestLike[]) => {
|
|
for (const digest of digests) {
|
|
if (!(await canViewPackage(digest))) continue;
|
|
if (!digestMatchesSearchFilters(digest, { ...args, topic })) continue;
|
|
const match = packageSearchMatch(digest, queryText);
|
|
if (!match || seen.has(digest.packageId)) continue;
|
|
seen.add(digest.packageId);
|
|
matches.push({
|
|
...match,
|
|
package: await toPublicPackageListItem(ctx, digest),
|
|
});
|
|
}
|
|
};
|
|
|
|
if (topic && category) {
|
|
const scanStates = searchFamilies.map((family) => ({
|
|
family,
|
|
cursor: null as string | null,
|
|
isDone: false,
|
|
pagesScanned: 0,
|
|
}));
|
|
let remainingScanBudget = MAX_PUBLIC_LIST_FILTER_SCAN_DOCUMENTS;
|
|
while (
|
|
authoritativeMatchCount() < targetCount &&
|
|
scanStates.some(
|
|
(state) => !state.isDone && state.pagesScanned < MAX_PUBLIC_LIST_FILTER_SCAN_PAGES,
|
|
) &&
|
|
remainingScanBudget > 0
|
|
) {
|
|
// Finish each round before checking the match quota so the fixed family
|
|
// order cannot decide global relevance or consume another family's cap.
|
|
for (const state of scanStates) {
|
|
if (
|
|
state.isDone ||
|
|
state.pagesScanned >= MAX_PUBLIC_LIST_FILTER_SCAN_PAGES ||
|
|
remainingScanBudget <= 0
|
|
) {
|
|
continue;
|
|
}
|
|
const pageSize = Math.min(scanLimit, remainingScanBudget);
|
|
const page: {
|
|
page: PackageDigestLike[];
|
|
isDone: boolean;
|
|
continueCursor: string;
|
|
} = await buildSearchDigestQuery(state.family)
|
|
.order("desc")
|
|
.paginate({ cursor: state.cursor, numItems: pageSize });
|
|
state.pagesScanned += 1;
|
|
remainingScanBudget -= pageSize;
|
|
await collectDigestMatches(page.page);
|
|
state.cursor = page.continueCursor;
|
|
state.isDone = page.isDone;
|
|
}
|
|
}
|
|
} else {
|
|
const digests = (
|
|
await Promise.all(
|
|
searchFamilies.map(
|
|
async (family) =>
|
|
(await buildSearchDigestQuery(family)
|
|
.order("desc")
|
|
.take(scanLimit)) as PackageDigestLike[],
|
|
),
|
|
)
|
|
).flat();
|
|
await collectDigestMatches(digests);
|
|
}
|
|
}
|
|
|
|
return matches.sort(comparePackageSearchMatches).slice(0, targetCount);
|
|
}
|
|
|
|
export const getPackageByNameInternal = internalQuery({
|
|
args: { name: v.string() },
|
|
handler: async (ctx, args) => {
|
|
return await getPackageByNormalizedName(ctx, normalizePackageName(args.name));
|
|
},
|
|
});
|
|
|
|
export const findPackagePublishResultInternal = internalQuery({
|
|
args: {
|
|
name: v.string(),
|
|
version: v.string(),
|
|
integritySha256: v.string(),
|
|
ownerUserId: v.id("users"),
|
|
ownerPublisherId: v.optional(v.id("publishers")),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const pkg = await getPackageByNormalizedName(ctx, normalizePackageName(args.name));
|
|
if (!pkg) return null;
|
|
if (getPackageOwnerKey(pkg) !== getRequestedPackageOwnerKey(args)) return null;
|
|
const release = await ctx.db
|
|
.query("packageReleases")
|
|
.withIndex("by_package_version", (q) =>
|
|
q.eq("packageId", pkg._id).eq("version", args.version),
|
|
)
|
|
.unique();
|
|
if (!isPublishedPackageRelease(release) || release.integritySha256 !== args.integritySha256) {
|
|
return null;
|
|
}
|
|
return { ok: true as const, packageId: pkg._id, releaseId: release._id };
|
|
},
|
|
});
|
|
|
|
async function buildPackageActivityTrend(ctx: DbReaderCtx, pkg: Doc<"packages">, endDay: number) {
|
|
const safeEndDay = clampActivityTrendEndDay(endDay, Date.now());
|
|
const { startDay, endDay: normalizedEndDay } = getActivityTrendRangeForEndDay(safeEndDay);
|
|
const rows = await ctx.db
|
|
.query("packageDailyStats")
|
|
.withIndex("by_package_day", (q) =>
|
|
q.eq("packageId", pkg._id).gte("day", startDay).lte("day", normalizedEndDay),
|
|
)
|
|
.take(ACTIVITY_TREND_DAYS);
|
|
|
|
return buildDailyMetricTrends(rows, normalizedEndDay);
|
|
}
|
|
|
|
export const getActivityTrendForName = query({
|
|
args: { name: v.string(), endDay: v.number() },
|
|
handler: async (ctx, args) => {
|
|
const viewerUserId = await getOptionalViewerUserId(ctx);
|
|
const pkg = await getReadablePackageByName(ctx, args.name, viewerUserId);
|
|
if (!pkg) return null;
|
|
|
|
return await buildPackageActivityTrend(ctx, pkg, args.endDay);
|
|
},
|
|
});
|
|
|
|
export const recordPackageDownloadInternal = internalMutation({
|
|
args: { packageId: v.id("packages") },
|
|
handler: async (ctx, args) => {
|
|
await ctx.db.insert("packageStatEvents", {
|
|
packageId: args.packageId,
|
|
kind: "download",
|
|
occurredAt: Date.now(),
|
|
processedAt: undefined,
|
|
});
|
|
},
|
|
});
|
|
|
|
export const recordPackageInstallInternal = internalMutation({
|
|
args: {
|
|
packageId: v.id("packages"),
|
|
identityKind: v.optional(v.union(v.literal("user"), v.literal("ip"))),
|
|
identityHash: v.optional(v.string()),
|
|
dayStart: v.optional(v.number()),
|
|
occurredAt: v.optional(v.number()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const identityKind = args.identityKind;
|
|
const identityHash = args.identityHash;
|
|
const dayStart = args.dayStart;
|
|
if (identityKind && identityHash && typeof dayStart === "number") {
|
|
const existing = await ctx.db
|
|
.query("packageInstallMetricDedupes")
|
|
.withIndex("by_target_metric_identity_day", (q) =>
|
|
q
|
|
.eq("targetKind", "package")
|
|
.eq("targetId", args.packageId)
|
|
.eq("metricKind", "install")
|
|
.eq("identityKind", identityKind)
|
|
.eq("identityHash", identityHash)
|
|
.eq("dayStart", dayStart),
|
|
)
|
|
.unique();
|
|
if (existing) return;
|
|
|
|
await ctx.db.insert("packageInstallMetricDedupes", {
|
|
targetKind: "package",
|
|
targetId: args.packageId,
|
|
metricKind: "install",
|
|
identityKind,
|
|
identityHash,
|
|
dayStart,
|
|
createdAt: Date.now(),
|
|
});
|
|
}
|
|
|
|
await insertPackageInstallStatEvent(ctx, {
|
|
packageId: args.packageId,
|
|
occurredAt: args.occurredAt,
|
|
});
|
|
},
|
|
});
|
|
|
|
async function bumpDailyPackageStats(
|
|
ctx: MutationCtx,
|
|
params: {
|
|
packageId: Id<"packages">;
|
|
day: number;
|
|
downloads: number;
|
|
installs: number;
|
|
now: number;
|
|
},
|
|
) {
|
|
if (params.downloads === 0 && params.installs === 0) return;
|
|
assertRankingMetricWritesAllowed();
|
|
|
|
const existing = await ctx.db
|
|
.query("packageDailyStats")
|
|
.withIndex("by_package_day", (q) => q.eq("packageId", params.packageId).eq("day", params.day))
|
|
.unique();
|
|
|
|
if (existing) {
|
|
await ctx.db.patch(existing._id, {
|
|
downloads: Math.max(0, existing.downloads + params.downloads),
|
|
installs: Math.max(0, existing.installs + params.installs),
|
|
updatedAt: params.now,
|
|
});
|
|
return;
|
|
}
|
|
|
|
await ctx.db.insert("packageDailyStats", {
|
|
packageId: params.packageId,
|
|
day: params.day,
|
|
downloads: Math.max(0, params.downloads),
|
|
installs: Math.max(0, params.installs),
|
|
updatedAt: params.now,
|
|
});
|
|
}
|
|
|
|
type PackageDailyStatsDelta = {
|
|
packageId: Id<"packages">;
|
|
day: number;
|
|
downloads: number;
|
|
installs: number;
|
|
};
|
|
|
|
export const processPackageStatEventsInternal = internalMutation({
|
|
args: { batchSize: v.optional(v.number()) },
|
|
handler: async (ctx, args) => {
|
|
const batchSize = Math.max(
|
|
1,
|
|
Math.min(args.batchSize ?? PACKAGE_STAT_EVENT_BATCH_SIZE, PACKAGE_STAT_EVENT_BATCH_SIZE),
|
|
);
|
|
const now = Date.now();
|
|
const events = await ctx.db
|
|
.query("packageStatEvents")
|
|
.withIndex("by_unprocessed", (q) => q.eq("processedAt", undefined))
|
|
.take(batchSize);
|
|
|
|
if (events.length === 0) return { processed: 0, packagesUpdated: 0 };
|
|
|
|
const statsByPackage = new Map<Id<"packages">, { downloads: number; installs: number }>();
|
|
const dailyStatsByPackageDay = new Map<string, PackageDailyStatsDelta>();
|
|
const dailyStatsByPackage = new Map<Id<"packages">, PackageDailyStatsDelta[]>();
|
|
for (const event of events) {
|
|
const stats = statsByPackage.get(event.packageId) ?? { downloads: 0, installs: 0 };
|
|
const day = toDayKey(event.occurredAt);
|
|
const dailyKey = `${event.packageId}:${day}`;
|
|
let dailyStats = dailyStatsByPackageDay.get(dailyKey);
|
|
if (!dailyStats) {
|
|
dailyStats = {
|
|
packageId: event.packageId,
|
|
day,
|
|
downloads: 0,
|
|
installs: 0,
|
|
};
|
|
dailyStatsByPackageDay.set(dailyKey, dailyStats);
|
|
const packageDailyStats = dailyStatsByPackage.get(event.packageId);
|
|
if (packageDailyStats) {
|
|
packageDailyStats.push(dailyStats);
|
|
} else {
|
|
dailyStatsByPackage.set(event.packageId, [dailyStats]);
|
|
}
|
|
}
|
|
if (event.kind === "install") {
|
|
stats.installs += 1;
|
|
dailyStats.installs += 1;
|
|
} else if (event.kind === "install_clear") {
|
|
stats.installs -= 1;
|
|
dailyStats.installs -= 1;
|
|
} else {
|
|
stats.downloads += 1;
|
|
dailyStats.downloads += 1;
|
|
}
|
|
statsByPackage.set(event.packageId, stats);
|
|
}
|
|
|
|
let packagesUpdated = 0;
|
|
for (const [packageId, stats] of statsByPackage) {
|
|
const pkg = await ctx.db.get(packageId);
|
|
if (!pkg) continue;
|
|
for (const dailyStats of dailyStatsByPackage.get(packageId) ?? []) {
|
|
await bumpDailyPackageStats(ctx, { ...dailyStats, now });
|
|
}
|
|
const nextStats = {
|
|
downloads: (pkg.stats?.downloads ?? 0) + stats.downloads,
|
|
installs: Math.max(0, (pkg.stats?.installs ?? 0) + stats.installs),
|
|
stars: pkg.stats?.stars ?? 0,
|
|
versions: pkg.stats?.versions ?? 0,
|
|
};
|
|
await ctx.db.patch(pkg._id, {
|
|
stats: nextStats,
|
|
...computePackageRecommendationPatch(nextStats, {
|
|
createdAt: pkg.createdAt ?? pkg._creationTime,
|
|
updatedAt: pkg.updatedAt,
|
|
now,
|
|
}),
|
|
});
|
|
packagesUpdated += 1;
|
|
}
|
|
|
|
for (const event of events) {
|
|
await ctx.db.patch(event._id, { processedAt: now });
|
|
}
|
|
|
|
if (events.length === batchSize) {
|
|
await ctx.scheduler.runAfter(0, internal.packages.processPackageStatEventsInternal, {
|
|
batchSize,
|
|
});
|
|
}
|
|
|
|
return { processed: events.length, packagesUpdated };
|
|
},
|
|
});
|
|
|
|
export const pruneProcessedPackageStatEventBatchInternal = internalMutation({
|
|
args: {
|
|
cutoffProcessedAt: v.number(),
|
|
dryRun: v.boolean(),
|
|
batchSize: v.optional(v.number()),
|
|
confirmationToken: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args): Promise<ProcessedPackageStatEventPruneBatchResult> => {
|
|
if (
|
|
!args.dryRun &&
|
|
args.confirmationToken !== PROCESSED_PACKAGE_STAT_EVENT_PRUNE_CONFIRMATION_TOKEN
|
|
) {
|
|
throw new Error(
|
|
`Apply requires confirmationToken=${PROCESSED_PACKAGE_STAT_EVENT_PRUNE_CONFIRMATION_TOKEN}`,
|
|
);
|
|
}
|
|
|
|
const batchSize = normalizeProcessedPackageStatEventPruneBatchSize(args.batchSize);
|
|
const events = await ctx.db
|
|
.query("packageStatEvents")
|
|
.withIndex("by_unprocessed", (q) =>
|
|
q.gt("processedAt", 0).lt("processedAt", args.cutoffProcessedAt),
|
|
)
|
|
.take(batchSize);
|
|
|
|
if (!args.dryRun) {
|
|
for (const event of events) {
|
|
await ctx.db.delete(event._id);
|
|
}
|
|
}
|
|
|
|
return {
|
|
cutoffProcessedAt: args.cutoffProcessedAt,
|
|
dryRun: args.dryRun,
|
|
matched: events.length,
|
|
deleted: args.dryRun ? 0 : events.length,
|
|
hasMore: events.length === batchSize,
|
|
};
|
|
},
|
|
});
|
|
|
|
export const pruneProcessedPackageStatEventsInternal: ReturnType<typeof internalAction> =
|
|
internalAction({
|
|
args: {
|
|
dryRun: v.optional(v.boolean()),
|
|
retentionDays: v.optional(v.number()),
|
|
batchSize: v.optional(v.number()),
|
|
maxBatches: v.optional(v.number()),
|
|
confirmationToken: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args): Promise<ProcessedPackageStatEventPruneResult> => {
|
|
const dryRun = args.dryRun ?? false;
|
|
const retentionDays = normalizeProcessedPackageStatEventRetentionDays(args.retentionDays);
|
|
const batchSize = normalizeProcessedPackageStatEventPruneBatchSize(args.batchSize);
|
|
const maxBatches = normalizeProcessedPackageStatEventPruneMaxBatches(args.maxBatches);
|
|
const cutoffProcessedAt = Date.now() - retentionDays * 24 * 60 * 60 * 1_000;
|
|
|
|
let batches = 0;
|
|
let matched = 0;
|
|
let deleted = 0;
|
|
let hasMore = false;
|
|
let stoppedReason: "empty" | "max_batches" = "empty";
|
|
const batchLimit = dryRun ? 1 : maxBatches;
|
|
|
|
for (let index = 0; index < batchLimit; index += 1) {
|
|
const batch = (await ctx.runMutation(
|
|
internal.packages.pruneProcessedPackageStatEventBatchInternal,
|
|
{
|
|
cutoffProcessedAt,
|
|
dryRun,
|
|
batchSize,
|
|
confirmationToken: args.confirmationToken,
|
|
},
|
|
)) as ProcessedPackageStatEventPruneBatchResult;
|
|
|
|
batches += 1;
|
|
matched += batch.matched;
|
|
deleted += batch.deleted;
|
|
hasMore = batch.hasMore;
|
|
|
|
if (!batch.hasMore) {
|
|
stoppedReason = "empty";
|
|
break;
|
|
}
|
|
|
|
stoppedReason = "max_batches";
|
|
}
|
|
|
|
if (!dryRun && hasMore && stoppedReason === "max_batches") {
|
|
await ctx.scheduler.runAfter(0, internal.packages.pruneProcessedPackageStatEventsInternal, {
|
|
dryRun,
|
|
retentionDays,
|
|
batchSize,
|
|
maxBatches,
|
|
confirmationToken: args.confirmationToken,
|
|
});
|
|
}
|
|
|
|
return {
|
|
cutoffProcessedAt,
|
|
retentionDays,
|
|
dryRun,
|
|
batches,
|
|
matched,
|
|
deleted,
|
|
stoppedReason,
|
|
scheduledContinuation: !dryRun && hasMore && stoppedReason === "max_batches",
|
|
};
|
|
},
|
|
});
|
|
|
|
export const getTrustedPublisherByPackageIdInternal = internalQuery({
|
|
args: { packageId: v.id("packages") },
|
|
handler: async (ctx, args) => {
|
|
return await getPackageTrustedPublisherByPackageId(ctx, args.packageId);
|
|
},
|
|
});
|
|
|
|
export const setTrustedPublisherForUserInternal = internalMutation({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
packageName: v.string(),
|
|
repository: v.string(),
|
|
repositoryId: v.string(),
|
|
repositoryOwner: v.string(),
|
|
repositoryOwnerId: v.string(),
|
|
workflowFilename: v.string(),
|
|
environment: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const pkg = await getPackageByNormalizedName(ctx, normalizePackageName(args.packageName));
|
|
if (!pkg) throw new ConvexError("Package not found");
|
|
if (pkg.family === "skill") {
|
|
throw new ConvexError(
|
|
"Trusted publishers are only supported for code-plugin and bundle-plugin packages",
|
|
);
|
|
}
|
|
await requireTrustedPublisherEditor(ctx, pkg, args.actorUserId);
|
|
|
|
const workflowFilename = normalizeWorkflowFilenameOrThrow(args.workflowFilename);
|
|
const environment = args.environment?.trim() || undefined;
|
|
|
|
const existing = await getPackageTrustedPublisherByPackageId(ctx, pkg._id);
|
|
const now = Date.now();
|
|
const patch = {
|
|
provider: "github-actions" as const,
|
|
repository: args.repository,
|
|
repositoryId: args.repositoryId,
|
|
repositoryOwner: args.repositoryOwner,
|
|
repositoryOwnerId: args.repositoryOwnerId,
|
|
workflowFilename,
|
|
environment,
|
|
updatedByUserId: args.actorUserId,
|
|
updatedAt: now,
|
|
};
|
|
if (existing) {
|
|
await ctx.db.patch(existing._id, patch);
|
|
} else {
|
|
await ctx.db.insert("packageTrustedPublishers", {
|
|
packageId: pkg._id,
|
|
createdByUserId: args.actorUserId,
|
|
createdAt: now,
|
|
...patch,
|
|
});
|
|
}
|
|
|
|
await ctx.db.insert("auditLogs", {
|
|
actorUserId: args.actorUserId,
|
|
action: "package.trusted_publisher.set",
|
|
targetType: "package",
|
|
targetId: pkg._id,
|
|
metadata: {
|
|
provider: "github-actions",
|
|
repository: args.repository,
|
|
repositoryId: args.repositoryId,
|
|
repositoryOwner: args.repositoryOwner,
|
|
repositoryOwnerId: args.repositoryOwnerId,
|
|
workflowFilename,
|
|
...(environment ? { environment } : {}),
|
|
},
|
|
createdAt: now,
|
|
});
|
|
|
|
return await getPackageTrustedPublisherByPackageId(ctx, pkg._id);
|
|
},
|
|
});
|
|
|
|
export const deleteTrustedPublisherForUserInternal = internalMutation({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
packageName: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const pkg = await getPackageByNormalizedName(ctx, normalizePackageName(args.packageName));
|
|
if (!pkg) throw new ConvexError("Package not found");
|
|
await requireTrustedPublisherEditor(ctx, pkg, args.actorUserId);
|
|
|
|
const existing = await getPackageTrustedPublisherByPackageId(ctx, pkg._id);
|
|
if (!existing) return { deleted: false as const };
|
|
await ctx.db.delete(existing._id);
|
|
|
|
await ctx.db.insert("auditLogs", {
|
|
actorUserId: args.actorUserId,
|
|
action: "package.trusted_publisher.delete",
|
|
targetType: "package",
|
|
targetId: pkg._id,
|
|
metadata: {
|
|
provider: existing.provider,
|
|
repository: existing.repository,
|
|
repositoryId: existing.repositoryId,
|
|
repositoryOwner: existing.repositoryOwner,
|
|
repositoryOwnerId: existing.repositoryOwnerId,
|
|
workflowFilename: existing.workflowFilename,
|
|
environment: existing.environment,
|
|
},
|
|
createdAt: Date.now(),
|
|
});
|
|
return { deleted: true as const };
|
|
},
|
|
});
|
|
|
|
export const insertAuditLogInternal = internalMutation({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
action: v.string(),
|
|
targetType: v.string(),
|
|
targetId: v.string(),
|
|
metadata: v.optional(v.any()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
await ctx.db.insert("auditLogs", {
|
|
actorUserId: args.actorUserId,
|
|
action: args.action,
|
|
targetType: args.targetType,
|
|
targetId: args.targetId,
|
|
metadata: args.metadata,
|
|
createdAt: Date.now(),
|
|
});
|
|
},
|
|
});
|
|
|
|
async function softDeletePackageDoc(
|
|
ctx: Pick<MutationCtx, "db">,
|
|
pkg: Doc<"packages">,
|
|
params: {
|
|
actorUserId: Id<"users">;
|
|
actorRole?: Doc<"users">["role"];
|
|
deletedAt?: number;
|
|
reason?: PackageSoftDeletedReason;
|
|
source: "cli" | "dashboard";
|
|
},
|
|
) {
|
|
const now = params.deletedAt ?? Date.now();
|
|
if (pkg.softDeletedAt) {
|
|
if (params.actorRole === "admin" || params.actorRole === "moderator") {
|
|
const packagePatch: Partial<Doc<"packages">> = {
|
|
softDeletedBy: params.actorUserId,
|
|
softDeletedByRole: params.actorRole,
|
|
updatedAt: now,
|
|
};
|
|
if (pkg.softDeletedReason === "user.banned" && params.reason !== "user.banned") {
|
|
packagePatch.softDeletedAt = now;
|
|
packagePatch.softDeletedReason = params.reason;
|
|
}
|
|
const nextPackage: Doc<"packages"> = { ...pkg, ...packagePatch };
|
|
await ctx.db.patch(pkg._id, packagePatch);
|
|
const deleteOwner = await getOwnerPublisher(ctx, {
|
|
ownerPublisherId: pkg.ownerPublisherId,
|
|
ownerUserId: pkg.ownerUserId,
|
|
});
|
|
await upsertPackageSearchDigest(ctx, {
|
|
...extractPackageDigestFields(nextPackage),
|
|
ownerHandle: deleteOwner?.handle ?? "",
|
|
ownerKind: deleteOwner?.kind,
|
|
});
|
|
}
|
|
return {
|
|
ok: true as const,
|
|
packageId: pkg._id,
|
|
releaseCount: 0,
|
|
alreadyDeleted: true as const,
|
|
};
|
|
}
|
|
|
|
const releases = await ctx.db
|
|
.query("packageReleases")
|
|
.withIndex("by_package", (q) => q.eq("packageId", pkg._id))
|
|
.collect();
|
|
const deletedReleaseIds = releases
|
|
.filter((release) => !release.softDeletedAt)
|
|
.map((release) => release._id);
|
|
const packagePatch: Partial<Doc<"packages">> = {
|
|
softDeletedAt: now,
|
|
softDeletedReason: params.reason,
|
|
softDeletedBy: params.actorUserId,
|
|
softDeletedByRole: params.actorRole ?? "user",
|
|
updatedAt: now,
|
|
};
|
|
const nextPackage: Doc<"packages"> = { ...pkg, ...packagePatch };
|
|
await ctx.db.patch(pkg._id, packagePatch);
|
|
const deleteOwner = await getOwnerPublisher(ctx, {
|
|
ownerPublisherId: pkg.ownerPublisherId,
|
|
ownerUserId: pkg.ownerUserId,
|
|
});
|
|
await upsertPackageSearchDigest(ctx, {
|
|
...extractPackageDigestFields(nextPackage),
|
|
ownerHandle: deleteOwner?.handle ?? "",
|
|
ownerKind: deleteOwner?.kind,
|
|
});
|
|
for (const releaseId of deletedReleaseIds) {
|
|
await ctx.db.patch(releaseId, { softDeletedAt: now });
|
|
}
|
|
await ctx.db.insert("auditLogs", {
|
|
actorUserId: params.actorUserId,
|
|
action: "package.delete",
|
|
targetType: "package",
|
|
targetId: pkg._id,
|
|
metadata: {
|
|
name: pkg.name,
|
|
normalizedName: pkg.normalizedName,
|
|
ownerUserId: pkg.ownerUserId,
|
|
ownerPublisherId: pkg.ownerPublisherId,
|
|
actorRole: params.actorRole ?? "user",
|
|
softDeletedReason: params.reason ?? null,
|
|
releaseCount: deletedReleaseIds.length,
|
|
releaseIds: deletedReleaseIds,
|
|
source: params.source,
|
|
},
|
|
createdAt: now,
|
|
});
|
|
|
|
return {
|
|
ok: true as const,
|
|
packageId: pkg._id,
|
|
releaseCount: deletedReleaseIds.length,
|
|
alreadyDeleted: false as const,
|
|
};
|
|
}
|
|
|
|
async function deletePackageModerationEventsForReport(
|
|
ctx: Pick<MutationCtx, "db">,
|
|
reportId: Id<"packageReports">,
|
|
) {
|
|
const logs = await ctx.db
|
|
.query("packageModerationEventLogs")
|
|
.withIndex("by_report_createdAt", (q) => q.eq("reportId", reportId))
|
|
.collect();
|
|
for (const log of logs) await ctx.db.delete(log._id);
|
|
}
|
|
|
|
async function deletePackageModerationEventsForAppeal(
|
|
ctx: Pick<MutationCtx, "db">,
|
|
appealId: Id<"packageAppeals">,
|
|
) {
|
|
const logs = await ctx.db
|
|
.query("packageModerationEventLogs")
|
|
.withIndex("by_appeal_createdAt", (q) => q.eq("appealId", appealId))
|
|
.collect();
|
|
for (const log of logs) await ctx.db.delete(log._id);
|
|
}
|
|
|
|
async function hardDeletePackageDoc(
|
|
ctx: Pick<MutationCtx, "db">,
|
|
pkg: Doc<"packages">,
|
|
params: {
|
|
actorUserId: Id<"users">;
|
|
deletedAt: number;
|
|
source: "admin" | "account.delete" | "publisher.delete";
|
|
reason?: string;
|
|
},
|
|
) {
|
|
const releases = await ctx.db
|
|
.query("packageReleases")
|
|
.withIndex("by_package", (q) => q.eq("packageId", pkg._id))
|
|
.collect();
|
|
for (const release of releases) {
|
|
const jobs = await ctx.db
|
|
.query("securityScanJobs")
|
|
.withIndex("by_package_release", (q) => q.eq("packageReleaseId", release._id))
|
|
.collect();
|
|
for (const job of jobs) await ctx.db.delete(job._id);
|
|
}
|
|
|
|
const reports = await ctx.db
|
|
.query("packageReports")
|
|
.withIndex("by_package", (q) => q.eq("packageId", pkg._id))
|
|
.collect();
|
|
for (const report of reports) {
|
|
await deletePackageModerationEventsForReport(ctx, report._id);
|
|
await ctx.db.delete(report._id);
|
|
}
|
|
|
|
const appeals = await ctx.db
|
|
.query("packageAppeals")
|
|
.withIndex("by_package", (q) => q.eq("packageId", pkg._id))
|
|
.collect();
|
|
for (const appeal of appeals) {
|
|
await deletePackageModerationEventsForAppeal(ctx, appeal._id);
|
|
await ctx.db.delete(appeal._id);
|
|
}
|
|
|
|
const badges = await ctx.db
|
|
.query("packageBadges")
|
|
.withIndex("by_package", (q) => q.eq("packageId", pkg._id))
|
|
.collect();
|
|
for (const badge of badges) await ctx.db.delete(badge._id);
|
|
|
|
const trustedPublishers = await ctx.db
|
|
.query("packageTrustedPublishers")
|
|
.withIndex("by_package", (q) => q.eq("packageId", pkg._id))
|
|
.collect();
|
|
for (const trustedPublisher of trustedPublishers) await ctx.db.delete(trustedPublisher._id);
|
|
|
|
const tokens = await ctx.db
|
|
.query("packagePublishTokens")
|
|
.withIndex("by_package", (q) => q.eq("packageId", pkg._id))
|
|
.collect();
|
|
for (const token of tokens) {
|
|
const tickets = await ctx.db
|
|
.query("packagePublishUploadTickets")
|
|
.withIndex("by_publish_token", (q) => q.eq("publishTokenId", token._id))
|
|
.collect();
|
|
for (const ticket of tickets) await ctx.db.delete(ticket._id);
|
|
await ctx.db.delete(token._id);
|
|
}
|
|
|
|
const statEvents = await ctx.db
|
|
.query("packageStatEvents")
|
|
.withIndex("by_package", (q) => q.eq("packageId", pkg._id))
|
|
.collect();
|
|
for (const statEvent of statEvents) await ctx.db.delete(statEvent._id);
|
|
|
|
const dailyStats = await ctx.db
|
|
.query("packageDailyStats")
|
|
.withIndex("by_package_day", (q) => q.eq("packageId", pkg._id))
|
|
.collect();
|
|
for (const dailyStat of dailyStats) await ctx.db.delete(dailyStat._id);
|
|
|
|
for (const release of releases) await ctx.db.delete(release._id);
|
|
await ctx.db.delete(pkg._id);
|
|
await ctx.db.insert("auditLogs", {
|
|
actorUserId: params.actorUserId,
|
|
action: "package.hard_delete",
|
|
targetType: "package",
|
|
targetId: pkg._id,
|
|
metadata: {
|
|
name: pkg.name,
|
|
normalizedName: pkg.normalizedName,
|
|
ownerUserId: pkg.ownerUserId,
|
|
ownerPublisherId: pkg.ownerPublisherId,
|
|
source: params.source,
|
|
reason: params.reason,
|
|
releases: releases.length,
|
|
reports: reports.length,
|
|
appeals: appeals.length,
|
|
publishTokens: tokens.length,
|
|
},
|
|
createdAt: params.deletedAt,
|
|
});
|
|
|
|
return {
|
|
ok: true as const,
|
|
packageId: pkg._id,
|
|
releaseCount: releases.length,
|
|
revokedTokenCount: tokens.length,
|
|
};
|
|
}
|
|
|
|
export const hardDeletePackageInternal = internalMutation({
|
|
args: {
|
|
packageId: v.id("packages"),
|
|
actorUserId: v.id("users"),
|
|
deletedAt: v.number(),
|
|
source: hardDeletePackageSourceValidator,
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const pkg = await ctx.db.get(args.packageId);
|
|
if (!pkg) return { ok: true as const, deleted: false as const };
|
|
const result = await hardDeletePackageDoc(ctx, pkg, {
|
|
actorUserId: args.actorUserId,
|
|
deletedAt: args.deletedAt,
|
|
source: args.source,
|
|
});
|
|
return { ...result, deleted: true as const };
|
|
},
|
|
});
|
|
|
|
export const hardDeleteForAdminInternal = internalMutation({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
name: v.string(),
|
|
ownerHandle: v.string(),
|
|
reason: v.string(),
|
|
dryRun: v.optional(v.boolean()),
|
|
confirmationToken: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const actor = await ctx.db.get(args.actorUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
|
assertAdmin(actor);
|
|
|
|
const name = normalizePackageName(args.name);
|
|
const ownerHandle = normalizePublisherHandle(args.ownerHandle);
|
|
const reason = args.reason.trim();
|
|
if (!name) throw new ConvexError("Package name required");
|
|
if (!ownerHandle) throw new ConvexError("Owner handle required");
|
|
if (!reason) throw new ConvexError("Reason is required");
|
|
if (reason.length > 500) throw new ConvexError("Reason too long (max 500 chars)");
|
|
|
|
const pkg = await getPackageByNormalizedName(ctx, name);
|
|
if (!pkg) throw new ConvexError("Package not found");
|
|
if (!pkg.softDeletedAt) throw new ConvexError("Package must be soft-deleted first");
|
|
const owner = await getOwnerPublisher(ctx, {
|
|
ownerPublisherId: pkg.ownerPublisherId,
|
|
ownerUserId: pkg.ownerUserId,
|
|
});
|
|
if (normalizePublisherHandle(owner?.handle) !== ownerHandle) {
|
|
throw new ConvexError("Package owner does not match --owner");
|
|
}
|
|
|
|
const confirmationToken = `hard-delete-package:@${ownerHandle}/${pkg.normalizedName}:${pkg._id}`;
|
|
const baseResult = {
|
|
ok: true as const,
|
|
packageId: pkg._id,
|
|
name: pkg.normalizedName,
|
|
ownerHandle,
|
|
displayName: pkg.displayName,
|
|
runtimeId: pkg.runtimeId ?? null,
|
|
confirmationToken,
|
|
};
|
|
if (args.dryRun !== false) return { ...baseResult, dryRun: true, deleted: false };
|
|
if (args.confirmationToken !== confirmationToken) {
|
|
throw new ConvexError(`Confirmation token must be "${confirmationToken}"`);
|
|
}
|
|
|
|
const now = Date.now();
|
|
await ctx.db.insert("auditLogs", {
|
|
actorUserId: args.actorUserId,
|
|
action: "package.hard_delete.requested",
|
|
targetType: "package",
|
|
targetId: pkg._id,
|
|
metadata: {
|
|
name: pkg.normalizedName,
|
|
ownerHandle,
|
|
reason,
|
|
source: "clawhub-admin",
|
|
},
|
|
createdAt: now,
|
|
});
|
|
await hardDeletePackageDoc(ctx, pkg, {
|
|
actorUserId: args.actorUserId,
|
|
deletedAt: now,
|
|
source: "admin",
|
|
reason,
|
|
});
|
|
return { ...baseResult, dryRun: false, deleted: true };
|
|
},
|
|
});
|
|
|
|
function comparePackageRestoreLatestCandidates(
|
|
family: Doc<"packages">["family"],
|
|
a: Doc<"packageReleases">,
|
|
b: Doc<"packageReleases">,
|
|
) {
|
|
if (family === "bundle-plugin") {
|
|
if (a.createdAt !== b.createdAt) return a.createdAt - b.createdAt;
|
|
return a._id.localeCompare(b._id);
|
|
}
|
|
const aSemver = semver.valid(a.version);
|
|
const bSemver = semver.valid(b.version);
|
|
if (aSemver && bSemver) return semver.compare(aSemver, bSemver);
|
|
if (aSemver) return 1;
|
|
if (bSemver) return -1;
|
|
if (a.createdAt !== b.createdAt) return a.createdAt - b.createdAt;
|
|
return a._id.localeCompare(b._id);
|
|
}
|
|
|
|
function getPreferredRestoredPackageRelease(
|
|
family: Doc<"packages">["family"],
|
|
releases: Doc<"packageReleases">[],
|
|
) {
|
|
return releases.reduce<Doc<"packageReleases"> | null>((best, release) => {
|
|
if (release.softDeletedAt) return best;
|
|
if (!best || comparePackageRestoreLatestCandidates(family, best, release) < 0) return release;
|
|
return best;
|
|
}, null);
|
|
}
|
|
|
|
function getPreservedRestoredPackageRelease(
|
|
pkg: Doc<"packages">,
|
|
releases: Doc<"packageReleases">[],
|
|
) {
|
|
const byId = new Map(
|
|
releases.filter((release) => !release.softDeletedAt).map((release) => [release._id, release]),
|
|
);
|
|
return (
|
|
byId.get(pkg.tags.latest) ??
|
|
(pkg.latestReleaseId ? byId.get(pkg.latestReleaseId) : null) ??
|
|
null
|
|
);
|
|
}
|
|
|
|
function rebuildPackageTagsFromActiveReleases(releases: Doc<"packageReleases">[]) {
|
|
const tags: Doc<"packages">["tags"] = {};
|
|
for (const release of releases) {
|
|
if (release.softDeletedAt) continue;
|
|
for (const tag of release.distTags ?? []) {
|
|
tags[tag] = release._id;
|
|
}
|
|
}
|
|
return tags;
|
|
}
|
|
|
|
function packageLatestSummaryFromRelease(release: Doc<"packageReleases"> | null) {
|
|
return release
|
|
? {
|
|
version: release.version,
|
|
createdAt: release.createdAt,
|
|
changelog: release.changelog,
|
|
icon: release.icon,
|
|
compatibility: release.compatibility,
|
|
verification: release.verification,
|
|
artifact: packageArtifactSummary(release),
|
|
}
|
|
: undefined;
|
|
}
|
|
|
|
function packageRuntimeIdFromRelease(release: Doc<"packageReleases"> | null) {
|
|
return release?.runtimeId;
|
|
}
|
|
|
|
function packageSourceRepoFromRelease(release: Doc<"packageReleases"> | null) {
|
|
return release?.sourceRepo ?? release?.verification?.sourceRepo;
|
|
}
|
|
|
|
async function restorePackageDoc(
|
|
ctx: Pick<MutationCtx, "db">,
|
|
pkg: Doc<"packages">,
|
|
params: {
|
|
actorUserId?: Id<"users">;
|
|
actorRole?: Doc<"users">["role"];
|
|
allowBanRestore?: boolean;
|
|
releaseSoftDeletedAt?: number;
|
|
source: "cli" | "dashboard" | "service";
|
|
},
|
|
) {
|
|
if (!pkg.softDeletedAt) {
|
|
return {
|
|
ok: true as const,
|
|
packageId: pkg._id,
|
|
releaseCount: 0,
|
|
alreadyRestored: true as const,
|
|
};
|
|
}
|
|
|
|
const now = Date.now();
|
|
const actorRole = params.actorRole ?? "user";
|
|
const isPrivilegedActor = actorRole === "admin" || actorRole === "moderator";
|
|
const isDirectlyRestorableDelete = pkg.softDeletedReason === undefined;
|
|
const isPrivilegedRestorableDelete = isPrivilegedActor && isDirectlyRestorableDelete;
|
|
const isUserRestorableDelete = pkg.softDeletedByRole === "user" && isDirectlyRestorableDelete;
|
|
const isUnbanBatchRestore =
|
|
params.allowBanRestore === true && pkg.softDeletedReason === "user.banned";
|
|
if (!isPrivilegedRestorableDelete && !isUserRestorableDelete && !isUnbanBatchRestore) {
|
|
throw new ConvexError(
|
|
"Forbidden: This package was hidden by moderation and cannot be restored by the owner. Please contact a moderator.",
|
|
);
|
|
}
|
|
|
|
const releases = await ctx.db
|
|
.query("packageReleases")
|
|
.withIndex("by_package", (q) => q.eq("packageId", pkg._id))
|
|
.collect();
|
|
let releaseCount = 0;
|
|
const restoredReleaseIds: Array<Id<"packageReleases">> = [];
|
|
const activeReleases: Doc<"packageReleases">[] = [];
|
|
for (const release of releases) {
|
|
if (release.ownerDeletedAt !== undefined) continue;
|
|
if (release.softDeletedAt) {
|
|
if (
|
|
params.releaseSoftDeletedAt !== undefined &&
|
|
release.softDeletedAt !== params.releaseSoftDeletedAt
|
|
) {
|
|
continue;
|
|
}
|
|
const restoredRelease = { ...release, softDeletedAt: undefined };
|
|
await ctx.db.patch(release._id, { softDeletedAt: undefined });
|
|
releaseCount += 1;
|
|
restoredReleaseIds.push(release._id);
|
|
activeReleases.push(restoredRelease);
|
|
} else {
|
|
activeReleases.push(release);
|
|
}
|
|
}
|
|
|
|
const nextLatest =
|
|
getPreservedRestoredPackageRelease(pkg, activeReleases) ??
|
|
getPreferredRestoredPackageRelease(pkg.family, activeReleases);
|
|
const nextTags = rebuildPackageTagsFromActiveReleases(activeReleases);
|
|
if (nextLatest) {
|
|
nextTags.latest = nextLatest._id;
|
|
if (!(nextLatest.distTags ?? []).includes("latest")) {
|
|
await ctx.db.patch(nextLatest._id, {
|
|
distTags: [...(nextLatest.distTags ?? []), "latest"],
|
|
});
|
|
}
|
|
}
|
|
|
|
const packagePatch: Partial<Doc<"packages">> = {
|
|
softDeletedAt: undefined,
|
|
softDeletedReason: undefined,
|
|
softDeletedBy: undefined,
|
|
softDeletedByRole: undefined,
|
|
tags: nextTags,
|
|
latestReleaseId: nextLatest?._id,
|
|
latestVersionSummary: nextLatest
|
|
? {
|
|
version: nextLatest.version,
|
|
createdAt: nextLatest.createdAt,
|
|
changelog: nextLatest.changelog,
|
|
icon: nextLatest.icon,
|
|
compatibility: nextLatest.compatibility,
|
|
verification: nextLatest.verification,
|
|
artifact: packageArtifactSummary(nextLatest),
|
|
}
|
|
: undefined,
|
|
summary: nextLatest?.summary,
|
|
icon: nextLatest?.icon,
|
|
compatibility: nextLatest?.compatibility,
|
|
verification: nextLatest?.verification,
|
|
scanStatus: nextLatest ? resolvePackageReleaseScanStatus(nextLatest) : pkg.scanStatus,
|
|
updatedAt: now,
|
|
};
|
|
const nextPackage: Doc<"packages"> = { ...pkg, ...packagePatch };
|
|
await ctx.db.patch(pkg._id, packagePatch);
|
|
const restoreOwner = await getOwnerPublisher(ctx, {
|
|
ownerPublisherId: pkg.ownerPublisherId,
|
|
ownerUserId: pkg.ownerUserId,
|
|
});
|
|
await upsertPackageSearchDigest(ctx, {
|
|
...extractPackageDigestFields(nextPackage),
|
|
ownerHandle: restoreOwner?.handle ?? "",
|
|
ownerKind: restoreOwner?.kind,
|
|
});
|
|
await ctx.db.insert("auditLogs", {
|
|
...(params.actorUserId ? { actorUserId: params.actorUserId } : {}),
|
|
action: "package.undelete",
|
|
targetType: "package",
|
|
targetId: pkg._id,
|
|
metadata: {
|
|
name: pkg.name,
|
|
normalizedName: pkg.normalizedName,
|
|
ownerUserId: pkg.ownerUserId,
|
|
ownerPublisherId: pkg.ownerPublisherId,
|
|
deletedBy: pkg.softDeletedBy,
|
|
deletedByRole: pkg.softDeletedByRole,
|
|
releaseCount,
|
|
releaseIds: restoredReleaseIds,
|
|
source: params.source,
|
|
},
|
|
createdAt: now,
|
|
});
|
|
|
|
return {
|
|
ok: true as const,
|
|
packageId: pkg._id,
|
|
releaseCount,
|
|
alreadyRestored: false as const,
|
|
};
|
|
}
|
|
|
|
async function revokePackagePublishTokensForPackage(
|
|
ctx: Pick<MutationCtx, "db" | "scheduler">,
|
|
packageId: Id<"packages">,
|
|
revokedAt: number,
|
|
) {
|
|
const tokens = await ctx.db
|
|
.query("packagePublishTokens")
|
|
.withIndex("by_package_revoked_created", (q) =>
|
|
q.eq("packageId", packageId).eq("revokedAt", undefined).lte("createdAt", revokedAt),
|
|
)
|
|
.order("desc")
|
|
.take(PACKAGE_PUBLISH_TOKEN_REVOKE_BATCH_SIZE + 1);
|
|
let revokedCount = 0;
|
|
for (const token of tokens.slice(0, PACKAGE_PUBLISH_TOKEN_REVOKE_BATCH_SIZE)) {
|
|
await ctx.db.patch(token._id, { revokedAt });
|
|
revokedCount += 1;
|
|
}
|
|
const scheduled = tokens.length > PACKAGE_PUBLISH_TOKEN_REVOKE_BATCH_SIZE;
|
|
if (scheduled) {
|
|
void ctx.scheduler.runAfter(
|
|
0,
|
|
internal.packages.revokePackagePublishTokensForPackageBatchInternal,
|
|
{ packageId, revokedAt },
|
|
);
|
|
}
|
|
return { revokedCount, scheduled };
|
|
}
|
|
|
|
async function isPackageOwnedByPersonalUser(
|
|
ctx: Pick<MutationCtx, "db">,
|
|
pkg: Pick<Doc<"packages">, "ownerPublisherId">,
|
|
owner: Doc<"users">,
|
|
) {
|
|
if (!pkg.ownerPublisherId) return true;
|
|
if (owner.personalPublisherId && pkg.ownerPublisherId === owner.personalPublisherId) {
|
|
return true;
|
|
}
|
|
const ownerPublisher = await ctx.db.get(pkg.ownerPublisherId);
|
|
return ownerPublisher?.kind === "user" && ownerPublisher.linkedUserId === owner._id;
|
|
}
|
|
|
|
async function getOwnedPackagePersonalPublisherId(
|
|
ctx: Pick<MutationCtx, "db">,
|
|
owner: Pick<Doc<"users">, "_id" | "personalPublisherId">,
|
|
) {
|
|
if (owner.personalPublisherId) return owner.personalPublisherId;
|
|
const linkedPublisher = await getPersonalPublisherForUser(ctx, owner._id);
|
|
if (
|
|
linkedPublisher?.kind === "user" &&
|
|
!linkedPublisher.deletedAt &&
|
|
!linkedPublisher.deactivatedAt
|
|
) {
|
|
return linkedPublisher._id;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function getOwnedPackageScanScope(args: { scope?: OwnedPackageScanScope }) {
|
|
return args.scope ?? "ownerUserId";
|
|
}
|
|
|
|
function shouldSkipOwnedPackageScanRow(
|
|
pkg: Pick<Doc<"packages">, "ownerUserId">,
|
|
args: { ownerUserId: Id<"users">; scope?: OwnedPackageScanScope },
|
|
) {
|
|
return (
|
|
getOwnedPackageScanScope(args) === "personalPublisher" && pkg.ownerUserId === args.ownerUserId
|
|
);
|
|
}
|
|
|
|
function scheduleNextOwnedPackageScanBatch(
|
|
ctx: Pick<MutationCtx, "scheduler">,
|
|
fn: unknown,
|
|
args: { ownerUserId: Id<"users">; cursor?: string; scope?: OwnedPackageScanScope } & Record<
|
|
string,
|
|
unknown
|
|
>,
|
|
personalPublisherId: Id<"publishers"> | undefined,
|
|
isDone: boolean,
|
|
continueCursor: string | null,
|
|
) {
|
|
if (!isDone) {
|
|
void ctx.scheduler.runAfter(
|
|
0,
|
|
fn as never,
|
|
{
|
|
...args,
|
|
cursor: continueCursor ?? undefined,
|
|
} as never,
|
|
);
|
|
return true;
|
|
}
|
|
if (getOwnedPackageScanScope(args) === "ownerUserId" && personalPublisherId) {
|
|
void ctx.scheduler.runAfter(
|
|
0,
|
|
fn as never,
|
|
{
|
|
...args,
|
|
scope: "personalPublisher",
|
|
cursor: undefined,
|
|
} as never,
|
|
);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export const revokePackagePublishTokensForPackageBatchInternal = internalMutation({
|
|
args: {
|
|
packageId: v.id("packages"),
|
|
revokedAt: v.number(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const result = await revokePackagePublishTokensForPackage(ctx, args.packageId, args.revokedAt);
|
|
return { ok: true as const, ...result };
|
|
},
|
|
});
|
|
|
|
export const applyBanToOwnedPackagesBatchInternal = internalMutation({
|
|
args: {
|
|
ownerUserId: v.id("users"),
|
|
bannedAt: v.number(),
|
|
deletedBy: v.id("users"),
|
|
deletedByRole: v.union(v.literal("admin"), v.literal("moderator"), v.literal("user")),
|
|
cursor: v.optional(v.string()),
|
|
scope: ownedPackageScanScopeValidator,
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const owner = await ctx.db.get(args.ownerUserId);
|
|
const scope = getOwnedPackageScanScope(args);
|
|
const isInitialOwnerUserIdBatch = !args.cursor && scope === "ownerUserId";
|
|
const ownerMatchesCurrentBan = owner?.deletedAt === args.bannedAt;
|
|
if (!owner || owner.deactivatedAt || (!isInitialOwnerUserIdBatch && !ownerMatchesCurrentBan)) {
|
|
return {
|
|
ok: true as const,
|
|
deletedCount: 0,
|
|
revokedTokenCount: 0,
|
|
scheduled: false,
|
|
stale: true as const,
|
|
};
|
|
}
|
|
|
|
const personalPublisherId = await getOwnedPackagePersonalPublisherId(ctx, owner);
|
|
const packageQuery =
|
|
scope === "personalPublisher" && personalPublisherId
|
|
? ctx.db
|
|
.query("packages")
|
|
.withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", personalPublisherId))
|
|
: ctx.db
|
|
.query("packages")
|
|
.withIndex("by_owner", (q) => q.eq("ownerUserId", args.ownerUserId));
|
|
const { page, isDone, continueCursor } = await packageQuery.order("desc").paginate({
|
|
cursor: args.cursor ?? null,
|
|
numItems: BAN_USER_PACKAGES_BATCH_SIZE,
|
|
});
|
|
|
|
let deletedCount = 0;
|
|
let revokedTokenCount = 0;
|
|
for (const pkg of page) {
|
|
if (shouldSkipOwnedPackageScanRow(pkg, args)) continue;
|
|
if (!(await isPackageOwnedByPersonalUser(ctx, pkg, owner))) continue;
|
|
const revokeResult = await revokePackagePublishTokensForPackage(ctx, pkg._id, args.bannedAt);
|
|
revokedTokenCount += revokeResult.revokedCount;
|
|
if (pkg.softDeletedAt) {
|
|
if (pkg.softDeletedReason === "user.banned" && pkg.softDeletedAt !== args.bannedAt) {
|
|
const previousBanHiddenAt = pkg.softDeletedAt;
|
|
const releases = await ctx.db
|
|
.query("packageReleases")
|
|
.withIndex("by_package", (q) => q.eq("packageId", pkg._id))
|
|
.collect();
|
|
for (const release of releases) {
|
|
if (release.softDeletedAt === previousBanHiddenAt) {
|
|
await ctx.db.patch(release._id, { softDeletedAt: args.bannedAt });
|
|
}
|
|
}
|
|
const packagePatch: Partial<Doc<"packages">> = {
|
|
softDeletedAt: args.bannedAt,
|
|
softDeletedBy: args.deletedBy,
|
|
softDeletedByRole: args.deletedByRole,
|
|
updatedAt: args.bannedAt,
|
|
};
|
|
const nextPackage: Doc<"packages"> = { ...pkg, ...packagePatch };
|
|
await ctx.db.patch(pkg._id, packagePatch);
|
|
const ownerPublisher = await getOwnerPublisher(ctx, {
|
|
ownerPublisherId: pkg.ownerPublisherId,
|
|
ownerUserId: pkg.ownerUserId,
|
|
});
|
|
await upsertPackageSearchDigest(ctx, {
|
|
...extractPackageDigestFields(nextPackage),
|
|
ownerHandle: ownerPublisher?.handle ?? "",
|
|
ownerKind: ownerPublisher?.kind,
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
|
|
await softDeletePackageDoc(ctx, pkg, {
|
|
actorUserId: args.deletedBy,
|
|
actorRole: args.deletedByRole,
|
|
deletedAt: args.bannedAt,
|
|
reason: "user.banned",
|
|
source: "dashboard",
|
|
});
|
|
deletedCount += 1;
|
|
}
|
|
|
|
const scheduled = scheduleNextOwnedPackageScanBatch(
|
|
ctx,
|
|
internal.packages.applyBanToOwnedPackagesBatchInternal,
|
|
args,
|
|
personalPublisherId,
|
|
isDone,
|
|
continueCursor,
|
|
);
|
|
|
|
return { ok: true as const, deletedCount, revokedTokenCount, scheduled };
|
|
},
|
|
});
|
|
|
|
export const restoreOwnedPackagesForUnbanBatchInternal = internalMutation({
|
|
args: {
|
|
actorUserId: v.optional(v.id("users")),
|
|
ownerUserId: v.id("users"),
|
|
bannedAt: v.number(),
|
|
cursor: v.optional(v.string()),
|
|
scope: ownedPackageScanScopeValidator,
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const actor = args.actorUserId ? await ctx.db.get(args.actorUserId) : null;
|
|
if (args.actorUserId && (!actor || actor.deletedAt || actor.deactivatedAt)) {
|
|
throw new ConvexError("Unauthorized");
|
|
}
|
|
const owner = await ctx.db.get(args.ownerUserId);
|
|
if (!owner || owner.deletedAt || owner.deactivatedAt) {
|
|
return { ok: true as const, restoredCount: 0, scheduled: false, stale: true as const };
|
|
}
|
|
|
|
const scope = getOwnedPackageScanScope(args);
|
|
const personalPublisherId = await getOwnedPackagePersonalPublisherId(ctx, owner);
|
|
const packageQuery =
|
|
scope === "personalPublisher" && personalPublisherId
|
|
? ctx.db
|
|
.query("packages")
|
|
.withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", personalPublisherId))
|
|
: ctx.db
|
|
.query("packages")
|
|
.withIndex("by_owner", (q) => q.eq("ownerUserId", args.ownerUserId));
|
|
const { page, isDone, continueCursor } = await packageQuery.order("desc").paginate({
|
|
cursor: args.cursor ?? null,
|
|
numItems: BAN_USER_PACKAGES_BATCH_SIZE,
|
|
});
|
|
|
|
let restoredCount = 0;
|
|
for (const pkg of page) {
|
|
if (shouldSkipOwnedPackageScanRow(pkg, args)) continue;
|
|
if (!(await isPackageOwnedByPersonalUser(ctx, pkg, owner))) continue;
|
|
if (
|
|
!pkg.softDeletedAt ||
|
|
pkg.softDeletedAt !== args.bannedAt ||
|
|
pkg.softDeletedReason !== "user.banned"
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
await restorePackageDoc(ctx, pkg, {
|
|
actorUserId: actor?._id,
|
|
actorRole: actor?.role,
|
|
allowBanRestore: true,
|
|
releaseSoftDeletedAt: args.bannedAt,
|
|
source: actor ? "dashboard" : "service",
|
|
});
|
|
restoredCount += 1;
|
|
}
|
|
|
|
const scheduled = scheduleNextOwnedPackageScanBatch(
|
|
ctx,
|
|
internal.packages.restoreOwnedPackagesForUnbanBatchInternal,
|
|
args,
|
|
personalPublisherId,
|
|
isDone,
|
|
continueCursor,
|
|
);
|
|
|
|
return { ok: true as const, restoredCount, scheduled };
|
|
},
|
|
});
|
|
|
|
export const applyAccountDeletionToOwnedPackagesBatchInternal = internalMutation({
|
|
args: {
|
|
ownerUserId: v.id("users"),
|
|
deletedAt: v.number(),
|
|
cursor: v.optional(v.string()),
|
|
scope: ownedPackageScanScopeValidator,
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const owner = await ctx.db.get(args.ownerUserId);
|
|
if (!owner) {
|
|
return {
|
|
ok: true as const,
|
|
deletedCount: 0,
|
|
revokedTokenCount: 0,
|
|
scheduled: false,
|
|
stale: true as const,
|
|
};
|
|
}
|
|
|
|
const scope = getOwnedPackageScanScope(args);
|
|
const personalPublisherId = await getOwnedPackagePersonalPublisherId(ctx, owner);
|
|
const packageQuery =
|
|
scope === "personalPublisher" && personalPublisherId
|
|
? ctx.db
|
|
.query("packages")
|
|
.withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", personalPublisherId))
|
|
: ctx.db
|
|
.query("packages")
|
|
.withIndex("by_owner", (q) => q.eq("ownerUserId", args.ownerUserId));
|
|
const { page, isDone, continueCursor } = await packageQuery.order("desc").paginate({
|
|
cursor: args.cursor ?? null,
|
|
numItems: BAN_USER_PACKAGES_BATCH_SIZE,
|
|
});
|
|
|
|
let deletedCount = 0;
|
|
let revokedTokenCount = 0;
|
|
for (const pkg of page) {
|
|
if (shouldSkipOwnedPackageScanRow(pkg, args)) continue;
|
|
if (!(await isPackageOwnedByPersonalUser(ctx, pkg, owner))) continue;
|
|
await softDeletePackageDoc(ctx, pkg, {
|
|
actorUserId: args.ownerUserId,
|
|
deletedAt: args.deletedAt,
|
|
reason: "user.deactivated",
|
|
source: "dashboard",
|
|
});
|
|
void ctx.scheduler.runAfter(0, internal.packages.hardDeletePackageInternal, {
|
|
packageId: pkg._id,
|
|
actorUserId: args.ownerUserId,
|
|
deletedAt: args.deletedAt,
|
|
source: "account.delete",
|
|
});
|
|
deletedCount += 1;
|
|
}
|
|
|
|
const scheduled = scheduleNextOwnedPackageScanBatch(
|
|
ctx,
|
|
internal.packages.applyAccountDeletionToOwnedPackagesBatchInternal,
|
|
args,
|
|
personalPublisherId,
|
|
isDone,
|
|
continueCursor,
|
|
);
|
|
|
|
return { ok: true as const, deletedCount, revokedTokenCount, scheduled };
|
|
},
|
|
});
|
|
|
|
export const applyPublisherDeletionToOwnedPackagesBatchInternal = internalMutation({
|
|
args: {
|
|
ownerPublisherId: v.id("publishers"),
|
|
actorUserId: v.id("users"),
|
|
deletedAt: v.number(),
|
|
cursor: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const publisher = await ctx.db.get(args.ownerPublisherId);
|
|
if (publisher && publisher.deletedAt !== args.deletedAt) {
|
|
return {
|
|
ok: true as const,
|
|
deletedCount: 0,
|
|
revokedTokenCount: 0,
|
|
scheduled: false,
|
|
stale: true as const,
|
|
};
|
|
}
|
|
|
|
const { page, isDone, continueCursor } = await ctx.db
|
|
.query("packages")
|
|
.withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", args.ownerPublisherId))
|
|
.order("desc")
|
|
.paginate({
|
|
cursor: args.cursor ?? null,
|
|
numItems: BAN_USER_PACKAGES_BATCH_SIZE,
|
|
});
|
|
|
|
let deletedCount = 0;
|
|
let revokedTokenCount = 0;
|
|
for (const pkg of page) {
|
|
await softDeletePackageDoc(ctx, pkg, {
|
|
actorUserId: args.actorUserId,
|
|
deletedAt: args.deletedAt,
|
|
reason: "publisher.deleted",
|
|
source: "dashboard",
|
|
});
|
|
void ctx.scheduler.runAfter(0, internal.packages.hardDeletePackageInternal, {
|
|
packageId: pkg._id,
|
|
actorUserId: args.actorUserId,
|
|
deletedAt: args.deletedAt,
|
|
source: "publisher.delete",
|
|
});
|
|
deletedCount += 1;
|
|
}
|
|
|
|
if (!isDone) {
|
|
await ctx.scheduler.runAfter(
|
|
0,
|
|
internal.packages.applyPublisherDeletionToOwnedPackagesBatchInternal,
|
|
{
|
|
...args,
|
|
cursor: continueCursor,
|
|
},
|
|
);
|
|
}
|
|
|
|
return { ok: true as const, deletedCount, revokedTokenCount, scheduled: !isDone };
|
|
},
|
|
});
|
|
|
|
export const softDeletePackageInternal = internalMutation({
|
|
args: {
|
|
userId: v.id("users"),
|
|
name: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const user = await ctx.db.get(args.userId);
|
|
if (!user || user.deletedAt || user.deactivatedAt) throw new Error("User not found");
|
|
|
|
const normalizedName = normalizePackageName(args.name);
|
|
if (!normalizedName) throw new Error("Package name required");
|
|
|
|
const pkg = await getPackageByNormalizedName(ctx, normalizedName);
|
|
if (!pkg) throw new Error("Package not found");
|
|
|
|
if (user.role === "moderator" || user.role === "admin") {
|
|
// Moderators can manage packages outside their own publisher memberships.
|
|
} else {
|
|
await assertCanManageOwnedResource(ctx, {
|
|
actor: user,
|
|
ownerUserId: pkg.ownerUserId,
|
|
ownerPublisherId: pkg.ownerPublisherId,
|
|
allowedPublisherRoles: ["admin"],
|
|
});
|
|
}
|
|
|
|
return await softDeletePackageDoc(ctx, pkg, {
|
|
actorUserId: user._id,
|
|
actorRole: user.role,
|
|
source: "cli",
|
|
});
|
|
},
|
|
});
|
|
|
|
export const restorePackageInternal = internalMutation({
|
|
args: {
|
|
userId: v.id("users"),
|
|
name: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const user = await ctx.db.get(args.userId);
|
|
if (!user || user.deletedAt || user.deactivatedAt) throw new Error("User not found");
|
|
|
|
const normalizedName = normalizePackageName(args.name);
|
|
if (!normalizedName) throw new Error("Package name required");
|
|
|
|
const pkg = await getPackageByNormalizedName(ctx, normalizedName);
|
|
if (!pkg) throw new Error("Package not found");
|
|
|
|
if (user.role === "moderator" || user.role === "admin") {
|
|
// Moderators can manage packages outside their own publisher memberships.
|
|
} else {
|
|
await assertCanManageOwnedResource(ctx, {
|
|
actor: user,
|
|
ownerUserId: pkg.ownerUserId,
|
|
ownerPublisherId: pkg.ownerPublisherId,
|
|
allowedPublisherRoles: ["admin"],
|
|
});
|
|
}
|
|
|
|
return await restorePackageDoc(ctx, pkg, {
|
|
actorUserId: user._id,
|
|
actorRole: user.role,
|
|
source: "cli",
|
|
});
|
|
},
|
|
});
|
|
|
|
export const softDeletePackage = mutation({
|
|
args: {
|
|
packageId: v.id("packages"),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const { user } = await requireUser(ctx);
|
|
const pkg = await ctx.db.get(args.packageId);
|
|
if (!pkg) throw new ConvexError("Package not found");
|
|
|
|
if (user.role === "moderator" || user.role === "admin") {
|
|
// Moderators can manage packages outside their own publisher memberships.
|
|
} else {
|
|
await assertCanManageOwnedResource(ctx, {
|
|
actor: user,
|
|
ownerUserId: pkg.ownerUserId,
|
|
ownerPublisherId: pkg.ownerPublisherId,
|
|
allowedPublisherRoles: ["admin"],
|
|
});
|
|
}
|
|
|
|
return await softDeletePackageDoc(ctx, pkg, {
|
|
actorUserId: user._id,
|
|
actorRole: user.role,
|
|
source: "dashboard",
|
|
});
|
|
},
|
|
});
|
|
|
|
async function hasBoundedAvailablePackageReleaseSurvivor(
|
|
ctx: MutationCtx,
|
|
packageId: Id<"packages">,
|
|
targetReleaseId: Id<"packageReleases">,
|
|
) {
|
|
const candidates = await ctx.db
|
|
.query("packageReleases")
|
|
.withIndex("by_package_active_created", (q) =>
|
|
q.eq("packageId", packageId).eq("softDeletedAt", undefined),
|
|
)
|
|
.take(MAX_POINTERLESS_RELEASE_SURVIVOR_SCAN + 1);
|
|
const hasSurvivor = candidates.some(
|
|
(candidate) =>
|
|
candidate._id !== targetReleaseId &&
|
|
isPackageReleaseAvailableForOwnerDeleteSafety(candidate, packageId),
|
|
);
|
|
if (hasSurvivor) return true;
|
|
if (candidates.length > MAX_POINTERLESS_RELEASE_SURVIVOR_SCAN) {
|
|
throw new ConvexError(
|
|
"This package has too many active releases to safely delete an individual release.",
|
|
);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function isPackageReleaseAvailableForOwnerDeleteSafety(
|
|
release: Doc<"packageReleases"> | null | undefined,
|
|
packageId: Id<"packages">,
|
|
): release is Doc<"packageReleases"> {
|
|
return Boolean(
|
|
release &&
|
|
release.packageId === packageId &&
|
|
!release.softDeletedAt &&
|
|
release.ownerDeletedAt === undefined &&
|
|
resolvePackageReleaseScanStatus(release) !== "malicious",
|
|
);
|
|
}
|
|
|
|
async function hasAvailableLatestPackageReleasePointer(
|
|
ctx: MutationCtx,
|
|
pkg: Pick<Doc<"packages">, "_id" | "latestReleaseId" | "tags">,
|
|
) {
|
|
const pointerIds = new Set<Id<"packageReleases">>();
|
|
if (pkg.latestReleaseId) pointerIds.add(pkg.latestReleaseId);
|
|
if (pkg.tags.latest) pointerIds.add(pkg.tags.latest);
|
|
|
|
for (const pointerId of pointerIds) {
|
|
const pointer = await ctx.db.get(pointerId);
|
|
if (isPackageReleaseAvailableForOwnerDeleteSafety(pointer, pkg._id)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export async function deleteOwnedPackageReleaseForActor(
|
|
ctx: MutationCtx,
|
|
actor: Doc<"users">,
|
|
args: { name: string; version: string },
|
|
) {
|
|
const normalizedName = normalizePackageName(args.name);
|
|
const pkg = await getPackageByNormalizedName(ctx, normalizedName);
|
|
if (!pkg || pkg.softDeletedAt || isPackageBlockedFromPublic(pkg.scanStatus)) {
|
|
throw new ConvexError("This package is unavailable and its releases cannot be deleted.");
|
|
}
|
|
if (pkg.family === "skill") {
|
|
throw new ConvexError("Skill packages must use the skills deletion flow.");
|
|
}
|
|
|
|
await assertCanManageOwnedResource(ctx, {
|
|
actor,
|
|
ownerUserId: pkg.ownerUserId,
|
|
ownerPublisherId: pkg.ownerPublisherId,
|
|
allowedPublisherRoles: ["admin"],
|
|
});
|
|
|
|
const release = await ctx.db
|
|
.query("packageReleases")
|
|
.withIndex("by_package_version", (q) => q.eq("packageId", pkg._id).eq("version", args.version))
|
|
.unique();
|
|
if (!isPackageReleaseAvailableForOwnerDeleteSafety(release, pkg._id)) {
|
|
throw new ConvexError("This package release is already unavailable and cannot be deleted.");
|
|
}
|
|
|
|
let mustPublishReplacement =
|
|
pkg.latestReleaseId === release._id ||
|
|
pkg.tags.latest === release._id ||
|
|
pkg.latestVersionSummary?.version === release.version ||
|
|
release.distTags?.includes("latest") === true;
|
|
if (!mustPublishReplacement && !(await hasAvailableLatestPackageReleasePointer(ctx, pkg))) {
|
|
// Admin cleanup can clear latest pointers, so prove a survivor with a bounded indexed read.
|
|
mustPublishReplacement = !(await hasBoundedAvailablePackageReleaseSurvivor(
|
|
ctx,
|
|
pkg._id,
|
|
release._id,
|
|
));
|
|
}
|
|
if (mustPublishReplacement) {
|
|
throw new ConvexError(
|
|
"Publish a replacement release before deleting the current latest release.",
|
|
);
|
|
}
|
|
|
|
const now = Date.now();
|
|
await ctx.db.patch(release._id, {
|
|
softDeletedAt: now,
|
|
ownerDeletedAt: now,
|
|
ownerDeletedBy: actor._id,
|
|
});
|
|
|
|
const nextTags = Object.fromEntries(
|
|
Object.entries(pkg.tags ?? {}).filter(([, releaseId]) => releaseId !== release._id),
|
|
) as Doc<"packages">["tags"];
|
|
if (Object.keys(nextTags).length !== Object.keys(pkg.tags ?? {}).length) {
|
|
const packagePatch: Partial<Doc<"packages">> = {
|
|
tags: nextTags,
|
|
updatedAt: now,
|
|
};
|
|
const nextPackage: Doc<"packages"> = {
|
|
...pkg,
|
|
...packagePatch,
|
|
};
|
|
await ctx.db.patch(pkg._id, packagePatch);
|
|
const owner = await getOwnerPublisher(ctx, {
|
|
ownerPublisherId: pkg.ownerPublisherId,
|
|
ownerUserId: pkg.ownerUserId,
|
|
});
|
|
await upsertPackageSearchDigest(ctx, {
|
|
...extractPackageDigestFields(nextPackage),
|
|
ownerHandle: owner?.handle ?? "",
|
|
ownerKind: owner?.kind,
|
|
});
|
|
}
|
|
|
|
await ctx.db.insert("auditLogs", {
|
|
actorUserId: actor._id,
|
|
action: "package.release.delete",
|
|
targetType: "packageRelease",
|
|
targetId: release._id,
|
|
metadata: {
|
|
packageId: pkg._id,
|
|
name: pkg.name,
|
|
version: release.version,
|
|
},
|
|
createdAt: now,
|
|
});
|
|
|
|
return { ok: true as const, packageId: pkg._id, releaseId: release._id };
|
|
}
|
|
|
|
export const deleteOwnedReleaseForUserInternal = internalMutation({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
name: v.string(),
|
|
version: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const actor = await ctx.db.get(args.actorUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
|
|
|
const version = args.version.trim();
|
|
if (!version) throw new ConvexError("Version required");
|
|
|
|
return await deleteOwnedPackageReleaseForActor(ctx, actor, {
|
|
name: args.name,
|
|
version,
|
|
});
|
|
},
|
|
});
|
|
|
|
export const deleteOwnedRelease = mutation({
|
|
args: { name: v.string(), version: v.string() },
|
|
handler: async (ctx, args) => {
|
|
const { user } = await requireUser(ctx);
|
|
return await deleteOwnedPackageReleaseForActor(ctx, user, args);
|
|
},
|
|
});
|
|
|
|
function isPackageReleaseRestorableByOwner(
|
|
release: Doc<"packageReleases"> | null | undefined,
|
|
packageId: Id<"packages">,
|
|
actorUserId: Id<"users">,
|
|
): release is Doc<"packageReleases"> {
|
|
return Boolean(
|
|
release &&
|
|
release.packageId === packageId &&
|
|
release.softDeletedAt !== undefined &&
|
|
release.ownerDeletedAt !== undefined &&
|
|
release.softDeletedAt === release.ownerDeletedAt &&
|
|
release.ownerDeletedBy === actorUserId &&
|
|
resolvePackageReleaseScanStatus(release) !== "malicious" &&
|
|
release.manualModeration?.state !== "quarantined" &&
|
|
release.manualModeration?.state !== "revoked",
|
|
);
|
|
}
|
|
|
|
export async function restoreOwnedPackageReleaseForActor(
|
|
ctx: MutationCtx,
|
|
actor: Doc<"users">,
|
|
args: { name: string; version: string },
|
|
) {
|
|
const normalizedName = normalizePackageName(args.name);
|
|
const pkg = await getPackageByNormalizedName(ctx, normalizedName);
|
|
if (!pkg || pkg.softDeletedAt || isPackageBlockedFromPublic(pkg.scanStatus)) {
|
|
throw new ConvexError("This package is unavailable and its releases cannot be restored.");
|
|
}
|
|
if (pkg.family === "skill") {
|
|
throw new ConvexError("Skill packages must use the skills restore flow.");
|
|
}
|
|
|
|
await assertCanManageOwnedResource(ctx, {
|
|
actor,
|
|
ownerUserId: pkg.ownerUserId,
|
|
ownerPublisherId: pkg.ownerPublisherId,
|
|
allowedPublisherRoles: ["admin"],
|
|
});
|
|
|
|
const release = await ctx.db
|
|
.query("packageReleases")
|
|
.withIndex("by_package_version", (q) => q.eq("packageId", pkg._id).eq("version", args.version))
|
|
.unique();
|
|
if (!isPackageReleaseRestorableByOwner(release, pkg._id, actor._id)) {
|
|
throw new ConvexError(
|
|
"This package release was not withdrawn by this owner and cannot be restored.",
|
|
);
|
|
}
|
|
|
|
const now = Date.now();
|
|
await ctx.db.patch(release._id, {
|
|
softDeletedAt: undefined,
|
|
ownerDeletedAt: undefined,
|
|
ownerDeletedBy: undefined,
|
|
distTags: [],
|
|
});
|
|
await ctx.db.insert("auditLogs", {
|
|
actorUserId: actor._id,
|
|
action: "package.release.restore",
|
|
targetType: "packageRelease",
|
|
targetId: release._id,
|
|
metadata: {
|
|
packageId: pkg._id,
|
|
name: pkg.name,
|
|
version: release.version,
|
|
},
|
|
createdAt: now,
|
|
});
|
|
|
|
return { ok: true as const, packageId: pkg._id, releaseId: release._id };
|
|
}
|
|
|
|
export const restoreOwnedReleaseForUserInternal = internalMutation({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
name: v.string(),
|
|
version: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const actor = await ctx.db.get(args.actorUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
|
|
|
const version = args.version.trim();
|
|
if (!version) throw new ConvexError("Version required");
|
|
|
|
return await restoreOwnedPackageReleaseForActor(ctx, actor, {
|
|
name: args.name,
|
|
version,
|
|
});
|
|
},
|
|
});
|
|
|
|
export const restoreOwnedRelease = mutation({
|
|
args: { name: v.string(), version: v.string() },
|
|
handler: async (ctx, args) => {
|
|
const { user } = await requireUser(ctx);
|
|
return await restoreOwnedPackageReleaseForActor(ctx, user, args);
|
|
},
|
|
});
|
|
|
|
export const moderatePackageReleaseForUserInternal = internalMutation({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
name: v.string(),
|
|
version: v.string(),
|
|
state: v.union(v.literal("approved"), v.literal("quarantined"), v.literal("revoked")),
|
|
reason: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const actor = await ctx.db.get(args.actorUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
|
assertModerator(actor);
|
|
|
|
const normalizedName = normalizePackageName(args.name);
|
|
const pkg = await getPackageByNormalizedName(ctx, normalizedName);
|
|
if (!pkg || pkg.softDeletedAt || pkg.family === "skill") {
|
|
throw new ConvexError("Package not found");
|
|
}
|
|
|
|
const release = await ctx.db
|
|
.query("packageReleases")
|
|
.withIndex("by_package_version", (q) =>
|
|
q.eq("packageId", pkg._id).eq("version", args.version),
|
|
)
|
|
.unique();
|
|
if (!release || release.softDeletedAt) throw new ConvexError("Version not found");
|
|
|
|
const now = Date.now();
|
|
const reason = args.reason.trim();
|
|
if (!reason) throw new ConvexError("Moderation reason required");
|
|
|
|
const scanStatus = args.state === "approved" ? ("clean" as const) : ("malicious" as const);
|
|
const verification = release.verification
|
|
? {
|
|
...release.verification,
|
|
scanStatus,
|
|
}
|
|
: release.verification;
|
|
const patch: Partial<Doc<"packageReleases">> = {
|
|
manualModeration: {
|
|
state: args.state,
|
|
reason,
|
|
reviewerUserId: actor._id,
|
|
updatedAt: now,
|
|
},
|
|
verification,
|
|
};
|
|
|
|
await ctx.db.patch(release._id, patch);
|
|
const updatedRelease = { ...release, ...patch } as Doc<"packageReleases">;
|
|
await syncLatestPackageVerification(ctx, updatedRelease);
|
|
await ctx.db.insert("auditLogs", {
|
|
actorUserId: actor._id,
|
|
action: "package.release.moderation",
|
|
targetType: "packageRelease",
|
|
targetId: release._id,
|
|
metadata: {
|
|
packageId: pkg._id,
|
|
packageName: pkg.name,
|
|
version: release.version,
|
|
state: args.state,
|
|
reason,
|
|
},
|
|
createdAt: now,
|
|
});
|
|
|
|
return {
|
|
ok: true as const,
|
|
packageId: pkg._id,
|
|
releaseId: release._id,
|
|
state: args.state,
|
|
scanStatus,
|
|
};
|
|
},
|
|
});
|
|
|
|
async function applyPackageReleaseModerationFinalAction(
|
|
ctx: MutationCtx,
|
|
params: {
|
|
actorUserId: Id<"users">;
|
|
pkg: Doc<"packages">;
|
|
release: Doc<"packageReleases">;
|
|
state: "approved" | "quarantined" | "revoked";
|
|
reason: string;
|
|
sourceKind: "report" | "appeal";
|
|
sourceId: Id<"packageReports"> | Id<"packageAppeals">;
|
|
now: number;
|
|
},
|
|
) {
|
|
const reason = params.reason.trim();
|
|
if (!reason) throw new ConvexError("Moderation reason required");
|
|
|
|
const scanStatus = params.state === "approved" ? ("clean" as const) : ("malicious" as const);
|
|
const verification = params.release.verification
|
|
? {
|
|
...params.release.verification,
|
|
scanStatus,
|
|
}
|
|
: params.release.verification;
|
|
const patch: Partial<Doc<"packageReleases">> = {
|
|
manualModeration: {
|
|
state: params.state,
|
|
reason,
|
|
reviewerUserId: params.actorUserId,
|
|
updatedAt: params.now,
|
|
},
|
|
verification,
|
|
};
|
|
|
|
await ctx.db.patch(params.release._id, patch);
|
|
const updatedRelease = { ...params.release, ...patch } as Doc<"packageReleases">;
|
|
await syncLatestPackageVerification(ctx, updatedRelease);
|
|
await ctx.db.insert("auditLogs", {
|
|
actorUserId: params.actorUserId,
|
|
action: "package.release.moderation",
|
|
targetType: "packageRelease",
|
|
targetId: params.release._id,
|
|
metadata: {
|
|
packageId: params.pkg._id,
|
|
packageName: params.pkg.name,
|
|
version: params.release.version,
|
|
state: params.state,
|
|
reason,
|
|
sourceKind: params.sourceKind,
|
|
sourceId: params.sourceId,
|
|
},
|
|
createdAt: params.now,
|
|
});
|
|
|
|
return { state: params.state, scanStatus };
|
|
}
|
|
|
|
async function countActivePackageReportsForUser(ctx: MutationCtx, userId: Id<"users">) {
|
|
const reports = await ctx.db
|
|
.query("packageReports")
|
|
.withIndex("by_user", (q) => q.eq("userId", userId))
|
|
.collect();
|
|
|
|
let count = 0;
|
|
for (const report of reports) {
|
|
if (report.status !== "open") continue;
|
|
const pkg = await ctx.db.get(report.packageId);
|
|
if (!pkg || pkg.softDeletedAt) continue;
|
|
const owner = await ctx.db.get(pkg.ownerUserId);
|
|
if (!owner || owner.deletedAt || owner.deactivatedAt) continue;
|
|
count += 1;
|
|
if (count >= MAX_ACTIVE_REPORTS_PER_USER) break;
|
|
}
|
|
|
|
return count;
|
|
}
|
|
|
|
export const reportPackageForUserInternal = internalMutation({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
name: v.string(),
|
|
version: v.optional(v.string()),
|
|
reason: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const actor = await ctx.db.get(args.actorUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) {
|
|
throw new ConvexError("Unauthorized");
|
|
}
|
|
|
|
const pkg = await getPackageByNormalizedName(ctx, normalizePackageName(args.name));
|
|
if (!pkg || pkg.softDeletedAt) throw new ConvexError("Package not found");
|
|
if (!(await canViewerReadPackage(ctx, pkg, actor._id))) {
|
|
throw new ConvexError("Package not found");
|
|
}
|
|
|
|
const reason = args.reason.trim();
|
|
if (!reason) throw new ConvexError("Report reason required.");
|
|
|
|
const version = args.version?.trim();
|
|
let release: Doc<"packageReleases"> | null = null;
|
|
if (version) {
|
|
release = await ctx.db
|
|
.query("packageReleases")
|
|
.withIndex("by_package_version", (q) => q.eq("packageId", pkg._id).eq("version", version))
|
|
.unique();
|
|
if (!release || release.softDeletedAt) throw new ConvexError("Package version not found");
|
|
} else if (pkg.latestReleaseId) {
|
|
const latest = await ctx.db.get(pkg.latestReleaseId);
|
|
release = latest && !latest.softDeletedAt ? latest : null;
|
|
}
|
|
|
|
const existing = await ctx.db
|
|
.query("packageReports")
|
|
.withIndex("by_package_user", (q) => q.eq("packageId", pkg._id).eq("userId", actor._id))
|
|
.unique();
|
|
if (existing) {
|
|
if (existing.status !== "open") {
|
|
const activeReports = await countActivePackageReportsForUser(ctx, actor._id);
|
|
if (activeReports >= MAX_ACTIVE_REPORTS_PER_USER) {
|
|
throw new ConvexError(
|
|
"Report limit reached. Please wait for moderation before reporting more.",
|
|
);
|
|
}
|
|
const now = Date.now();
|
|
await ctx.db.patch(existing._id, {
|
|
...(release ? { releaseId: release._id, version: release.version } : {}),
|
|
reason: reason.slice(0, MAX_REPORT_REASON_LENGTH),
|
|
status: "open",
|
|
triagedAt: undefined,
|
|
triagedBy: undefined,
|
|
triageNote: undefined,
|
|
createdAt: now,
|
|
});
|
|
const nextReportCount = (pkg.reportCount ?? 0) + 1;
|
|
await ctx.db.patch(pkg._id, {
|
|
reportCount: nextReportCount,
|
|
lastReportedAt: now,
|
|
});
|
|
const eventMetadata = {
|
|
packageId: pkg._id,
|
|
packageName: pkg.name,
|
|
releaseId: release?._id ?? existing.releaseId ?? null,
|
|
version: release?.version ?? version ?? null,
|
|
reportCount: nextReportCount,
|
|
};
|
|
await appendPackageModerationEventLog(ctx, {
|
|
kind: "report",
|
|
reportId: existing._id,
|
|
actorUserId: actor._id,
|
|
action: "package.report.reopen",
|
|
timelineMetadata: eventMetadata,
|
|
auditAction: "package.report.reopen",
|
|
auditTargetType: "package",
|
|
auditTargetId: pkg._id,
|
|
auditMetadata: {
|
|
reportId: existing._id,
|
|
...eventMetadata,
|
|
},
|
|
createdAt: now,
|
|
});
|
|
return {
|
|
ok: true as const,
|
|
reported: true,
|
|
alreadyReported: false,
|
|
packageId: pkg._id,
|
|
releaseId: release?._id ?? existing.releaseId ?? null,
|
|
reportCount: nextReportCount,
|
|
};
|
|
}
|
|
return {
|
|
ok: true as const,
|
|
reported: false,
|
|
alreadyReported: true,
|
|
packageId: pkg._id,
|
|
releaseId: existing.releaseId ?? null,
|
|
reportCount: pkg.reportCount ?? 0,
|
|
};
|
|
}
|
|
|
|
const activeReports = await countActivePackageReportsForUser(ctx, actor._id);
|
|
if (activeReports >= MAX_ACTIVE_REPORTS_PER_USER) {
|
|
throw new ConvexError(
|
|
"Report limit reached. Please wait for moderation before reporting more.",
|
|
);
|
|
}
|
|
|
|
const now = Date.now();
|
|
const reportId = await ctx.db.insert("packageReports", {
|
|
packageId: pkg._id,
|
|
...(release ? { releaseId: release._id, version: release.version } : {}),
|
|
userId: actor._id,
|
|
reason: reason.slice(0, MAX_REPORT_REASON_LENGTH),
|
|
status: "open",
|
|
createdAt: now,
|
|
});
|
|
|
|
const nextReportCount = (pkg.reportCount ?? 0) + 1;
|
|
await ctx.db.patch(pkg._id, {
|
|
reportCount: nextReportCount,
|
|
lastReportedAt: now,
|
|
});
|
|
|
|
const eventMetadata = {
|
|
packageId: pkg._id,
|
|
packageName: pkg.name,
|
|
releaseId: release?._id ?? null,
|
|
version: release?.version ?? version ?? null,
|
|
reportCount: nextReportCount,
|
|
};
|
|
await appendPackageModerationEventLog(ctx, {
|
|
kind: "report",
|
|
reportId,
|
|
actorUserId: actor._id,
|
|
action: "package.report.submit",
|
|
timelineMetadata: eventMetadata,
|
|
auditAction: "package.report",
|
|
auditTargetType: "package",
|
|
auditTargetId: pkg._id,
|
|
auditMetadata: {
|
|
reportId,
|
|
...eventMetadata,
|
|
},
|
|
createdAt: now,
|
|
});
|
|
|
|
return {
|
|
ok: true as const,
|
|
reported: true,
|
|
alreadyReported: false,
|
|
packageId: pkg._id,
|
|
releaseId: release?._id ?? null,
|
|
reportCount: nextReportCount,
|
|
};
|
|
},
|
|
});
|
|
|
|
function toPackageReportListItem(
|
|
report: Doc<"packageReports">,
|
|
pkg: Doc<"packages">,
|
|
reporter: Doc<"users"> | null,
|
|
): PackageReportListItem {
|
|
return {
|
|
reportId: report._id,
|
|
packageId: pkg._id,
|
|
releaseId: report.releaseId ?? null,
|
|
name: pkg.name,
|
|
displayName: pkg.displayName,
|
|
family: pkg.family,
|
|
version: report.version ?? null,
|
|
reason: report.reason ?? null,
|
|
status: readArtifactReportStatus(report.status),
|
|
createdAt: report.createdAt,
|
|
reporter: {
|
|
userId: report.userId,
|
|
handle: reporter?.handle ?? null,
|
|
displayName: reporter?.displayName ?? reporter?.name ?? null,
|
|
},
|
|
triagedAt: report.triagedAt ?? null,
|
|
triagedBy: report.triagedBy ?? null,
|
|
triageNote: report.triageNote ?? null,
|
|
actionTaken: report.actionTaken ?? null,
|
|
};
|
|
}
|
|
|
|
export const listPackageReportsInternal = internalQuery({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
cursor: v.optional(v.union(v.string(), v.null())),
|
|
limit: v.optional(v.number()),
|
|
status: v.optional(
|
|
v.union(v.literal("open"), v.literal("confirmed"), v.literal("dismissed"), v.literal("all")),
|
|
),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const actor = await ctx.db.get(args.actorUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
|
assertModerator(actor);
|
|
|
|
const limit = Math.max(1, Math.min(Math.round(args.limit ?? 25), 100));
|
|
const status = args.status ?? "open";
|
|
const reportQuery =
|
|
status === "all"
|
|
? ctx.db.query("packageReports").withIndex("by_createdAt", (q) => q)
|
|
: ctx.db
|
|
.query("packageReports")
|
|
.withIndex("by_status_createdAt", (q) => q.eq("status", status));
|
|
const page = await reportQuery.order("desc").paginate({
|
|
cursor: args.cursor ?? null,
|
|
numItems: limit,
|
|
});
|
|
|
|
const items: PackageReportListItem[] = [];
|
|
for (const report of page.page) {
|
|
const pkg = await ctx.db.get(report.packageId);
|
|
if (!pkg || pkg.softDeletedAt || pkg.family === "skill") continue;
|
|
const reporter = await ctx.db.get(report.userId);
|
|
items.push(toPackageReportListItem(report, pkg, reporter));
|
|
}
|
|
|
|
return {
|
|
items,
|
|
nextCursor: page.isDone ? null : page.continueCursor,
|
|
done: page.isDone,
|
|
};
|
|
},
|
|
});
|
|
|
|
export const triagePackageReportForUserInternal = internalMutation({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
reportId: v.id("packageReports"),
|
|
status: v.union(v.literal("open"), v.literal("confirmed"), v.literal("dismissed")),
|
|
note: v.optional(v.string()),
|
|
finalAction: v.optional(
|
|
v.union(v.literal("none"), v.literal("quarantine"), v.literal("revoke")),
|
|
),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const actor = await ctx.db.get(args.actorUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
|
assertModerator(actor);
|
|
|
|
const report = await ctx.db.get(args.reportId);
|
|
if (!report) throw new ConvexError("Package report not found");
|
|
const pkg = await ctx.db.get(report.packageId);
|
|
if (!pkg || pkg.softDeletedAt) throw new ConvexError("Package report not found");
|
|
|
|
const now = Date.now();
|
|
const previousStatus = readArtifactReportStatus(report.status);
|
|
const nextStatus = args.status;
|
|
assertArtifactReportTransition(previousStatus, nextStatus);
|
|
const wasOpen = previousStatus === "open";
|
|
const willBeOpen = nextStatus === "open";
|
|
const note = args.note?.trim();
|
|
if (!willBeOpen && !note) throw new ConvexError("Review note required.");
|
|
const finalAction = args.finalAction ?? "none";
|
|
assertArtifactReportFinalAction(nextStatus, finalAction, ["quarantine", "revoke"]);
|
|
|
|
await ctx.db.patch(report._id, {
|
|
status: nextStatus,
|
|
triagedAt: willBeOpen ? undefined : now,
|
|
triagedBy: willBeOpen ? undefined : actor._id,
|
|
triageNote: willBeOpen ? undefined : note?.slice(0, MAX_REPORT_REASON_LENGTH),
|
|
actionTaken: willBeOpen ? undefined : finalAction,
|
|
});
|
|
|
|
let reportCount = pkg.reportCount ?? 0;
|
|
if (wasOpen && !willBeOpen) reportCount = Math.max(0, reportCount - 1);
|
|
if (!wasOpen && willBeOpen) reportCount += 1;
|
|
if (reportCount !== (pkg.reportCount ?? 0)) {
|
|
await ctx.db.patch(pkg._id, {
|
|
reportCount,
|
|
...(willBeOpen ? { lastReportedAt: now } : {}),
|
|
});
|
|
}
|
|
|
|
let moderatedRelease: Doc<"packageReleases"> | null = null;
|
|
if (finalAction !== "none") {
|
|
const releaseId = report.releaseId ?? pkg.latestReleaseId;
|
|
if (!releaseId) throw new ConvexError("Package report has no release to moderate");
|
|
const release = await ctx.db.get(releaseId);
|
|
if (!release || release.softDeletedAt) {
|
|
throw new ConvexError("Package report release not found");
|
|
}
|
|
moderatedRelease = release;
|
|
await applyPackageReleaseModerationFinalAction(ctx, {
|
|
actorUserId: actor._id,
|
|
pkg,
|
|
release,
|
|
state: finalAction === "quarantine" ? "quarantined" : "revoked",
|
|
reason: note ?? "",
|
|
sourceKind: "report",
|
|
sourceId: report._id,
|
|
now,
|
|
});
|
|
}
|
|
|
|
const eventMetadata = {
|
|
packageId: pkg._id,
|
|
packageName: pkg.name,
|
|
status: args.status,
|
|
finalAction,
|
|
releaseId: moderatedRelease?._id ?? report.releaseId ?? null,
|
|
version: moderatedRelease?.version ?? report.version ?? null,
|
|
reportCount,
|
|
};
|
|
await appendPackageModerationEventLog(ctx, {
|
|
kind: "report",
|
|
reportId: report._id,
|
|
actorUserId: actor._id,
|
|
action: "package.report.triage",
|
|
timelineMetadata: eventMetadata,
|
|
auditAction: "package.report.triage",
|
|
auditTargetType: "packageReport",
|
|
auditTargetId: report._id,
|
|
auditMetadata: eventMetadata,
|
|
createdAt: now,
|
|
});
|
|
|
|
return {
|
|
ok: true as const,
|
|
reportId: report._id,
|
|
packageId: pkg._id,
|
|
status: args.status,
|
|
reportCount,
|
|
actionTaken: finalAction,
|
|
};
|
|
},
|
|
});
|
|
|
|
export const getPackageModerationStatusForUserInternal = internalQuery({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
name: v.string(),
|
|
},
|
|
handler: async (ctx, args): Promise<PackageModerationStatus> => {
|
|
const actor = await ctx.db.get(args.actorUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
|
|
|
const pkg = await getPackageByNormalizedName(ctx, normalizePackageName(args.name));
|
|
if (!pkg || pkg.softDeletedAt) throw new ConvexError("Package not found");
|
|
|
|
const canSeeOwnerStatus = await viewerCanAccessPackageOwner(ctx, pkg, actor._id);
|
|
const canSeeStaffStatus = actor.role === "admin" || actor.role === "moderator";
|
|
if (!canSeeOwnerStatus && !canSeeStaffStatus) throw new ConvexError("Unauthorized");
|
|
|
|
const latestRelease = pkg.latestReleaseId ? await ctx.db.get(pkg.latestReleaseId) : null;
|
|
const activeLatestRelease =
|
|
latestRelease && !latestRelease.softDeletedAt ? latestRelease : null;
|
|
const latestReleaseStatus = activeLatestRelease
|
|
? (() => {
|
|
const releaseScanStatus = resolvePackageReleaseScanStatus(activeLatestRelease);
|
|
return {
|
|
releaseId: activeLatestRelease._id,
|
|
version: activeLatestRelease.version,
|
|
artifactKind: activeLatestRelease.artifactKind ?? null,
|
|
scanStatus: releaseScanStatus,
|
|
moderationState: activeLatestRelease.manualModeration?.state ?? null,
|
|
moderationReason: activeLatestRelease.manualModeration?.reason ?? null,
|
|
blockedFromDownload: releaseScanStatus === "malicious",
|
|
reasons: getPackageTrustReasons(
|
|
activeLatestRelease,
|
|
releaseScanStatus,
|
|
pkg.reportCount ?? 0,
|
|
),
|
|
createdAt: activeLatestRelease.createdAt,
|
|
};
|
|
})()
|
|
: null;
|
|
|
|
return {
|
|
package: {
|
|
packageId: pkg._id,
|
|
name: pkg.name,
|
|
displayName: pkg.displayName,
|
|
family: pkg.family,
|
|
channel: pkg.channel,
|
|
isOfficial: pkg.isOfficial,
|
|
reportCount: pkg.reportCount ?? 0,
|
|
lastReportedAt: pkg.lastReportedAt ?? null,
|
|
scanStatus: latestReleaseStatus?.scanStatus ?? pkg.scanStatus,
|
|
},
|
|
latestRelease: latestReleaseStatus,
|
|
};
|
|
},
|
|
});
|
|
|
|
// Deprecated compatibility path. First-class appeal intake is no longer exposed
|
|
// in the CLI/docs; keep this route backed until legacy clients age out.
|
|
export const submitPackageAppealForUserInternal = internalMutation({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
name: v.string(),
|
|
version: v.string(),
|
|
message: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const actor = await ctx.db.get(args.actorUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
|
|
|
const pkg = await getPackageByNormalizedName(ctx, normalizePackageName(args.name));
|
|
if (!pkg || pkg.softDeletedAt) throw new ConvexError("Package not found");
|
|
if (!(await viewerCanAccessPackageOwner(ctx, pkg, actor._id))) {
|
|
throw new ConvexError("Unauthorized");
|
|
}
|
|
|
|
const version = args.version.trim();
|
|
if (!version) throw new ConvexError("Package version required");
|
|
const release = await ctx.db
|
|
.query("packageReleases")
|
|
.withIndex("by_package_version", (q) => q.eq("packageId", pkg._id).eq("version", version))
|
|
.unique();
|
|
if (!release || release.softDeletedAt) throw new ConvexError("Package version not found");
|
|
|
|
const scanStatus = resolvePackageReleaseScanStatus(release);
|
|
const moderationState = release.manualModeration?.state ?? null;
|
|
const isAppealable =
|
|
moderationState === "quarantined" ||
|
|
moderationState === "revoked" ||
|
|
scanStatus === "suspicious" ||
|
|
scanStatus === "malicious";
|
|
if (!isAppealable) throw new ConvexError("Package release is not in an appealable state");
|
|
|
|
const message = args.message.trim();
|
|
if (!message) throw new ConvexError("Appeal message required.");
|
|
|
|
const existingOpenAppeal = await ctx.db
|
|
.query("packageAppeals")
|
|
.withIndex("by_release_status_createdAt", (q) =>
|
|
q.eq("releaseId", release._id).eq("status", "open"),
|
|
)
|
|
.order("desc")
|
|
.first();
|
|
if (existingOpenAppeal) {
|
|
return {
|
|
ok: true as const,
|
|
submitted: false,
|
|
alreadyOpen: true,
|
|
appealId: existingOpenAppeal._id,
|
|
packageId: pkg._id,
|
|
releaseId: release._id,
|
|
status: existingOpenAppeal.status,
|
|
};
|
|
}
|
|
|
|
const now = Date.now();
|
|
const appealId = await ctx.db.insert("packageAppeals", {
|
|
packageId: pkg._id,
|
|
releaseId: release._id,
|
|
version: release.version,
|
|
userId: actor._id,
|
|
message: message.slice(0, MAX_APPEAL_MESSAGE_LENGTH),
|
|
status: "open",
|
|
createdAt: now,
|
|
});
|
|
|
|
const eventMetadata = {
|
|
packageId: pkg._id,
|
|
releaseId: release._id,
|
|
packageName: pkg.name,
|
|
version: release.version,
|
|
moderationState,
|
|
scanStatus,
|
|
};
|
|
await appendPackageModerationEventLog(ctx, {
|
|
kind: "appeal",
|
|
appealId,
|
|
actorUserId: actor._id,
|
|
action: "package.appeal.submit",
|
|
timelineMetadata: eventMetadata,
|
|
auditAction: "package.appeal.submit",
|
|
auditTargetType: "packageAppeal",
|
|
auditTargetId: appealId,
|
|
auditMetadata: eventMetadata,
|
|
createdAt: now,
|
|
});
|
|
|
|
return {
|
|
ok: true as const,
|
|
submitted: true,
|
|
alreadyOpen: false,
|
|
appealId,
|
|
packageId: pkg._id,
|
|
releaseId: release._id,
|
|
status: "open" as const,
|
|
};
|
|
},
|
|
});
|
|
|
|
function toPackageAppealListItem(
|
|
appeal: Doc<"packageAppeals">,
|
|
pkg: Doc<"packages">,
|
|
submitter: Doc<"users"> | null,
|
|
): PackageAppealListItem {
|
|
return {
|
|
appealId: appeal._id,
|
|
packageId: pkg._id,
|
|
releaseId: appeal.releaseId,
|
|
name: pkg.name,
|
|
displayName: pkg.displayName,
|
|
family: pkg.family,
|
|
version: appeal.version,
|
|
message: appeal.message,
|
|
status: appeal.status,
|
|
createdAt: appeal.createdAt,
|
|
submitter: {
|
|
userId: appeal.userId,
|
|
handle: submitter?.handle ?? null,
|
|
displayName: submitter?.displayName ?? submitter?.name ?? null,
|
|
},
|
|
resolvedAt: appeal.resolvedAt ?? null,
|
|
resolvedBy: appeal.resolvedBy ?? null,
|
|
resolutionNote: appeal.resolutionNote ?? null,
|
|
actionTaken: appeal.actionTaken ?? null,
|
|
};
|
|
}
|
|
|
|
export const listPackageAppealsInternal = internalQuery({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
cursor: v.optional(v.union(v.string(), v.null())),
|
|
limit: v.optional(v.number()),
|
|
status: v.optional(
|
|
v.union(v.literal("open"), v.literal("accepted"), v.literal("rejected"), v.literal("all")),
|
|
),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const actor = await ctx.db.get(args.actorUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
|
assertModerator(actor);
|
|
|
|
const limit = Math.max(1, Math.min(Math.round(args.limit ?? 25), 100));
|
|
const status = args.status ?? "open";
|
|
const appealQuery =
|
|
status === "all"
|
|
? ctx.db.query("packageAppeals").withIndex("by_createdAt", (q) => q)
|
|
: ctx.db
|
|
.query("packageAppeals")
|
|
.withIndex("by_status_createdAt", (q) => q.eq("status", status));
|
|
const page = await appealQuery.order("desc").paginate({
|
|
cursor: args.cursor ?? null,
|
|
numItems: limit,
|
|
});
|
|
|
|
const items: PackageAppealListItem[] = [];
|
|
for (const appeal of page.page) {
|
|
const pkg = await ctx.db.get(appeal.packageId);
|
|
if (!pkg || pkg.softDeletedAt || pkg.family === "skill") continue;
|
|
const submitter = await ctx.db.get(appeal.userId);
|
|
items.push(toPackageAppealListItem(appeal, pkg, submitter));
|
|
}
|
|
|
|
return {
|
|
items,
|
|
nextCursor: page.isDone ? null : page.continueCursor,
|
|
done: page.isDone,
|
|
};
|
|
},
|
|
});
|
|
|
|
export const resolvePackageAppealForUserInternal = internalMutation({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
appealId: v.id("packageAppeals"),
|
|
status: v.union(v.literal("open"), v.literal("accepted"), v.literal("rejected")),
|
|
note: v.optional(v.string()),
|
|
finalAction: v.optional(v.union(v.literal("none"), v.literal("approve"))),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const actor = await ctx.db.get(args.actorUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
|
assertModerator(actor);
|
|
|
|
const appeal = await ctx.db.get(args.appealId);
|
|
if (!appeal) throw new ConvexError("Package appeal not found");
|
|
const pkg = await ctx.db.get(appeal.packageId);
|
|
if (!pkg || pkg.softDeletedAt) throw new ConvexError("Package appeal not found");
|
|
|
|
const note = args.note?.trim();
|
|
const isOpen = args.status === "open";
|
|
assertArtifactAppealTransition(appeal.status, args.status);
|
|
if (!isOpen && !note) throw new ConvexError("Resolution note required.");
|
|
const finalAction = args.finalAction ?? "none";
|
|
assertArtifactAppealFinalAction(args.status, finalAction, ["approve"]);
|
|
const now = Date.now();
|
|
|
|
await ctx.db.patch(appeal._id, {
|
|
status: args.status,
|
|
resolvedAt: isOpen ? undefined : now,
|
|
resolvedBy: isOpen ? undefined : actor._id,
|
|
resolutionNote: isOpen ? undefined : note?.slice(0, MAX_APPEAL_MESSAGE_LENGTH),
|
|
actionTaken: isOpen ? undefined : finalAction,
|
|
});
|
|
|
|
if (finalAction === "approve") {
|
|
const release = await ctx.db.get(appeal.releaseId);
|
|
if (!release || release.softDeletedAt)
|
|
throw new ConvexError("Package appeal release not found");
|
|
await applyPackageReleaseModerationFinalAction(ctx, {
|
|
actorUserId: actor._id,
|
|
pkg,
|
|
release,
|
|
state: "approved",
|
|
reason: note ?? "",
|
|
sourceKind: "appeal",
|
|
sourceId: appeal._id,
|
|
now,
|
|
});
|
|
}
|
|
|
|
const eventMetadata = {
|
|
packageId: pkg._id,
|
|
releaseId: appeal.releaseId,
|
|
packageName: pkg.name,
|
|
version: appeal.version,
|
|
status: args.status,
|
|
finalAction,
|
|
};
|
|
await appendPackageModerationEventLog(ctx, {
|
|
kind: "appeal",
|
|
appealId: appeal._id,
|
|
actorUserId: actor._id,
|
|
action: "package.appeal.resolve",
|
|
timelineMetadata: eventMetadata,
|
|
auditAction: "package.appeal.resolve",
|
|
auditTargetType: "packageAppeal",
|
|
auditTargetId: appeal._id,
|
|
auditMetadata: eventMetadata,
|
|
createdAt: now,
|
|
});
|
|
|
|
return {
|
|
ok: true as const,
|
|
appealId: appeal._id,
|
|
packageId: pkg._id,
|
|
releaseId: appeal.releaseId,
|
|
status: args.status,
|
|
actionTaken: finalAction,
|
|
};
|
|
},
|
|
});
|
|
|
|
export const listPackageModerationEventLogsInternal = internalQuery({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
kind: v.union(v.literal("report"), v.literal("appeal")),
|
|
reportId: v.optional(v.id("packageReports")),
|
|
appealId: v.optional(v.id("packageAppeals")),
|
|
limit: v.optional(v.number()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const actor = await ctx.db.get(args.actorUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
|
assertModerator(actor);
|
|
|
|
const limit = Math.max(1, Math.min(Math.round(args.limit ?? 50), 100));
|
|
if (args.kind === "report") {
|
|
if (!args.reportId) throw new ConvexError("reportId required");
|
|
return await ctx.db
|
|
.query("packageModerationEventLogs")
|
|
.withIndex("by_report_createdAt", (q) => q.eq("reportId", args.reportId))
|
|
.order("asc")
|
|
.take(limit);
|
|
}
|
|
if (!args.appealId) throw new ConvexError("appealId required");
|
|
return await ctx.db
|
|
.query("packageModerationEventLogs")
|
|
.withIndex("by_appeal_createdAt", (q) => q.eq("appealId", args.appealId))
|
|
.order("asc")
|
|
.take(limit);
|
|
},
|
|
});
|
|
|
|
function normalizeOfficialMigrationId(raw: string) {
|
|
const value = raw.trim().toLowerCase();
|
|
if (!value) throw new ConvexError("Bundled plugin id required");
|
|
if (value.length > MAX_OFFICIAL_MIGRATION_FIELD_LENGTH) {
|
|
throw new ConvexError("Bundled plugin id too long");
|
|
}
|
|
if (!/^[a-z0-9][a-z0-9._:-]*$/.test(value)) {
|
|
throw new ConvexError(
|
|
"Bundled plugin id must use letters, numbers, dot, dash, underscore, or colon.",
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function normalizeOptionalMigrationText(raw: string | undefined) {
|
|
const value = raw?.trim();
|
|
if (!value) return undefined;
|
|
return value.slice(0, MAX_OFFICIAL_MIGRATION_FIELD_LENGTH);
|
|
}
|
|
|
|
function normalizeMigrationBlockers(raw: string[] | undefined) {
|
|
if (!raw) return undefined;
|
|
const blockers: string[] = [];
|
|
const seen = new Set<string>();
|
|
for (const blocker of raw) {
|
|
const value = blocker.trim().slice(0, MAX_OFFICIAL_MIGRATION_FIELD_LENGTH);
|
|
if (!value || seen.has(value)) continue;
|
|
seen.add(value);
|
|
blockers.push(value);
|
|
if (blockers.length >= MAX_OFFICIAL_MIGRATION_BLOCKERS) break;
|
|
}
|
|
return blockers;
|
|
}
|
|
|
|
function toPackageOfficialMigrationItem(
|
|
migration: Doc<"officialPluginMigrations">,
|
|
): PackageOfficialMigrationListItem {
|
|
return {
|
|
migrationId: migration._id,
|
|
bundledPluginId: migration.bundledPluginId,
|
|
packageName: migration.packageName,
|
|
packageId: migration.packageId ?? null,
|
|
owner: migration.owner ?? null,
|
|
sourceRepo: migration.sourceRepo ?? null,
|
|
sourcePath: migration.sourcePath ?? null,
|
|
sourceCommit: migration.sourceCommit ?? null,
|
|
phase: migration.phase,
|
|
blockers: migration.blockers,
|
|
hostTargetsComplete: migration.hostTargetsComplete,
|
|
scanClean: migration.scanClean,
|
|
moderationApproved: migration.moderationApproved,
|
|
runtimeBundlesReady: migration.runtimeBundlesReady,
|
|
notes: migration.notes ?? null,
|
|
createdAt: migration.createdAt,
|
|
updatedAt: migration.updatedAt,
|
|
};
|
|
}
|
|
|
|
export const listOfficialPluginMigrationsInternal = internalQuery({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
cursor: v.optional(v.union(v.string(), v.null())),
|
|
limit: v.optional(v.number()),
|
|
phase: v.optional(v.union(packageOfficialMigrationPhaseValidator, v.literal("all"))),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const actor = await ctx.db.get(args.actorUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
|
assertModerator(actor);
|
|
|
|
const limit = Math.max(1, Math.min(Math.round(args.limit ?? 25), 100));
|
|
const phase: PackageOfficialMigrationListPhase = args.phase ?? "all";
|
|
const migrationQuery =
|
|
phase === "all"
|
|
? ctx.db.query("officialPluginMigrations").withIndex("by_updatedAt", (q) => q)
|
|
: ctx.db
|
|
.query("officialPluginMigrations")
|
|
.withIndex("by_phase_updatedAt", (q) => q.eq("phase", phase));
|
|
const page = await migrationQuery.order("desc").paginate({
|
|
cursor: args.cursor ?? null,
|
|
numItems: limit,
|
|
});
|
|
|
|
return {
|
|
items: page.page.map(toPackageOfficialMigrationItem),
|
|
nextCursor: page.isDone ? null : page.continueCursor,
|
|
done: page.isDone,
|
|
};
|
|
},
|
|
});
|
|
|
|
export const upsertOfficialPluginMigrationForUserInternal = internalMutation({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
bundledPluginId: v.string(),
|
|
packageName: v.string(),
|
|
owner: v.optional(v.string()),
|
|
sourceRepo: v.optional(v.string()),
|
|
sourcePath: v.optional(v.string()),
|
|
sourceCommit: v.optional(v.string()),
|
|
phase: v.optional(packageOfficialMigrationPhaseValidator),
|
|
blockers: v.optional(v.array(v.string())),
|
|
hostTargetsComplete: v.optional(v.boolean()),
|
|
scanClean: v.optional(v.boolean()),
|
|
moderationApproved: v.optional(v.boolean()),
|
|
runtimeBundlesReady: v.optional(v.boolean()),
|
|
notes: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const actor = await ctx.db.get(args.actorUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
|
assertAdmin(actor);
|
|
|
|
const bundledPluginId = normalizeOfficialMigrationId(args.bundledPluginId);
|
|
const packageName = normalizePackageName(args.packageName);
|
|
const packageDoc = await ctx.db
|
|
.query("packages")
|
|
.withIndex("by_name", (q) => q.eq("normalizedName", packageName))
|
|
.unique();
|
|
const existing = await ctx.db
|
|
.query("officialPluginMigrations")
|
|
.withIndex("by_bundled_plugin", (q) => q.eq("bundledPluginId", bundledPluginId))
|
|
.unique();
|
|
const blockers = normalizeMigrationBlockers(args.blockers);
|
|
const now = Date.now();
|
|
|
|
if (existing) {
|
|
const patch: Partial<Doc<"officialPluginMigrations">> = {
|
|
packageName,
|
|
packageId: packageDoc && !packageDoc.softDeletedAt ? packageDoc._id : undefined,
|
|
owner: normalizeOptionalMigrationText(args.owner),
|
|
sourceRepo: normalizeOptionalMigrationText(args.sourceRepo),
|
|
sourcePath: normalizeOptionalMigrationText(args.sourcePath),
|
|
sourceCommit: normalizeOptionalMigrationText(args.sourceCommit),
|
|
phase: args.phase ?? existing.phase,
|
|
blockers: blockers ?? existing.blockers,
|
|
hostTargetsComplete: args.hostTargetsComplete ?? existing.hostTargetsComplete,
|
|
scanClean: args.scanClean ?? existing.scanClean,
|
|
moderationApproved: args.moderationApproved ?? existing.moderationApproved,
|
|
runtimeBundlesReady: args.runtimeBundlesReady ?? existing.runtimeBundlesReady,
|
|
notes: args.notes?.trim().slice(0, MAX_OFFICIAL_MIGRATION_NOTES_LENGTH),
|
|
updatedAt: now,
|
|
};
|
|
await ctx.db.patch(existing._id, patch);
|
|
const migration = { ...existing, ...patch } as Doc<"officialPluginMigrations">;
|
|
await ctx.db.insert("auditLogs", {
|
|
actorUserId: actor._id,
|
|
action: "package.official_migration.upsert",
|
|
targetType: "officialPluginMigration",
|
|
targetId: existing._id,
|
|
metadata: {
|
|
bundledPluginId,
|
|
packageName,
|
|
phase: migration.phase,
|
|
packageId: migration.packageId,
|
|
},
|
|
createdAt: now,
|
|
});
|
|
return { ok: true as const, migration: toPackageOfficialMigrationItem(migration) };
|
|
}
|
|
|
|
const phase: PackageOfficialMigrationPhase =
|
|
args.phase ?? (blockers && blockers.length > 0 ? "blocked" : "planned");
|
|
const migrationId = await ctx.db.insert("officialPluginMigrations", {
|
|
bundledPluginId,
|
|
packageName,
|
|
packageId: packageDoc && !packageDoc.softDeletedAt ? packageDoc._id : undefined,
|
|
owner: normalizeOptionalMigrationText(args.owner),
|
|
sourceRepo: normalizeOptionalMigrationText(args.sourceRepo),
|
|
sourcePath: normalizeOptionalMigrationText(args.sourcePath),
|
|
sourceCommit: normalizeOptionalMigrationText(args.sourceCommit),
|
|
phase,
|
|
blockers: blockers ?? [],
|
|
hostTargetsComplete: args.hostTargetsComplete ?? false,
|
|
scanClean: args.scanClean ?? false,
|
|
moderationApproved: args.moderationApproved ?? false,
|
|
runtimeBundlesReady: args.runtimeBundlesReady ?? false,
|
|
notes: args.notes?.trim().slice(0, MAX_OFFICIAL_MIGRATION_NOTES_LENGTH),
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
const migration = (await ctx.db.get(migrationId))!;
|
|
await ctx.db.insert("auditLogs", {
|
|
actorUserId: actor._id,
|
|
action: "package.official_migration.upsert",
|
|
targetType: "officialPluginMigration",
|
|
targetId: migrationId,
|
|
metadata: {
|
|
bundledPluginId,
|
|
packageName,
|
|
phase,
|
|
packageId: migration.packageId,
|
|
},
|
|
createdAt: now,
|
|
});
|
|
|
|
return { ok: true as const, migration: toPackageOfficialMigrationItem(migration) };
|
|
},
|
|
});
|
|
|
|
export const listPackageModerationQueueInternal = internalQuery({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
cursor: v.optional(v.union(v.string(), v.null())),
|
|
limit: v.optional(v.number()),
|
|
status: v.optional(
|
|
v.union(v.literal("open"), v.literal("blocked"), v.literal("manual"), v.literal("all")),
|
|
),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const actor = await ctx.db.get(args.actorUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
|
assertModerator(actor);
|
|
|
|
const limit = Math.max(1, Math.min(Math.round(args.limit ?? 25), 100));
|
|
const status = args.status ?? "open";
|
|
let cursor = args.cursor ?? null;
|
|
let done = false;
|
|
let scannedPages = 0;
|
|
const items: PackageModerationQueueItem[] = [];
|
|
const seenReleaseIds = new Set<string>();
|
|
|
|
if (status === "open" || status === "all") {
|
|
const reports = await ctx.db
|
|
.query("packageReports")
|
|
.withIndex("by_status_createdAt", (q) => q.eq("status", "open"))
|
|
.order("desc")
|
|
.take(limit * 3);
|
|
|
|
for (const report of reports) {
|
|
if (items.length >= limit) break;
|
|
const pkg = await ctx.db.get(report.packageId);
|
|
if (!pkg || pkg.softDeletedAt || pkg.family === "skill" || !pkg.latestReleaseId) continue;
|
|
const release = await ctx.db.get(pkg.latestReleaseId);
|
|
if (!release || release.softDeletedAt || seenReleaseIds.has(release._id)) continue;
|
|
const item = toPackageModerationQueueItem(pkg, release);
|
|
if (!shouldIncludePackageReportsInModerationQueue(item.reportCount, status)) continue;
|
|
seenReleaseIds.add(release._id);
|
|
items.push(item);
|
|
}
|
|
}
|
|
|
|
while (items.length < limit && !done && scannedPages < 5) {
|
|
const page = await ctx.db
|
|
.query("packageReleases")
|
|
.withIndex("by_active_created", (q) => q.eq("softDeletedAt", undefined))
|
|
.order("desc")
|
|
.paginate({
|
|
cursor,
|
|
numItems: limit,
|
|
});
|
|
|
|
scannedPages += 1;
|
|
cursor = page.continueCursor;
|
|
done = page.isDone;
|
|
|
|
for (const release of page.page) {
|
|
if (items.length >= limit) break;
|
|
const scanStatus = resolvePackageReleaseScanStatus(release);
|
|
const pkg = await ctx.db.get(release.packageId);
|
|
if (!pkg || pkg.softDeletedAt || pkg.family === "skill") continue;
|
|
const reportCount = pkg.reportCount ?? 0;
|
|
const releaseNeedsReview = shouldIncludeReleaseInModerationQueue(
|
|
release,
|
|
scanStatus,
|
|
status,
|
|
);
|
|
const packageReportsNeedReview = shouldIncludePackageReportsInModerationQueue(
|
|
reportCount,
|
|
status,
|
|
);
|
|
if (
|
|
!releaseNeedsReview &&
|
|
(!packageReportsNeedReview || pkg.latestReleaseId !== release._id)
|
|
)
|
|
continue;
|
|
if (seenReleaseIds.has(release._id)) continue;
|
|
seenReleaseIds.add(release._id);
|
|
items.push(toPackageModerationQueueItem(pkg, release));
|
|
}
|
|
}
|
|
|
|
return {
|
|
items,
|
|
nextCursor: done ? null : cursor,
|
|
done,
|
|
};
|
|
},
|
|
});
|
|
|
|
export const getReleaseByIdInternal = internalQuery({
|
|
args: { releaseId: v.id("packageReleases") },
|
|
handler: async (ctx, args) => {
|
|
return await ctx.db.get(args.releaseId);
|
|
},
|
|
});
|
|
|
|
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"),
|
|
version: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
return await ctx.db
|
|
.query("packageReleases")
|
|
.withIndex("by_package_version", (q) =>
|
|
q.eq("packageId", args.packageId).eq("version", args.version),
|
|
)
|
|
.unique();
|
|
},
|
|
});
|
|
|
|
export const getReleasesByIdsInternal = internalQuery({
|
|
args: { releaseIds: v.array(v.id("packageReleases")) },
|
|
handler: async (ctx, args) => {
|
|
return (
|
|
await Promise.all(
|
|
args.releaseIds.map(async (releaseId) => {
|
|
const release = await ctx.db.get(releaseId);
|
|
return release && !release.softDeletedAt ? release : null;
|
|
}),
|
|
)
|
|
).filter(Boolean);
|
|
},
|
|
});
|
|
|
|
export const getPackageReleaseScanBackfillBatchInternal = internalQuery({
|
|
args: {
|
|
cursor: v.optional(v.number()),
|
|
batchSize: v.optional(v.number()),
|
|
prioritizeRecent: v.optional(v.boolean()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const batchSize = Math.max(1, Math.min(args.batchSize ?? 50, 200));
|
|
const cursor = args.cursor ?? 0;
|
|
const prioritizeRecent = args.prioritizeRecent ?? true;
|
|
|
|
const [recentReleases, backlogReleases] = await Promise.all([
|
|
prioritizeRecent
|
|
? ctx.db
|
|
.query("packageReleases")
|
|
.order("desc")
|
|
.take(batchSize * 2)
|
|
: Promise.resolve([]),
|
|
ctx.db
|
|
.query("packageReleases")
|
|
.withIndex("by_creation_time", (q) => q.gt("_creationTime", cursor))
|
|
.order("asc")
|
|
.take(batchSize * 3),
|
|
]);
|
|
|
|
const releases = [
|
|
...recentReleases,
|
|
...backlogReleases.filter(
|
|
(release, index, all) =>
|
|
recentReleases.findIndex((candidate) => candidate._id === release._id) === -1 &&
|
|
all.findIndex((candidate) => candidate._id === release._id) === index,
|
|
),
|
|
];
|
|
|
|
const results: Array<{
|
|
releaseId: Id<"packageReleases">;
|
|
packageId: Id<"packages">;
|
|
needsVt: boolean;
|
|
needsLlm: boolean;
|
|
needsStatic: boolean;
|
|
}> = [];
|
|
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.vtAnalysis;
|
|
const needsLlm = !release.llmAnalysis || release.llmAnalysis.status === "error";
|
|
const needsStatic = !release.staticScan;
|
|
if (!needsVt && !needsLlm && !needsStatic) continue;
|
|
|
|
results.push({
|
|
releaseId: release._id,
|
|
packageId: release.packageId,
|
|
needsVt,
|
|
needsLlm,
|
|
needsStatic,
|
|
});
|
|
}
|
|
|
|
return {
|
|
releases: results,
|
|
nextCursor,
|
|
done: backlogReleases.length < batchSize * 3,
|
|
};
|
|
},
|
|
});
|
|
|
|
function buildGitHubActionsPublishActor(
|
|
publishToken: Doc<"packagePublishTokens">,
|
|
): Extract<PackagePublishActor, { kind: "github-actions" }> {
|
|
return {
|
|
kind: "github-actions",
|
|
repository: publishToken.repository,
|
|
workflow: publishToken.workflowFilename,
|
|
runId: publishToken.runId,
|
|
runAttempt: publishToken.runAttempt,
|
|
sha: publishToken.sha,
|
|
};
|
|
}
|
|
|
|
function resolveTrustedPublishSource(
|
|
payload: ServerPackagePublishRequest,
|
|
publishToken: Doc<"packagePublishTokens">,
|
|
): ServerPackagePublishRequest["source"] {
|
|
const source = payload.source;
|
|
if (source && source.kind !== "github") {
|
|
throw new ConvexError("Trusted publishes only support GitHub source metadata");
|
|
}
|
|
const requestedRepo =
|
|
typeof source?.repo === "string" && source.repo.trim()
|
|
? (normalizeGitHubRepository(source.repo) ?? source.repo.trim())
|
|
: undefined;
|
|
if (requestedRepo && requestedRepo !== publishToken.repository) {
|
|
throw new ConvexError("Trusted publish source repo must match the verified GitHub repository");
|
|
}
|
|
if (source?.commit && source.commit !== publishToken.sha) {
|
|
throw new ConvexError("Trusted publish source commit must match the verified GitHub SHA");
|
|
}
|
|
if (source?.ref && source.ref !== publishToken.ref) {
|
|
throw new ConvexError("Trusted publish source ref must match the verified GitHub ref");
|
|
}
|
|
const path = source?.path?.trim() || ".";
|
|
return {
|
|
kind: "github",
|
|
url: `https://github.com/${publishToken.repository}`,
|
|
repo: publishToken.repository,
|
|
ref: publishToken.ref,
|
|
commit: publishToken.sha,
|
|
path,
|
|
importedAt: source?.importedAt ?? Date.now(),
|
|
};
|
|
}
|
|
|
|
function doesTrustedPublisherMatchPublishToken(
|
|
trustedPublisher: PackageTrustedPublisherDoc | null,
|
|
publishToken: Doc<"packagePublishTokens">,
|
|
) {
|
|
return Boolean(
|
|
trustedPublisher &&
|
|
trustedPublisher.packageId === publishToken.packageId &&
|
|
trustedPublisher.provider === publishToken.provider &&
|
|
trustedPublisher.repository === publishToken.repository &&
|
|
trustedPublisher.repositoryId === publishToken.repositoryId &&
|
|
trustedPublisher.repositoryOwner === publishToken.repositoryOwner &&
|
|
trustedPublisher.repositoryOwnerId === publishToken.repositoryOwnerId &&
|
|
trustedPublisher.workflowFilename === publishToken.workflowFilename &&
|
|
trustedPublisher.environment === publishToken.environment,
|
|
);
|
|
}
|
|
|
|
async function runPackageInspectorPublishGate(
|
|
ctx: Pick<ActionCtx, "runAction">,
|
|
args: {
|
|
packageName: string;
|
|
version: string;
|
|
files: ReturnType<typeof normalizePublishFiles>;
|
|
},
|
|
): Promise<PackageInspectorPublishResult> {
|
|
const result = await runActionRef<PackageInspectorPublishResult>(
|
|
ctx,
|
|
internalRefs.packageInspectorNode.runPackageInspectorForPublishInternal,
|
|
{
|
|
packageName: args.packageName,
|
|
version: args.version,
|
|
files: args.files,
|
|
},
|
|
);
|
|
if (result.status === "fail" || result.summary.breakageCount > 0) {
|
|
throw new ConvexError(formatPackageInspectorBlockedPublishError(result));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function formatPackageInspectorBlockedPublishError(result: PackageInspectorPublishResult) {
|
|
const count = Math.max(result.summary.breakageCount, result.breakages.length, 1);
|
|
const noun = count === 1 ? "breakage" : "breakages";
|
|
const details = result.breakages
|
|
.slice(0, 3)
|
|
.map((finding) => `${finding.code}: ${finding.message}`)
|
|
.join("; ");
|
|
return `Plugin Inspector blocked publish: ${count} ${noun}${details ? `. ${details}` : ""}`;
|
|
}
|
|
|
|
function packageInspectorWarningDedupeKey(
|
|
warning: Pick<PackageInspectorFinding, "id" | "code" | "message" | "evidence" | "fixture"> & {
|
|
inspectorVersion?: string;
|
|
targetOpenClawVersion?: string;
|
|
},
|
|
) {
|
|
return JSON.stringify([
|
|
warning.id ?? "",
|
|
warning.code,
|
|
warning.message,
|
|
warning.fixture ?? "",
|
|
warning.evidence ?? [],
|
|
warning.inspectorVersion ?? "",
|
|
warning.targetOpenClawVersion ?? "",
|
|
]);
|
|
}
|
|
|
|
async function verifyPublishFileStorageMetadata(
|
|
ctx: Pick<ActionCtx, "storage">,
|
|
files: ReturnType<typeof normalizePublishFiles>,
|
|
) {
|
|
const verified = await Promise.all(
|
|
files.map(async (file) => {
|
|
const blob = await ctx.storage.get(file.storageId as Id<"_storage">);
|
|
if (!blob) throw new ConvexError(`Uploaded file no longer exists: ${file.path}`);
|
|
const bytes = new Uint8Array(await blob.arrayBuffer());
|
|
return {
|
|
file: {
|
|
...file,
|
|
size: blob.size,
|
|
sha256: await sha256Hex(bytes),
|
|
contentType: file.contentType?.trim() || blob.type || undefined,
|
|
},
|
|
zipEntry: { path: file.path, bytes },
|
|
};
|
|
}),
|
|
);
|
|
return {
|
|
files: verified.map(({ file }) => file),
|
|
legacyZipEntries: verified.map(({ zipEntry }) => zipEntry),
|
|
};
|
|
}
|
|
|
|
function normalizeStoredPluginCategoryOverride(categories: readonly string[] | undefined) {
|
|
if (categories === undefined) return undefined;
|
|
try {
|
|
return normalizePluginCategories(categories);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
async function withSkillMarkdownTextsForManifestSummary(
|
|
ctx: Pick<ActionCtx, "storage">,
|
|
files: ReturnType<typeof normalizePublishFiles>,
|
|
) {
|
|
const summaryFiles: Array<
|
|
(typeof files)[number] & {
|
|
text?: string;
|
|
}
|
|
> = [];
|
|
for (const file of files) {
|
|
const lower = file.path.toLowerCase();
|
|
if (lower === "skill.md" || lower.endsWith("/skill.md")) {
|
|
summaryFiles.push({
|
|
...file,
|
|
text: await readStorageText(ctx, file.storageId),
|
|
});
|
|
} else {
|
|
summaryFiles.push(file);
|
|
}
|
|
}
|
|
return summaryFiles;
|
|
}
|
|
|
|
async function publishPackageImpl(
|
|
ctx: Parameters<typeof requireGitHubAccountAge>[0] &
|
|
Pick<ActionCtx, "storage" | "scheduler" | "runAction">,
|
|
auth: PackagePublishAuthContext,
|
|
rawPayload: unknown,
|
|
options: PackagePublishOptions = {},
|
|
) {
|
|
const payload = parseArk<ServerPackagePublishRequest>(
|
|
ServerPackagePublishRequestSchema,
|
|
rawPayload,
|
|
"Package publish payload",
|
|
);
|
|
if (payload.family === "claw" && !experimentalClawsEnabled()) {
|
|
throw new ConvexError("Experimental Claw publication is disabled");
|
|
}
|
|
if (payload.family === "skill") {
|
|
throw new ConvexError("Skill packages must use the skills publish flow");
|
|
}
|
|
const family = payload.family;
|
|
const name = normalizePackageName(payload.name);
|
|
if (payload.family === "claw" && payload.name !== name) {
|
|
throw new ConvexError(`Claw package name must use canonical form ${name}`);
|
|
}
|
|
const version = assertPackageVersion(family, payload.version);
|
|
const existingPackage = await runQueryRef<Doc<"packages"> | null>(
|
|
ctx,
|
|
internalRefs.packages.getPackageByNameInternal,
|
|
{ name },
|
|
);
|
|
const existingTrustedPublisher = existingPackage
|
|
? await runQueryRef<PackageTrustedPublisherDoc | null>(
|
|
ctx,
|
|
internalRefs.packages.getTrustedPublisherByPackageIdInternal,
|
|
{ packageId: existingPackage._id },
|
|
)
|
|
: null;
|
|
|
|
let actorUserId: Id<"users">;
|
|
let ownerUserId: Id<"users">;
|
|
let ownerPublisherId: Id<"publishers"> | undefined;
|
|
let publishActor: PackagePublishActor;
|
|
let effectiveSource = payload.source;
|
|
const manualOverrideReason = normalizeManualOverrideReason(payload.manualOverrideReason);
|
|
|
|
if (auth.kind === "github-actions") {
|
|
if (!existingPackage) {
|
|
throw new ConvexError("First publish must be manual by a logged-in package owner");
|
|
}
|
|
if (auth.publishToken.packageId !== existingPackage._id) {
|
|
throw new ConvexError("Trusted publish token does not match the target package");
|
|
}
|
|
if (auth.publishToken.version !== version) {
|
|
throw new ConvexError("Trusted publish token does not match the target version");
|
|
}
|
|
if (payload.ownerHandle?.trim()) {
|
|
throw new ConvexError("Trusted publishes must not override the package owner");
|
|
}
|
|
if (payload.channel && payload.channel !== existingPackage.channel) {
|
|
throw new ConvexError("Trusted publishes must not change the package channel");
|
|
}
|
|
actorUserId = existingPackage.ownerUserId;
|
|
ownerUserId = existingPackage.ownerUserId;
|
|
ownerPublisherId = existingPackage.ownerPublisherId;
|
|
publishActor = buildGitHubActionsPublishActor(auth.publishToken);
|
|
effectiveSource = resolveTrustedPublishSource(payload, auth.publishToken);
|
|
} else {
|
|
actorUserId = auth.actorUserId;
|
|
await requireGitHubAccountAge(ctx, actorUserId);
|
|
const actor = await runQueryRef<Doc<"users"> | null>(ctx, internalRefs.users.getByIdInternal, {
|
|
userId: actorUserId,
|
|
});
|
|
const ownerMismatch = getPackageScopeOwnerMismatch(name, payload.ownerHandle);
|
|
if (ownerMismatch) throw new ConvexError(ownerMismatch.message);
|
|
const scopedOwnerHandle = inferOwnerHandleFromScopedPackageName(name);
|
|
const ownerHandle = normalizePublisherHandle(payload.ownerHandle) ?? scopedOwnerHandle;
|
|
let ownerTarget: {
|
|
publisherId: Id<"publishers">;
|
|
linkedUserId?: Id<"users">;
|
|
} | null;
|
|
try {
|
|
ownerTarget = await runMutationRef<{
|
|
publisherId: Id<"publishers">;
|
|
linkedUserId?: Id<"users">;
|
|
} | null>(ctx, internalRefs.publishers.resolvePublishTargetForUserInternal, {
|
|
actorUserId,
|
|
ownerHandle,
|
|
minimumRole: "publisher",
|
|
});
|
|
} catch (error) {
|
|
if (scopedOwnerHandle && error instanceof Error) {
|
|
if (/not found/i.test(error.message)) {
|
|
let legacyPersonalOwnerHandle: string | undefined;
|
|
if (existingPackage && actor && existingPackage.ownerUserId === actor._id) {
|
|
if (existingPackage.ownerPublisherId) {
|
|
const existingOwnerPublisher = await runQueryRef<Doc<"publishers"> | null>(
|
|
ctx,
|
|
internalRefs.publishers.getByIdInternal,
|
|
{ publisherId: existingPackage.ownerPublisherId },
|
|
);
|
|
if (
|
|
existingOwnerPublisher?.kind === "user" &&
|
|
existingOwnerPublisher.linkedUserId === actor._id
|
|
) {
|
|
legacyPersonalOwnerHandle = normalizePublisherHandle(existingOwnerPublisher.handle);
|
|
}
|
|
} else {
|
|
legacyPersonalOwnerHandle = normalizePublisherHandle(actor.handle);
|
|
}
|
|
}
|
|
throw new ConvexError(
|
|
getScopedPackageMissingPublisherMessage({
|
|
scopedOwnerHandle,
|
|
packageName: name,
|
|
legacyPersonalOwnerHandle,
|
|
}),
|
|
);
|
|
}
|
|
if (/forbidden|publish access/i.test(error.message)) {
|
|
throw new ConvexError(
|
|
getScopedPackagePublishAccessMessage({ scopedOwnerHandle, packageName: name }),
|
|
);
|
|
}
|
|
}
|
|
throw error;
|
|
}
|
|
ownerUserId = ownerTarget?.linkedUserId ?? actorUserId;
|
|
ownerPublisherId = ownerTarget?.publisherId;
|
|
if (existingTrustedPublisher && !manualOverrideReason && actor?.role !== "admin") {
|
|
throw new ConvexError(
|
|
"Manual publishes for packages with trusted publisher config require manualOverrideReason",
|
|
);
|
|
}
|
|
publishActor = { kind: "user", userId: actorUserId };
|
|
}
|
|
|
|
const displayName = payload.displayName?.trim() || name;
|
|
const { files, legacyZipEntries } = await verifyPublishFileStorageMetadata(
|
|
ctx,
|
|
normalizePublishFiles(payload.files),
|
|
);
|
|
if (payload.artifact?.kind !== "npm-pack") {
|
|
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_PUBLISH_TOTAL_BYTES) {
|
|
throw new ConvexError(getPublishTotalSizeError("package"));
|
|
}
|
|
const legacyZipBytes = buildDeterministicPackageZip(legacyZipEntries);
|
|
const legacyZipSha256 = await sha256Hex(legacyZipBytes);
|
|
|
|
if (family === "code-plugin" && (!effectiveSource?.repo || !effectiveSource?.commit)) {
|
|
throw new ConvexError("Code plugins require source repo and commit metadata");
|
|
}
|
|
|
|
const packageJsonEntry = await readOptionalTextFile(
|
|
ctx,
|
|
files,
|
|
(path) => path === "package.json",
|
|
family === "claw"
|
|
? {
|
|
exactPath: true,
|
|
maxBytes: 256 * 1024,
|
|
label: "Claw package.json",
|
|
strictUtf8: true,
|
|
}
|
|
: undefined,
|
|
);
|
|
const pluginManifestEntry = await readOptionalTextFile(
|
|
ctx,
|
|
files,
|
|
(path) => path === "openclaw.plugin.json",
|
|
);
|
|
let detectedBundleFormat: string | undefined;
|
|
let bundleManifestEntry: Awaited<ReturnType<typeof readOptionalTextFile>> | undefined;
|
|
for (const marker of REAL_BUNDLE_MANIFESTS) {
|
|
const entry = await readOptionalTextFile(ctx, files, (path) => path === marker.path);
|
|
if (entry) {
|
|
bundleManifestEntry = entry;
|
|
detectedBundleFormat = marker.format;
|
|
break;
|
|
}
|
|
}
|
|
const readmeEntry = await readOptionalTextFile(
|
|
ctx,
|
|
files,
|
|
(path) => path === "readme.md" || path === "readme.mdx",
|
|
);
|
|
|
|
const packageJson = maybeParseJson(packageJsonEntry?.text);
|
|
const declaredClawManifestPath =
|
|
family === "claw" &&
|
|
packageJson &&
|
|
typeof packageJson.openclaw === "object" &&
|
|
packageJson.openclaw !== null &&
|
|
!Array.isArray(packageJson.openclaw) &&
|
|
typeof (packageJson.openclaw as Record<string, unknown>).claw === "string"
|
|
? ((packageJson.openclaw as Record<string, unknown>).claw as string)
|
|
: undefined;
|
|
const clawManifestEntry = declaredClawManifestPath
|
|
? await readOptionalTextFile(ctx, files, (path) => path === declaredClawManifestPath, {
|
|
exactPath: true,
|
|
maxBytes: 1024 * 1024,
|
|
label: "Claw manifest",
|
|
strictUtf8: true,
|
|
})
|
|
: null;
|
|
const pluginManifest = maybeParseJson(pluginManifestEntry?.text);
|
|
const bundleManifest = maybeParseJson(bundleManifestEntry?.text);
|
|
const storedPackageJson = toConvexSafeJsonValue(packageJson, {
|
|
maxDepth: MAX_STORED_PACKAGE_METADATA_DEPTH,
|
|
});
|
|
const storedPluginManifest = toConvexSafeJsonValue(pluginManifest, {
|
|
maxDepth: MAX_STORED_PACKAGE_METADATA_DEPTH,
|
|
});
|
|
const storedBundleManifest = toConvexSafeJsonValue(bundleManifest, {
|
|
maxDepth: MAX_STORED_PACKAGE_METADATA_DEPTH,
|
|
});
|
|
if (family !== "claw" && packageJson) ensurePluginNameMatchesPackage(name, packageJson);
|
|
if (family !== "claw" && !pluginManifest) {
|
|
throw new ConvexError("openclaw.plugin.json is required for plugin packages");
|
|
}
|
|
const clawValidationFiles = files.map((file) => ({
|
|
path: file.path,
|
|
...(clawManifestEntry?.file.path === file.path ? { text: clawManifestEntry.text } : {}),
|
|
}));
|
|
let clawPackage =
|
|
family === "claw"
|
|
? validateClawPackageContents({
|
|
packageName: name,
|
|
version,
|
|
packageJson,
|
|
files: clawValidationFiles,
|
|
})
|
|
: null;
|
|
if (clawPackage && !clawPackage.ok) {
|
|
const profilePath = clawPackage.issues.find(
|
|
(entry) => entry.code === "missing_openclaw_profile",
|
|
)?.path;
|
|
if (profilePath) {
|
|
const profileEntry = await readOptionalTextFile(ctx, files, (path) => path === profilePath, {
|
|
exactPath: true,
|
|
maxBytes: 256 * 1024,
|
|
label: "OpenClaw profile",
|
|
strictUtf8: true,
|
|
});
|
|
if (profileEntry) {
|
|
clawPackage = validateClawPackageContents({
|
|
packageName: name,
|
|
version,
|
|
packageJson,
|
|
files: clawValidationFiles.map((file) =>
|
|
file.path === profileEntry.file.path ? { ...file, text: profileEntry.text } : file,
|
|
),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
if (clawPackage && !clawPackage.ok) {
|
|
throw new ConvexError(
|
|
`Invalid Claw package: ${clawPackage.issues
|
|
.map((entry) => `${entry.path}: ${entry.message}`)
|
|
.join(" ")}`,
|
|
);
|
|
}
|
|
const validatedClaw = clawPackage?.ok ? clawPackage.value : undefined;
|
|
const icon = family === "claw" ? undefined : normalizePluginManifestIcon(pluginManifest);
|
|
if (family === "code-plugin") {
|
|
const validation = validateOpenClawExternalCodePluginPackageContents(
|
|
packageJson,
|
|
files.map((file) => file.path),
|
|
);
|
|
if (validation.issues.length > 0) {
|
|
throw new ConvexError(validation.issues.map((issue) => issue.message).join(" "));
|
|
}
|
|
}
|
|
if (payload.artifact?.kind === "npm-pack") {
|
|
if (!packageJson) throw new ConvexError("ClawPack must contain package.json");
|
|
const declaredVersion =
|
|
typeof packageJson.version === "string" ? packageJson.version.trim() : "";
|
|
if (declaredVersion !== version) {
|
|
throw new ConvexError(`ClawPack package.json version must match ${version}`);
|
|
}
|
|
}
|
|
|
|
const bundleArtifacts =
|
|
family === "bundle-plugin"
|
|
? extractBundlePluginArtifacts({
|
|
packageName: name,
|
|
packageJson,
|
|
pluginManifest:
|
|
pluginManifest ??
|
|
(() => {
|
|
throw new ConvexError("openclaw.plugin.json is required for plugin packages");
|
|
})(),
|
|
bundleManifest,
|
|
bundleMetadata:
|
|
payload.bundle || detectedBundleFormat
|
|
? {
|
|
...payload.bundle,
|
|
format: payload.bundle?.format ?? detectedBundleFormat,
|
|
}
|
|
: undefined,
|
|
source: effectiveSource,
|
|
})
|
|
: null;
|
|
|
|
const codeArtifacts =
|
|
family === "code-plugin"
|
|
? extractCodePluginArtifacts({
|
|
packageName: name,
|
|
packageJson:
|
|
packageJson ??
|
|
(() => {
|
|
throw new ConvexError("package.json is required for code plugins");
|
|
})(),
|
|
pluginManifest:
|
|
pluginManifest ??
|
|
(() => {
|
|
throw new ConvexError("openclaw.plugin.json is required for plugin packages");
|
|
})(),
|
|
source: effectiveSource,
|
|
})
|
|
: null;
|
|
|
|
const summary =
|
|
validatedClaw?.summary.agent.description ||
|
|
validatedClaw?.summary.agent.name ||
|
|
summarizePackageForSearch({
|
|
packageName: name,
|
|
packageJson,
|
|
readmeText: readmeEntry?.text ?? null,
|
|
});
|
|
let categories: string[];
|
|
let normalizedTopics: string[];
|
|
try {
|
|
const declaredCategories =
|
|
payload.categories ?? normalizeStoredPluginCategoryOverride(existingPackage?.categories);
|
|
categories =
|
|
family === "claw"
|
|
? (declaredCategories ?? [])
|
|
: resolvePluginCategories({ declared: declaredCategories });
|
|
normalizedTopics = normalizeCatalogTopics(payload.topics ?? existingPackage?.topics);
|
|
} catch (error) {
|
|
throw new ConvexError(error instanceof Error ? error.message : "Invalid catalog metadata");
|
|
}
|
|
const topics = normalizedTopics.length ? normalizedTopics : undefined;
|
|
const staticScan = await runStaticPublishScan(ctx, {
|
|
slug: name,
|
|
displayName,
|
|
summary,
|
|
metadata: {
|
|
packageJson,
|
|
pluginManifest,
|
|
bundleManifest,
|
|
clawManifest: validatedClaw?.manifest,
|
|
source: effectiveSource,
|
|
},
|
|
files,
|
|
});
|
|
const inspectorResult =
|
|
family === "code-plugin" || family === "bundle-plugin"
|
|
? await runPackageInspectorPublishGate(ctx, {
|
|
packageName: name,
|
|
version,
|
|
files,
|
|
})
|
|
: null;
|
|
const ownerPublisher = ownerPublisherId
|
|
? await runQueryRef<Doc<"publishers"> | null>(ctx, internalRefs.publishers.getByIdInternal, {
|
|
publisherId: ownerPublisherId,
|
|
})
|
|
: null;
|
|
const trustedOpenClawPlugin = isTrustedOpenClawPluginPackage({
|
|
family,
|
|
normalizedName: name,
|
|
ownerPublisher,
|
|
});
|
|
const verificationSource = codeArtifacts?.verification ?? bundleArtifacts?.verification;
|
|
const initialScanStatus = trustedOpenClawPlugin ? "clean" : "pending";
|
|
const verification = verificationSource
|
|
? {
|
|
...verificationSource,
|
|
trustedOpenClawPlugin: trustedOpenClawPlugin || undefined,
|
|
scanStatus: initialScanStatus,
|
|
}
|
|
: undefined;
|
|
const integritySha256 = await hashSkillFiles(
|
|
files.map((file) => ({ path: file.path, sha256: file.sha256 })),
|
|
);
|
|
const pluginManifestSummary =
|
|
family === "claw"
|
|
? undefined
|
|
: derivePluginManifestSummary({
|
|
pluginManifest:
|
|
pluginManifest ??
|
|
(() => {
|
|
throw new ConvexError("openclaw.plugin.json is required for plugin packages");
|
|
})(),
|
|
...(bundleManifest ? { skillManifest: bundleManifest } : {}),
|
|
compatibility: codeArtifacts?.compatibility ?? bundleArtifacts?.compatibility,
|
|
files: await withSkillMarkdownTextsForManifestSummary(ctx, files),
|
|
});
|
|
|
|
const legacyZipStorageId =
|
|
payload.artifact?.kind === "npm-pack"
|
|
? undefined
|
|
: await ctx.storage.store(
|
|
new Blob(
|
|
[
|
|
legacyZipBytes.buffer.slice(
|
|
legacyZipBytes.byteOffset,
|
|
legacyZipBytes.byteOffset + legacyZipBytes.byteLength,
|
|
) as ArrayBuffer,
|
|
],
|
|
{ type: "application/zip" },
|
|
),
|
|
);
|
|
|
|
const packageInsertArgs = {
|
|
actorUserId,
|
|
ownerUserId,
|
|
ownerPublisherId,
|
|
publishActor,
|
|
name,
|
|
displayName,
|
|
family,
|
|
version,
|
|
changelog: payload.changelog.trim(),
|
|
tags: payload.tags?.map((tag: string) => tag.trim()).filter(Boolean) ?? ["latest"],
|
|
summary,
|
|
...(icon ? { icon } : {}),
|
|
categories,
|
|
topics,
|
|
sourceRepo: effectiveSource?.repo || effectiveSource?.url,
|
|
runtimeId: codeArtifacts?.runtimeId ?? bundleArtifacts?.runtimeId,
|
|
channel: payload.channel,
|
|
compatibility: codeArtifacts?.compatibility ?? bundleArtifacts?.compatibility,
|
|
verification,
|
|
staticScan,
|
|
files,
|
|
integritySha256,
|
|
sha256hash: legacyZipSha256,
|
|
artifactKind: payload.artifact?.kind ?? "legacy-zip",
|
|
clawpackStorageId:
|
|
(payload.artifact?.storageId as Id<"_storage"> | undefined) ?? legacyZipStorageId,
|
|
clawpackSha256: payload.artifact?.sha256 ?? legacyZipSha256,
|
|
clawpackSize: payload.artifact?.size ?? legacyZipBytes.byteLength,
|
|
clawpackFormat: payload.artifact?.format,
|
|
npmIntegrity: payload.artifact?.npmIntegrity,
|
|
npmShasum: payload.artifact?.npmShasum,
|
|
npmTarballName: payload.artifact?.npmTarballName,
|
|
npmUnpackedSize: payload.artifact?.npmUnpackedSize,
|
|
npmFileCount: payload.artifact?.npmFileCount,
|
|
allowExistingRelease:
|
|
auth.kind === "github-actions" ||
|
|
(auth.kind === "user" && manualOverrideReason?.startsWith("GitHub Actions ")),
|
|
extractedPackageJson: storedPackageJson,
|
|
extractedPluginManifest: storedPluginManifest,
|
|
normalizedBundleManifest: family === "bundle-plugin" ? storedBundleManifest : undefined,
|
|
pluginManifestSummary,
|
|
clawManifestSummary: validatedClaw?.summary,
|
|
source: effectiveSource,
|
|
};
|
|
|
|
const inspectorFindings =
|
|
inspectorResult?.warnings.map((finding) =>
|
|
toPackageInspectorPublishResponseFinding(finding, inspectorResult.metadata),
|
|
) ?? [];
|
|
|
|
if (options.stagePrePublicationChecks) {
|
|
let existingRelease: Doc<"packageReleases"> | null = null;
|
|
if (existingPackage) {
|
|
existingRelease = await runQueryRef<Doc<"packageReleases"> | null>(
|
|
ctx,
|
|
internalRefs.packages.getReleaseByPackageAndVersionInternal,
|
|
{ packageId: existingPackage._id, version },
|
|
);
|
|
}
|
|
const existingAttempt = await runQueryRef<null | { attemptId: Id<"publishAttempts"> }>(
|
|
ctx,
|
|
internalRefs.publishAttempts.findExistingPublishAttemptForArtifactInternal,
|
|
{
|
|
kind: "package",
|
|
slug: name,
|
|
version,
|
|
},
|
|
);
|
|
if (existingAttempt) {
|
|
throw new ConvexError(
|
|
`Version ${version} already exists. Increment the version number and try again.`,
|
|
);
|
|
}
|
|
if (existingPackage && existingRelease) {
|
|
if (!existingRelease.softDeletedAt && existingRelease.publicationStatus === "pending") {
|
|
await runMutationRef(ctx, internalRefs.packages.discardPendingPackagePublicationInternal, {
|
|
packageId: existingPackage._id,
|
|
releaseId: existingRelease._id,
|
|
createdNewParent: hasNoPublishedPackageVersions(existingPackage),
|
|
});
|
|
throw new ConvexError(
|
|
`Previous pending publish for ${version} did not finish creating security checks. It was cleaned up; retry the publish.`,
|
|
);
|
|
}
|
|
throw new ConvexError(
|
|
`Version ${version} already exists. Increment the version number and try again.`,
|
|
);
|
|
}
|
|
|
|
const pendingResult = await runMutationRef<{
|
|
ok: true;
|
|
packageId: Id<"packages">;
|
|
releaseId: Id<"packageReleases">;
|
|
publicationStatus?: "pending" | "published";
|
|
createdNewParent?: boolean;
|
|
}>(ctx, internalRefs.packages.insertReleaseInternal, {
|
|
...packageInsertArgs,
|
|
publicationStatus: "pending",
|
|
});
|
|
|
|
const staged = await runMutationRef<{
|
|
attemptId: Id<"publishAttempts">;
|
|
status: string;
|
|
result?: { ok: true; packageId: Id<"packages">; releaseId: Id<"packageReleases"> };
|
|
}>(ctx, internalRefs.publishAttempts.createPackagePublishAttemptInternal, {
|
|
userId: actorUserId,
|
|
ownerUserId,
|
|
ownerPublisherId,
|
|
packageId: pendingResult.packageId,
|
|
packageReleaseId: pendingResult.releaseId,
|
|
createdNewParent: pendingResult.createdNewParent,
|
|
name,
|
|
displayName,
|
|
version,
|
|
idempotencyKey: buildPackagePublishAttemptIdempotencyKey({
|
|
actorUserId,
|
|
ownerPublisherId,
|
|
ownerUserId,
|
|
name,
|
|
version,
|
|
integritySha256,
|
|
}),
|
|
artifactFingerprint: integritySha256,
|
|
files,
|
|
clawpackStorageId: packageInsertArgs.clawpackStorageId,
|
|
scanContext: buildPackagePublishAttemptScanContext(packageInsertArgs),
|
|
packageFollowup: stripUndefinedForStoredAttempt({
|
|
ownerUserId,
|
|
ownerPublisherId,
|
|
packageName: name,
|
|
version,
|
|
inspectorWarnings: inspectorResult?.warnings ?? [],
|
|
inspectorMetadata: inspectorResult?.metadata,
|
|
trustedPublishTokenId: auth.kind === "github-actions" ? auth.publishToken._id : undefined,
|
|
manualOverrideAudit:
|
|
auth.kind === "user" && existingTrustedPublisher && manualOverrideReason
|
|
? {
|
|
actorUserId,
|
|
version,
|
|
reason: manualOverrideReason,
|
|
trustedPublisher: {
|
|
provider: existingTrustedPublisher.provider,
|
|
repository: existingTrustedPublisher.repository,
|
|
workflowFilename: existingTrustedPublisher.workflowFilename,
|
|
environment: existingTrustedPublisher.environment,
|
|
},
|
|
}
|
|
: undefined,
|
|
githubActionsAudit:
|
|
auth.kind === "github-actions"
|
|
? {
|
|
actorUserId,
|
|
version,
|
|
repository: auth.publishToken.repository,
|
|
workflowFilename: auth.publishToken.workflowFilename,
|
|
environment: auth.publishToken.environment,
|
|
runId: auth.publishToken.runId,
|
|
runAttempt: auth.publishToken.runAttempt,
|
|
sha: auth.publishToken.sha,
|
|
}
|
|
: undefined,
|
|
}),
|
|
}).catch(async (error) => {
|
|
await runMutationRef(ctx, internalRefs.packages.discardPendingPackagePublicationInternal, {
|
|
packageId: pendingResult.packageId,
|
|
releaseId: pendingResult.releaseId,
|
|
createdNewParent: pendingResult.createdNewParent,
|
|
});
|
|
throw error;
|
|
});
|
|
if (auth.kind === "github-actions") {
|
|
await runMutationRef(ctx, internalRefs.packagePublishTokens.revokeInternal, {
|
|
tokenId: auth.publishToken._id,
|
|
});
|
|
}
|
|
|
|
if (staged.status === "finalized" && staged.result) {
|
|
const finalizedResult = {
|
|
...staged.result,
|
|
publicationStatus: "published" as const,
|
|
};
|
|
return inspectorFindings.length > 0
|
|
? { ...finalizedResult, inspectorFindings }
|
|
: finalizedResult;
|
|
}
|
|
|
|
return {
|
|
ok: true as const,
|
|
status: "pending" as const,
|
|
packageId: pendingResult.packageId,
|
|
releaseId: pendingResult.releaseId,
|
|
publicationStatus: "pending" as const,
|
|
attemptId: staged.attemptId,
|
|
packageName: name,
|
|
version,
|
|
...(inspectorFindings.length > 0 ? { inspectorFindings } : {}),
|
|
};
|
|
}
|
|
|
|
const publishResult = await runMutationRef<{
|
|
ok: true;
|
|
packageId: Id<"packages">;
|
|
releaseId: Id<"packageReleases">;
|
|
}>(ctx, internalRefs.packages.insertReleaseInternal, packageInsertArgs);
|
|
if (inspectorResult?.warnings.length) {
|
|
const insertFindingsResult = await runMutationRef<{
|
|
ok: true;
|
|
inserted: number;
|
|
shouldEmailOwner: boolean;
|
|
}>(ctx, internalRefs.packages.insertPackageInspectorWarningsInternal, {
|
|
packageId: publishResult.packageId,
|
|
releaseId: publishResult.releaseId,
|
|
ownerUserId,
|
|
ownerPublisherId,
|
|
packageName: name,
|
|
version,
|
|
scanSource: "publish",
|
|
inspectorVersion: inspectorResult.metadata?.inspectorVersion,
|
|
targetOpenClawVersion: inspectorResult.metadata?.targetOpenClawVersion,
|
|
findings: inspectorResult.warnings,
|
|
});
|
|
if (insertFindingsResult.shouldEmailOwner) {
|
|
try {
|
|
await runActionRef(ctx, internalRefs.packages.sendPackageInspectorFindingsEmailInternal, {
|
|
packageId: publishResult.packageId,
|
|
releaseId: publishResult.releaseId,
|
|
});
|
|
} catch (error) {
|
|
console.error("Package Inspector findings email failed", error);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (auth.kind === "github-actions") {
|
|
await runMutationRef(ctx, internalRefs.packagePublishTokens.revokeInternal, {
|
|
tokenId: auth.publishToken._id,
|
|
});
|
|
}
|
|
if (auth.kind === "user" && existingTrustedPublisher && manualOverrideReason) {
|
|
await runMutationRef(ctx, internalRefs.packages.insertAuditLogInternal, {
|
|
actorUserId,
|
|
action: "package.publish.manual_override",
|
|
targetType: "package",
|
|
targetId: String(publishResult.packageId),
|
|
metadata: {
|
|
version,
|
|
reason: manualOverrideReason,
|
|
trustedPublisher: {
|
|
provider: existingTrustedPublisher.provider,
|
|
repository: existingTrustedPublisher.repository,
|
|
workflowFilename: existingTrustedPublisher.workflowFilename,
|
|
environment: existingTrustedPublisher.environment,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
if (auth.kind === "github-actions") {
|
|
await runMutationRef(ctx, internalRefs.packages.insertAuditLogInternal, {
|
|
actorUserId,
|
|
action: "package.publish.github_actions",
|
|
targetType: "package",
|
|
targetId: String(publishResult.packageId),
|
|
metadata: {
|
|
version,
|
|
repository: auth.publishToken.repository,
|
|
workflowFilename: auth.publishToken.workflowFilename,
|
|
environment: auth.publishToken.environment,
|
|
runId: auth.publishToken.runId,
|
|
runAttempt: auth.publishToken.runAttempt,
|
|
sha: auth.publishToken.sha,
|
|
},
|
|
});
|
|
}
|
|
|
|
await runAfterRef(
|
|
ctx,
|
|
INITIAL_PACKAGE_VT_SCAN_DELAY_MS,
|
|
internalRefs.vt.scanPackageReleaseWithVirusTotal,
|
|
{
|
|
releaseId: publishResult.releaseId,
|
|
},
|
|
);
|
|
await runMutationRef(ctx, internalRefs.securityScan.enqueuePackageReleaseScanInternal, {
|
|
releaseId: publishResult.releaseId,
|
|
source: "publish",
|
|
});
|
|
|
|
const publishedResult = {
|
|
...publishResult,
|
|
publicationStatus: "published" as const,
|
|
};
|
|
return inspectorFindings.length > 0 ? { ...publishedResult, inspectorFindings } : publishedResult;
|
|
}
|
|
|
|
function toPackageInspectorPublishResponseFinding(
|
|
finding: PackageInspectorFinding,
|
|
metadata: PackageInspectorPublishResult["metadata"],
|
|
) {
|
|
const findingKind =
|
|
finding.level === "breakage" || finding.level === "error" || finding.severity === "P0"
|
|
? ("error" as const)
|
|
: ("warning" as const);
|
|
return {
|
|
findingKind,
|
|
code: finding.code,
|
|
severity: finding.severity,
|
|
level: finding.level,
|
|
issueClass: finding.issueClass,
|
|
message: finding.message,
|
|
authorRemediation: finding.authorRemediation,
|
|
inspectorVersion: metadata?.inspectorVersion,
|
|
targetOpenClawVersion: metadata?.targetOpenClawVersion,
|
|
};
|
|
}
|
|
|
|
export const publishPackageForUserInternal = internalAction({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
payload: v.any(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
return await publishPackageImpl(
|
|
ctx,
|
|
{ kind: "user", actorUserId: args.actorUserId },
|
|
args.payload,
|
|
{ stagePrePublicationChecks: stagedPrePublicationPublishesEnabled() },
|
|
);
|
|
},
|
|
});
|
|
|
|
export const publishRelease: ReturnType<typeof action> = action({
|
|
args: {
|
|
payload: v.any(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const { userId } = await requireUserFromAction(ctx);
|
|
const stagePrePublicationChecks = stagedPrePublicationPublishesEnabled();
|
|
return await publishPackageImpl(ctx, { kind: "user", actorUserId: userId }, args.payload, {
|
|
stagePrePublicationChecks,
|
|
});
|
|
},
|
|
});
|
|
|
|
function stagedPrePublicationPublishesEnabled() {
|
|
return process.env.CLAWHUB_STAGED_PREPUBLICATION_PUBLISHES === "1";
|
|
}
|
|
|
|
export const finalizePackagePublishAttemptInternal = internalAction({
|
|
args: {
|
|
attemptId: v.id("publishAttempts"),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const claimId = buildPackageFinalizationClaimId();
|
|
const claim = await runMutationRef<
|
|
| {
|
|
status: "claimed";
|
|
attemptId: Id<"publishAttempts">;
|
|
packageId?: Id<"packages">;
|
|
releaseId?: Id<"packageReleases">;
|
|
packageInsertArgs?: unknown;
|
|
packageFollowup: unknown;
|
|
}
|
|
| {
|
|
status: "finalized";
|
|
attemptId: Id<"publishAttempts">;
|
|
result: { ok: true; packageId: Id<"packages">; releaseId: Id<"packageReleases"> };
|
|
packageFollowup: unknown;
|
|
}
|
|
>(ctx, internalRefs.publishAttempts.claimPackagePublishAttemptForFinalizationInternal, {
|
|
attemptId: args.attemptId,
|
|
claimId,
|
|
});
|
|
if (claim.status === "finalized") return claim.result;
|
|
|
|
let publishResult: { ok: true; packageId: Id<"packages">; releaseId: Id<"packageReleases"> };
|
|
try {
|
|
publishResult =
|
|
claim.releaseId !== undefined
|
|
? await runMutationRef(ctx, internalRefs.packages.publishPendingReleaseInternal, {
|
|
releaseId: claim.releaseId,
|
|
})
|
|
: await runMutationRef(
|
|
ctx,
|
|
internalRefs.packages.insertReleaseInternal,
|
|
claim.packageInsertArgs,
|
|
);
|
|
} catch (error) {
|
|
if (claim.releaseId !== undefined) {
|
|
await releasePackagePublishAttemptFinalizationClaim(ctx, claim.attemptId, claimId, error);
|
|
throw error;
|
|
}
|
|
const insertArgs = claim.packageInsertArgs as {
|
|
name?: string;
|
|
version?: string;
|
|
integritySha256?: string;
|
|
ownerUserId?: Id<"users">;
|
|
ownerPublisherId?: Id<"publishers">;
|
|
};
|
|
const existingResult =
|
|
insertArgs.name &&
|
|
insertArgs.version &&
|
|
insertArgs.integritySha256 &&
|
|
insertArgs.ownerUserId
|
|
? await runQueryRef<{
|
|
ok: true;
|
|
packageId: Id<"packages">;
|
|
releaseId: Id<"packageReleases">;
|
|
} | null>(ctx, internalRefs.packages.findPackagePublishResultInternal, {
|
|
name: insertArgs.name,
|
|
version: insertArgs.version,
|
|
integritySha256: insertArgs.integritySha256,
|
|
ownerUserId: insertArgs.ownerUserId,
|
|
ownerPublisherId: insertArgs.ownerPublisherId,
|
|
})
|
|
: null;
|
|
if (!existingResult) {
|
|
await releasePackagePublishAttemptFinalizationClaim(ctx, claim.attemptId, claimId, error);
|
|
throw error;
|
|
}
|
|
publishResult = existingResult;
|
|
}
|
|
|
|
try {
|
|
await runPackagePublishPostFinalizeFollowups(ctx, publishResult, claim.packageFollowup);
|
|
await runMutationRef(
|
|
ctx,
|
|
internalRefs.publishAttempts.recordPackagePublishAttemptFinalizedInternal,
|
|
{
|
|
attemptId: claim.attemptId,
|
|
claimId,
|
|
result: publishResult,
|
|
},
|
|
);
|
|
} catch (error) {
|
|
await releasePackagePublishAttemptFinalizationClaim(ctx, claim.attemptId, claimId, error);
|
|
throw error;
|
|
}
|
|
|
|
return publishResult;
|
|
},
|
|
});
|
|
|
|
export const generateChangelogPreview: ReturnType<typeof action> = action({
|
|
args: {
|
|
name: v.string(),
|
|
family: v.union(v.literal("code-plugin"), v.literal("bundle-plugin")),
|
|
version: v.string(),
|
|
readmeText: v.string(),
|
|
filePaths: v.optional(v.array(v.string())),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const { userId } = await requireUserFromAction(ctx);
|
|
const name = normalizePackageName(args.name);
|
|
const version = assertPackageVersion(args.family, args.version);
|
|
const authorizedPreview = await runQueryRef<{
|
|
ok: true;
|
|
latestReleaseId?: Id<"packageReleases"> | null;
|
|
}>(ctx, internalRefs.packages.assertCanGenerateChangelogPreviewInternal, {
|
|
actorUserId: userId,
|
|
name,
|
|
});
|
|
const changelog = await generatePackageChangelogPreview(ctx, {
|
|
name,
|
|
version,
|
|
readmeText: args.readmeText,
|
|
filePaths: args.filePaths,
|
|
latestReleaseId: authorizedPreview.latestReleaseId ?? null,
|
|
});
|
|
return { changelog };
|
|
},
|
|
});
|
|
|
|
export const publishPackageForTrustedPublisherInternal = internalAction({
|
|
args: {
|
|
publishTokenId: v.id("packagePublishTokens"),
|
|
payload: v.any(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const publishToken = await runQueryRef<Doc<"packagePublishTokens"> | null>(
|
|
ctx,
|
|
internalRefs.packagePublishTokens.getByIdInternal,
|
|
{ tokenId: args.publishTokenId },
|
|
);
|
|
if (!publishToken || publishToken.revokedAt || publishToken.expiresAt <= Date.now()) {
|
|
throw new ConvexError("Trusted publish token is missing or expired");
|
|
}
|
|
const trustedPublisher = await runQueryRef<PackageTrustedPublisherDoc | null>(
|
|
ctx,
|
|
internalRefs.packages.getTrustedPublisherByPackageIdInternal,
|
|
{ packageId: publishToken.packageId },
|
|
);
|
|
if (!doesTrustedPublisherMatchPublishToken(trustedPublisher, publishToken)) {
|
|
throw new ConvexError(
|
|
"Trusted publish token no longer matches the current package trusted publisher",
|
|
);
|
|
}
|
|
return await publishPackageImpl(ctx, { kind: "github-actions", publishToken }, args.payload, {
|
|
stagePrePublicationChecks: stagedPrePublicationPublishesEnabled(),
|
|
});
|
|
},
|
|
});
|
|
|
|
function buildPackagePublishAttemptIdempotencyKey(args: {
|
|
actorUserId: Id<"users">;
|
|
ownerUserId: Id<"users">;
|
|
ownerPublisherId?: Id<"publishers">;
|
|
name: string;
|
|
version: string;
|
|
integritySha256: string;
|
|
}) {
|
|
return [
|
|
"package",
|
|
args.actorUserId,
|
|
args.ownerPublisherId ?? args.ownerUserId,
|
|
args.name,
|
|
args.version,
|
|
args.integritySha256,
|
|
].join(":");
|
|
}
|
|
|
|
function stripUndefinedForStoredAttempt(value: unknown): unknown {
|
|
if (Array.isArray(value)) return value.map(stripUndefinedForStoredAttempt);
|
|
if (!value || typeof value !== "object") return value;
|
|
|
|
const result: Record<string, unknown> = {};
|
|
for (const [key, nested] of Object.entries(value)) {
|
|
if (nested !== undefined) result[key] = stripUndefinedForStoredAttempt(nested);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function buildPackagePublishAttemptScanContext(insertArgs: Record<string, unknown>) {
|
|
const verification =
|
|
insertArgs.verification &&
|
|
typeof insertArgs.verification === "object" &&
|
|
!Array.isArray(insertArgs.verification)
|
|
? (insertArgs.verification as Record<string, unknown>)
|
|
: {};
|
|
return stripUndefinedForStoredAttempt({
|
|
trustedOpenClawPlugin: verification.trustedOpenClawPlugin === true ? true : undefined,
|
|
release: {
|
|
staticScan: insertArgs.staticScan,
|
|
pluginManifestSummary: insertArgs.pluginManifestSummary,
|
|
verification: insertArgs.verification,
|
|
artifactKind: insertArgs.artifactKind,
|
|
npmIntegrity: insertArgs.npmIntegrity,
|
|
npmShasum: insertArgs.npmShasum,
|
|
npmTarballName: insertArgs.npmTarballName,
|
|
source: insertArgs.source,
|
|
},
|
|
});
|
|
}
|
|
|
|
function buildPackageFinalizationClaimId() {
|
|
return typeof crypto.randomUUID === "function"
|
|
? crypto.randomUUID()
|
|
: `${Date.now()}:${Math.random().toString(36).slice(2)}`;
|
|
}
|
|
|
|
async function releasePackagePublishAttemptFinalizationClaim(
|
|
ctx: ActionCtx,
|
|
attemptId: Id<"publishAttempts">,
|
|
claimId: string,
|
|
error: unknown,
|
|
) {
|
|
await runMutationRef(
|
|
ctx,
|
|
internalRefs.publishAttempts.releasePackagePublishAttemptFinalizationClaimInternal,
|
|
{
|
|
attemptId,
|
|
claimId,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
},
|
|
);
|
|
}
|
|
|
|
async function runPackagePublishPostFinalizeFollowups(
|
|
ctx: ActionCtx,
|
|
publishResult: { packageId: Id<"packages">; releaseId: Id<"packageReleases"> },
|
|
rawFollowup: unknown,
|
|
) {
|
|
const followup = rawFollowup as {
|
|
ownerUserId?: Id<"users">;
|
|
ownerPublisherId?: Id<"publishers">;
|
|
packageName?: string;
|
|
version?: string;
|
|
inspectorWarnings?: PackageInspectorFinding[];
|
|
inspectorMetadata?: PackageInspectorPublishResult["metadata"];
|
|
trustedPublishTokenId?: Id<"packagePublishTokens">;
|
|
manualOverrideAudit?: {
|
|
actorUserId: Id<"users">;
|
|
version: string;
|
|
reason: string;
|
|
trustedPublisher: {
|
|
provider: string;
|
|
repository: string;
|
|
workflowFilename: string;
|
|
environment?: string;
|
|
};
|
|
};
|
|
githubActionsAudit?: {
|
|
actorUserId: Id<"users">;
|
|
version: string;
|
|
repository: string;
|
|
workflowFilename: string;
|
|
environment?: string;
|
|
runId?: string;
|
|
runAttempt?: string;
|
|
sha?: string;
|
|
};
|
|
};
|
|
|
|
if (
|
|
followup.ownerUserId &&
|
|
followup.packageName &&
|
|
followup.version &&
|
|
followup.inspectorWarnings?.length
|
|
) {
|
|
const insertFindingsResult = await runMutationRef<{
|
|
ok: true;
|
|
inserted: number;
|
|
shouldEmailOwner: boolean;
|
|
}>(ctx, internalRefs.packages.insertPackageInspectorWarningsInternal, {
|
|
packageId: publishResult.packageId,
|
|
releaseId: publishResult.releaseId,
|
|
ownerUserId: followup.ownerUserId,
|
|
ownerPublisherId: followup.ownerPublisherId,
|
|
packageName: followup.packageName,
|
|
version: followup.version,
|
|
scanSource: "publish",
|
|
inspectorVersion: followup.inspectorMetadata?.inspectorVersion,
|
|
targetOpenClawVersion: followup.inspectorMetadata?.targetOpenClawVersion,
|
|
findings: followup.inspectorWarnings,
|
|
});
|
|
if (insertFindingsResult.shouldEmailOwner) {
|
|
try {
|
|
await runActionRef(ctx, internalRefs.packages.sendPackageInspectorFindingsEmailInternal, {
|
|
packageId: publishResult.packageId,
|
|
releaseId: publishResult.releaseId,
|
|
});
|
|
} catch (error) {
|
|
console.error("Package Inspector findings email failed", error);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (followup.trustedPublishTokenId) {
|
|
await runMutationRef(ctx, internalRefs.packagePublishTokens.revokeInternal, {
|
|
tokenId: followup.trustedPublishTokenId,
|
|
});
|
|
}
|
|
|
|
if (followup.manualOverrideAudit) {
|
|
await runMutationRef(ctx, internalRefs.packages.insertAuditLogInternal, {
|
|
actorUserId: followup.manualOverrideAudit.actorUserId,
|
|
action: "package.publish.manual_override",
|
|
targetType: "package",
|
|
targetId: String(publishResult.packageId),
|
|
metadata: {
|
|
version: followup.manualOverrideAudit.version,
|
|
reason: followup.manualOverrideAudit.reason,
|
|
trustedPublisher: followup.manualOverrideAudit.trustedPublisher,
|
|
},
|
|
});
|
|
}
|
|
|
|
if (followup.githubActionsAudit) {
|
|
await runMutationRef(ctx, internalRefs.packages.insertAuditLogInternal, {
|
|
actorUserId: followup.githubActionsAudit.actorUserId,
|
|
action: "package.publish.github_actions",
|
|
targetType: "package",
|
|
targetId: String(publishResult.packageId),
|
|
metadata: {
|
|
version: followup.githubActionsAudit.version,
|
|
repository: followup.githubActionsAudit.repository,
|
|
workflowFilename: followup.githubActionsAudit.workflowFilename,
|
|
environment: followup.githubActionsAudit.environment,
|
|
runId: followup.githubActionsAudit.runId,
|
|
runAttempt: followup.githubActionsAudit.runAttempt,
|
|
sha: followup.githubActionsAudit.sha,
|
|
},
|
|
});
|
|
}
|
|
|
|
await runAfterRef(
|
|
ctx,
|
|
INITIAL_PACKAGE_VT_SCAN_DELAY_MS,
|
|
internalRefs.vt.scanPackageReleaseWithVirusTotal,
|
|
{
|
|
releaseId: publishResult.releaseId,
|
|
},
|
|
);
|
|
await runMutationRef(ctx, internalRefs.securityScan.enqueuePackageReleaseScanInternal, {
|
|
releaseId: publishResult.releaseId,
|
|
source: "publish",
|
|
});
|
|
}
|
|
|
|
export const reservePackageNameInternal = internalMutation({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
ownerUserId: v.id("users"),
|
|
ownerPublisherId: v.optional(v.id("publishers")),
|
|
name: v.string(),
|
|
displayName: v.optional(v.string()),
|
|
summary: v.optional(v.string()),
|
|
family: v.optional(
|
|
v.union(v.literal("skill"), v.literal("code-plugin"), v.literal("bundle-plugin")),
|
|
),
|
|
reason: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const now = Date.now();
|
|
const actor = await ctx.db.get(args.actorUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
|
assertAdmin(actor);
|
|
|
|
const owner = await ctx.db.get(args.ownerUserId);
|
|
if (!owner || owner.deletedAt || owner.deactivatedAt) {
|
|
throw new ConvexError("Owner user not found");
|
|
}
|
|
|
|
const ownerPublisher = args.ownerPublisherId ? await ctx.db.get(args.ownerPublisherId) : null;
|
|
if (args.ownerPublisherId && (!ownerPublisher || ownerPublisher.deletedAt)) {
|
|
throw new ConvexError("Owner publisher not found");
|
|
}
|
|
|
|
const normalizedName = normalizePackageName(args.name);
|
|
const family = args.family ?? "code-plugin";
|
|
const existing = await getPackageByNormalizedName(ctx, normalizedName);
|
|
if (existing) {
|
|
const existingOwnerKey = getPackageOwnerKey(existing, {
|
|
nextOwnerPublisherId: args.ownerPublisherId,
|
|
ownerPublisher,
|
|
});
|
|
const nextOwnerKey = getRequestedPackageOwnerKey({
|
|
ownerUserId: args.ownerUserId,
|
|
ownerPublisherId: args.ownerPublisherId,
|
|
});
|
|
if (existingOwnerKey !== nextOwnerKey) {
|
|
throw new ConvexError("Package already exists and belongs to another publisher");
|
|
}
|
|
|
|
await ctx.db.insert("auditLogs", {
|
|
actorUserId: args.actorUserId,
|
|
action: "package.reserve",
|
|
targetType: "package",
|
|
targetId: existing._id,
|
|
metadata: {
|
|
name: normalizedName,
|
|
ownerUserId: args.ownerUserId,
|
|
ownerPublisherId: args.ownerPublisherId,
|
|
action: "already_owned",
|
|
reason: args.reason || undefined,
|
|
},
|
|
createdAt: now,
|
|
});
|
|
|
|
return {
|
|
ok: true as const,
|
|
action: "already_owned" as const,
|
|
packageId: existing._id,
|
|
name: normalizedName,
|
|
};
|
|
}
|
|
|
|
const packageId = await ctx.db.insert("packages", {
|
|
name: normalizedName,
|
|
normalizedName,
|
|
displayName: args.displayName?.trim() || normalizedName,
|
|
summary: args.summary?.trim() || "Reserved for an official OpenClaw plugin.",
|
|
ownerUserId: args.ownerUserId,
|
|
ownerPublisherId: args.ownerPublisherId,
|
|
family,
|
|
channel: "private",
|
|
isOfficial: false,
|
|
tags: {},
|
|
stats: { downloads: 0, installs: 0, stars: 0, versions: 0 },
|
|
...computePackageRecommendationPatch(
|
|
{
|
|
downloads: 0,
|
|
installs: 0,
|
|
stars: 0,
|
|
versions: 0,
|
|
},
|
|
{
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
now,
|
|
},
|
|
),
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
|
|
await ctx.db.insert("auditLogs", {
|
|
actorUserId: args.actorUserId,
|
|
action: "package.reserve",
|
|
targetType: "package",
|
|
targetId: packageId,
|
|
metadata: {
|
|
name: normalizedName,
|
|
ownerUserId: args.ownerUserId,
|
|
ownerPublisherId: args.ownerPublisherId,
|
|
family,
|
|
reason: args.reason || undefined,
|
|
},
|
|
createdAt: now,
|
|
});
|
|
|
|
return {
|
|
ok: true as const,
|
|
action: "reserved" as const,
|
|
packageId,
|
|
name: normalizedName,
|
|
};
|
|
},
|
|
});
|
|
|
|
async function patchPackageOwnerWithAudit(
|
|
ctx: Pick<MutationCtx, "db">,
|
|
args: {
|
|
actorUserId: Id<"users">;
|
|
pkg: Doc<"packages">;
|
|
owner: Doc<"users">;
|
|
ownerPublisher?: Doc<"publishers"> | null;
|
|
publisherOfficial?: boolean;
|
|
channel?: "official" | "community" | "private";
|
|
reason?: string;
|
|
},
|
|
) {
|
|
const now = Date.now();
|
|
const publisherOfficial =
|
|
args.publisherOfficial ?? (await isOfficialPublisher(ctx, args.ownerPublisher));
|
|
const nextChannel = derivePackagePublisherChannel({
|
|
requestedChannel: args.channel,
|
|
currentChannel: args.pkg.channel,
|
|
publisherOfficial,
|
|
});
|
|
if (nextChannel === "official" && !publisherOfficial) {
|
|
throw new ConvexError("Only official publishers may own official packages");
|
|
}
|
|
const nextPackageFields = {
|
|
ownerUserId: args.owner._id,
|
|
ownerPublisherId: args.ownerPublisher?._id,
|
|
channel: nextChannel,
|
|
isOfficial: nextChannel === "official",
|
|
updatedAt: now,
|
|
};
|
|
|
|
await ctx.db.patch(args.pkg._id, nextPackageFields);
|
|
await upsertPackageSearchDigest(ctx, {
|
|
...extractPackageDigestFields(args.pkg),
|
|
...nextPackageFields,
|
|
});
|
|
|
|
await ctx.db.insert("auditLogs", {
|
|
actorUserId: args.actorUserId,
|
|
action: "package.owner.transfer",
|
|
targetType: "package",
|
|
targetId: args.pkg._id,
|
|
metadata: {
|
|
name: args.pkg.normalizedName,
|
|
previousOwnerUserId: args.pkg.ownerUserId,
|
|
previousOwnerPublisherId: args.pkg.ownerPublisherId,
|
|
nextOwnerUserId: args.owner._id,
|
|
nextOwnerPublisherId: args.ownerPublisher?._id,
|
|
previousChannel: args.pkg.channel,
|
|
nextChannel,
|
|
reason: args.reason || undefined,
|
|
},
|
|
createdAt: now,
|
|
});
|
|
|
|
return {
|
|
ok: true as const,
|
|
packageId: args.pkg._id,
|
|
name: args.pkg.normalizedName,
|
|
ownerUserId: args.owner._id,
|
|
ownerPublisherId: args.ownerPublisher?._id,
|
|
channel: nextChannel,
|
|
isOfficial: nextChannel === "official",
|
|
};
|
|
}
|
|
|
|
async function transferPackageOwnerForUser(
|
|
ctx: MutationCtx,
|
|
args: {
|
|
actorUserId: Id<"users">;
|
|
name: string;
|
|
toOwner: string;
|
|
reason?: string;
|
|
},
|
|
) {
|
|
const actor = await ctx.db.get(args.actorUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
|
|
|
const normalizedName = normalizePackageName(args.name);
|
|
const pkg = await getPackageByNormalizedName(ctx, normalizedName);
|
|
if (!pkg || pkg.softDeletedAt) throw new ConvexError("Package not found");
|
|
if (pkg.family === "skill") {
|
|
throw new ConvexError("Skill packages must use the skills transfer flow");
|
|
}
|
|
|
|
const scopedOwner = inferOwnerHandleFromScopedPackageName(normalizedName);
|
|
const destinationHandle = normalizePublisherHandle(args.toOwner);
|
|
if (!destinationHandle) throw new ConvexError("Destination owner is required");
|
|
if (scopedOwner && scopedOwner !== destinationHandle) {
|
|
throw new ConvexError(
|
|
`Package scope "@${scopedOwner}" can only be transferred to publisher "@${scopedOwner}".`,
|
|
);
|
|
}
|
|
|
|
if (pkg.ownerPublisherId) {
|
|
const sourcePublisher = await ctx.db.get(pkg.ownerPublisherId);
|
|
const sourceMembership = await getPublisherMembership(ctx, pkg.ownerPublisherId, actor._id);
|
|
const canManagePersonalSource =
|
|
sourcePublisher?.kind === "user" &&
|
|
(sourcePublisher.linkedUserId
|
|
? sourcePublisher.linkedUserId === actor._id
|
|
: pkg.ownerUserId === actor._id);
|
|
const canManageSource =
|
|
actor.role === "admin" ||
|
|
(sourcePublisher?.kind === "user"
|
|
? canManagePersonalSource
|
|
: Boolean(sourceMembership && isPublisherRoleAllowed(sourceMembership.role, ["admin"])));
|
|
if (!canManageSource) {
|
|
throw new ConvexError("Forbidden");
|
|
}
|
|
} else {
|
|
await assertCanManageOwnedResource(ctx, {
|
|
actor,
|
|
ownerUserId: pkg.ownerUserId,
|
|
ownerPublisherId: pkg.ownerPublisherId,
|
|
allowedPublisherRoles: ["admin"],
|
|
allowPlatformAdmin: true,
|
|
});
|
|
}
|
|
|
|
const destinationPublisher = await getPublisherByHandle(ctx, destinationHandle);
|
|
if (
|
|
!destinationPublisher ||
|
|
destinationPublisher.deletedAt ||
|
|
destinationPublisher.deactivatedAt
|
|
) {
|
|
throw new ConvexError(
|
|
`Publisher "@${destinationHandle}" not found. Create the "@${destinationHandle}" organization on ClawHub before transferring this package.`,
|
|
);
|
|
}
|
|
|
|
const destinationMembership = await getPublisherMembership(
|
|
ctx,
|
|
destinationPublisher._id,
|
|
actor._id,
|
|
);
|
|
const canManagePersonalDestination =
|
|
destinationPublisher.kind === "user" &&
|
|
(destinationPublisher.linkedUserId
|
|
? destinationPublisher.linkedUserId === actor._id
|
|
: actor.personalPublisherId === destinationPublisher._id);
|
|
const canManageDestination =
|
|
actor.role === "admin" ||
|
|
(destinationPublisher.kind === "user"
|
|
? canManagePersonalDestination
|
|
: Boolean(
|
|
destinationMembership && isPublisherRoleAllowed(destinationMembership.role, ["admin"]),
|
|
));
|
|
if (!canManageDestination) {
|
|
throw new ConvexError(
|
|
`You do not have admin access for "@${destinationHandle}". Ask an owner or admin to add you before transferring this package.`,
|
|
);
|
|
}
|
|
|
|
return await patchPackageOwnerWithAudit(ctx, {
|
|
actorUserId: actor._id,
|
|
pkg,
|
|
owner: actor,
|
|
ownerPublisher: destinationPublisher,
|
|
publisherOfficial: await isOfficialPublisher(ctx, destinationPublisher),
|
|
reason: args.reason,
|
|
});
|
|
}
|
|
|
|
export const transferPackageOwnerForUserInternal = internalMutation({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
name: v.string(),
|
|
toOwner: v.string(),
|
|
reason: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => await transferPackageOwnerForUser(ctx, args),
|
|
});
|
|
|
|
export const transferPackageOwner = mutation({
|
|
args: {
|
|
name: v.string(),
|
|
toOwner: v.string(),
|
|
reason: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const { user } = await requireUser(ctx);
|
|
return await transferPackageOwnerForUser(ctx, {
|
|
actorUserId: user._id,
|
|
...args,
|
|
});
|
|
},
|
|
});
|
|
|
|
export const transferPackageOwnerInternal = internalMutation({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
name: v.string(),
|
|
ownerUserId: v.id("users"),
|
|
ownerPublisherId: v.optional(v.id("publishers")),
|
|
channel: v.optional(
|
|
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
|
|
),
|
|
reason: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const actor = await ctx.db.get(args.actorUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
|
assertAdmin(actor);
|
|
|
|
const owner = await ctx.db.get(args.ownerUserId);
|
|
if (!owner || owner.deletedAt || owner.deactivatedAt) {
|
|
throw new ConvexError("Owner user not found");
|
|
}
|
|
|
|
const ownerPublisher = args.ownerPublisherId ? await ctx.db.get(args.ownerPublisherId) : null;
|
|
if (args.ownerPublisherId && (!ownerPublisher || ownerPublisher.deletedAt)) {
|
|
throw new ConvexError("Owner publisher not found");
|
|
}
|
|
const officialPublisher = await getOwnerPublisher(ctx, {
|
|
ownerPublisherId: args.ownerPublisherId,
|
|
ownerUserId: args.ownerUserId,
|
|
});
|
|
|
|
const normalizedName = normalizePackageName(args.name);
|
|
const pkg = await getPackageByNormalizedName(ctx, normalizedName);
|
|
if (!pkg || pkg.softDeletedAt) throw new ConvexError("Package not found");
|
|
|
|
return await patchPackageOwnerWithAudit(ctx, {
|
|
actorUserId: args.actorUserId,
|
|
pkg,
|
|
owner,
|
|
ownerPublisher,
|
|
publisherOfficial: await isOfficialPublisher(ctx, officialPublisher),
|
|
channel: args.channel,
|
|
reason: args.reason,
|
|
});
|
|
},
|
|
});
|
|
|
|
export const repairPackageIdentityInternal = internalMutation({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
name: v.string(),
|
|
nextName: v.optional(v.string()),
|
|
nextRuntimeId: v.optional(v.string()),
|
|
reason: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const now = Date.now();
|
|
const actor = await ctx.db.get(args.actorUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
|
assertAdmin(actor);
|
|
|
|
const normalizedName = normalizePackageName(args.name);
|
|
const pkg = await getPackageByNormalizedName(ctx, normalizedName);
|
|
if (!pkg || pkg.softDeletedAt) throw new ConvexError("Package not found");
|
|
|
|
const patch: Partial<Doc<"packages">> = { updatedAt: now };
|
|
const metadata: Record<string, unknown> = {
|
|
name: normalizedName,
|
|
reason: args.reason,
|
|
};
|
|
|
|
if (typeof args.nextName === "string") {
|
|
const nextName = normalizePackageName(args.nextName);
|
|
if (!nextName) throw new ConvexError("Package name required");
|
|
const existingByName = await getPackageByNormalizedName(ctx, nextName);
|
|
if (existingByName && existingByName._id !== pkg._id && !existingByName.softDeletedAt) {
|
|
throw new ConvexError(`Package "${nextName}" already exists`);
|
|
}
|
|
patch.name = nextName;
|
|
patch.normalizedName = nextName;
|
|
metadata.previousName = pkg.normalizedName;
|
|
metadata.nextName = nextName;
|
|
}
|
|
|
|
if (typeof args.nextRuntimeId === "string") {
|
|
const nextRuntimeId = args.nextRuntimeId.trim();
|
|
if (!nextRuntimeId) throw new ConvexError("Runtime id required");
|
|
const runtimeCollisions = await ctx.db
|
|
.query("packages")
|
|
.withIndex("by_runtime_id", (q) => q.eq("runtimeId", nextRuntimeId))
|
|
.collect();
|
|
const runtimeCollision = runtimeCollisions.find(
|
|
(candidate) => candidate._id !== pkg._id && !candidate.softDeletedAt,
|
|
);
|
|
if (runtimeCollision) {
|
|
throw new ConvexError(`Plugin id "${nextRuntimeId}" is already claimed by another package`);
|
|
}
|
|
patch.runtimeId = nextRuntimeId;
|
|
metadata.previousRuntimeId = pkg.runtimeId;
|
|
metadata.nextRuntimeId = nextRuntimeId;
|
|
}
|
|
|
|
await ctx.db.patch(pkg._id, patch);
|
|
await upsertPackageSearchDigest(ctx, {
|
|
...extractPackageDigestFields(pkg),
|
|
...patch,
|
|
});
|
|
await ctx.db.insert("auditLogs", {
|
|
actorUserId: args.actorUserId,
|
|
action: "package.identity.repair",
|
|
targetType: "package",
|
|
targetId: pkg._id,
|
|
metadata,
|
|
createdAt: now,
|
|
});
|
|
|
|
return {
|
|
ok: true as const,
|
|
packageId: pkg._id,
|
|
name: patch.normalizedName ?? pkg.normalizedName,
|
|
runtimeId: patch.runtimeId ?? pkg.runtimeId,
|
|
};
|
|
},
|
|
});
|
|
|
|
export const setPackageCatalogMetadata = mutation({
|
|
args: {
|
|
packageId: v.id("packages"),
|
|
categories: v.optional(v.array(v.string())),
|
|
topics: v.array(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const { user } = await requireUser(ctx);
|
|
|
|
const pkg = await ctx.db.get(args.packageId);
|
|
if (!pkg || pkg.softDeletedAt) throw new ConvexError("Package not found");
|
|
await assertCanManageOwnedResource(ctx, {
|
|
actor: user,
|
|
ownerUserId: pkg.ownerUserId,
|
|
ownerPublisherId: pkg.ownerPublisherId,
|
|
allowedPublisherRoles: ["publisher"],
|
|
allowPlatformModerator: true,
|
|
});
|
|
|
|
let normalizedCategories: string[];
|
|
let normalizedTopics: string[];
|
|
try {
|
|
normalizedCategories = resolvePluginCategories({ declared: args.categories });
|
|
normalizedTopics = normalizeCatalogTopics(args.topics);
|
|
} catch (error) {
|
|
throw new ConvexError(error instanceof Error ? error.message : "Invalid catalog metadata");
|
|
}
|
|
|
|
const now = Date.now();
|
|
const nextPackage = {
|
|
...pkg,
|
|
categories: normalizedCategories,
|
|
topics: normalizedTopics.length ? normalizedTopics : undefined,
|
|
inferredCategories: undefined,
|
|
inferredTopics: undefined,
|
|
inferredFromReleaseId: undefined,
|
|
inferredCategoryConfidence: undefined,
|
|
inferredTopicConfidence: undefined,
|
|
inferredClassifierVersion: undefined,
|
|
inferredTopicClassifierVersion: undefined,
|
|
inferredInputHash: undefined,
|
|
inferredTopicInputHash: undefined,
|
|
inferredAt: undefined,
|
|
updatedAt: now,
|
|
};
|
|
await ctx.db.patch(pkg._id, {
|
|
categories: nextPackage.categories,
|
|
topics: nextPackage.topics,
|
|
inferredCategories: nextPackage.inferredCategories,
|
|
inferredTopics: nextPackage.inferredTopics,
|
|
inferredFromReleaseId: nextPackage.inferredFromReleaseId,
|
|
inferredCategoryConfidence: nextPackage.inferredCategoryConfidence,
|
|
inferredTopicConfidence: nextPackage.inferredTopicConfidence,
|
|
inferredClassifierVersion: nextPackage.inferredClassifierVersion,
|
|
inferredTopicClassifierVersion: nextPackage.inferredTopicClassifierVersion,
|
|
inferredInputHash: nextPackage.inferredInputHash,
|
|
inferredTopicInputHash: nextPackage.inferredTopicInputHash,
|
|
inferredAt: nextPackage.inferredAt,
|
|
updatedAt: now,
|
|
});
|
|
const owner = await getOwnerPublisher(ctx, {
|
|
ownerPublisherId: nextPackage.ownerPublisherId,
|
|
ownerUserId: nextPackage.ownerUserId,
|
|
});
|
|
await upsertPackageSearchDigest(ctx, {
|
|
...extractPackageDigestFields(nextPackage),
|
|
ownerHandle: owner?.handle ?? "",
|
|
ownerKind: owner?.kind,
|
|
});
|
|
await ctx.db.insert("auditLogs", {
|
|
actorUserId: user._id,
|
|
action: "package.catalog_metadata.set",
|
|
targetType: "package",
|
|
targetId: pkg._id,
|
|
metadata: {
|
|
previous: { categories: pkg.categories, topics: pkg.topics },
|
|
next: { categories: nextPackage.categories, topics: nextPackage.topics },
|
|
},
|
|
createdAt: now,
|
|
});
|
|
},
|
|
});
|
|
|
|
export const insertPackageInspectorWarningsInternal = internalMutation({
|
|
args: {
|
|
packageId: v.id("packages"),
|
|
releaseId: v.id("packageReleases"),
|
|
ownerUserId: v.id("users"),
|
|
ownerPublisherId: v.optional(v.id("publishers")),
|
|
packageName: v.string(),
|
|
version: v.string(),
|
|
scanSource: v.optional(v.union(v.literal("publish"), v.literal("nightly"))),
|
|
inspectorVersion: v.optional(v.string()),
|
|
targetOpenClawVersion: v.optional(v.string()),
|
|
findings: v.optional(v.array(packageInspectorFindingInputValidator)),
|
|
warnings: v.optional(v.array(packageInspectorWarningInputValidator)),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
return await insertPackageInspectorFindings(ctx, args);
|
|
},
|
|
});
|
|
|
|
async function hasCompletedPackageInspectorNotification(
|
|
ctx: DbReaderCtx,
|
|
args: {
|
|
releaseId: Id<"packageReleases">;
|
|
inspectorVersion?: string;
|
|
targetOpenClawVersion?: string;
|
|
exactScanIdentity: boolean;
|
|
},
|
|
) {
|
|
if (args.exactScanIdentity && args.inspectorVersion && args.targetOpenClawVersion) {
|
|
const scanState = await ctx.db
|
|
.query("packageInspectorScanStates")
|
|
.withIndex("by_release_and_inspector_version_and_target_openclaw_version", (q) =>
|
|
q
|
|
.eq("releaseId", args.releaseId)
|
|
.eq("inspectorVersion", args.inspectorVersion as string)
|
|
.eq("targetOpenClawVersion", args.targetOpenClawVersion as string),
|
|
)
|
|
.unique();
|
|
return Boolean(scanState?.notificationCompletedAt);
|
|
}
|
|
const notification = await ctx.db
|
|
.query("packageInspectorFindingNotifications")
|
|
.withIndex("by_release", (q) => q.eq("releaseId", args.releaseId))
|
|
.unique();
|
|
return Boolean(notification);
|
|
}
|
|
|
|
async function insertPackageInspectorFindings(
|
|
ctx: Pick<MutationCtx, "db">,
|
|
args: {
|
|
packageId: Id<"packages">;
|
|
releaseId: Id<"packageReleases">;
|
|
ownerUserId: Id<"users">;
|
|
ownerPublisherId?: Id<"publishers">;
|
|
packageName: string;
|
|
version: string;
|
|
scanSource?: "publish" | "nightly";
|
|
inspectorVersion?: string;
|
|
targetOpenClawVersion?: string;
|
|
notifyOwners?: boolean;
|
|
findings?: PackageInspectorFinding[];
|
|
warnings?: PackageInspectorFinding[];
|
|
},
|
|
) {
|
|
const findings = (args.findings ?? args.warnings ?? []).filter(hasAuthorRemediation);
|
|
const existingWarnings = await ctx.db
|
|
.query("packageInspectorWarnings")
|
|
.withIndex("by_release", (q) => q.eq("releaseId", args.releaseId))
|
|
.collect();
|
|
const replaceNightlyFindings = args.scanSource === "nightly";
|
|
if (replaceNightlyFindings) {
|
|
for (const warning of existingWarnings) {
|
|
if (warning.scanSource === "nightly") await ctx.db.delete(warning._id);
|
|
}
|
|
}
|
|
const existingAuthorWarnings = existingWarnings.filter(
|
|
(warning) =>
|
|
hasStoredAuthorRemediation(warning) &&
|
|
!(replaceNightlyFindings && warning.scanSource === "nightly"),
|
|
);
|
|
if (findings.length === 0 && existingAuthorWarnings.length === 0) {
|
|
return { ok: true as const, inserted: 0, shouldEmailOwner: false };
|
|
}
|
|
const existingWarningKeys = new Set(
|
|
replaceNightlyFindings
|
|
? []
|
|
: existingAuthorWarnings.map((warning) =>
|
|
packageInspectorWarningDedupeKey({
|
|
id: warning.inspectorFindingId,
|
|
code: warning.code,
|
|
message: warning.message,
|
|
evidence: warning.evidence,
|
|
fixture: warning.fixture,
|
|
inspectorVersion: warning.inspectorVersion,
|
|
targetOpenClawVersion: warning.targetOpenClawVersion,
|
|
}),
|
|
),
|
|
);
|
|
const shouldNotifyOwner = args.notifyOwners ?? args.scanSource !== "nightly";
|
|
const notificationCompleted = shouldNotifyOwner
|
|
? await hasCompletedPackageInspectorNotification(ctx, {
|
|
releaseId: args.releaseId,
|
|
inspectorVersion: args.inspectorVersion,
|
|
targetOpenClawVersion: args.targetOpenClawVersion,
|
|
exactScanIdentity: args.scanSource === "nightly",
|
|
})
|
|
: false;
|
|
const now = Date.now();
|
|
let inserted = 0;
|
|
let hasStoredHardError =
|
|
args.scanSource !== "nightly" &&
|
|
existingAuthorWarnings.some((warning) => warning.findingKind === "error");
|
|
for (const warning of findings.slice(0, 100)) {
|
|
const warningKey = packageInspectorWarningDedupeKey({
|
|
...warning,
|
|
inspectorVersion: args.inspectorVersion,
|
|
targetOpenClawVersion: args.targetOpenClawVersion,
|
|
});
|
|
if (existingWarningKeys.has(warningKey)) continue;
|
|
const findingKind =
|
|
warning.level === "breakage" || warning.level === "error" || warning.severity === "P0"
|
|
? "error"
|
|
: "warning";
|
|
if (findingKind === "error") hasStoredHardError = true;
|
|
await ctx.db.insert("packageInspectorWarnings", {
|
|
packageId: args.packageId,
|
|
releaseId: args.releaseId,
|
|
ownerUserId: args.ownerUserId,
|
|
ownerPublisherId: args.ownerPublisherId,
|
|
packageName: args.packageName,
|
|
version: args.version,
|
|
findingKind,
|
|
scanSource: args.scanSource ?? "publish",
|
|
inspectorVersion: args.inspectorVersion,
|
|
targetOpenClawVersion: args.targetOpenClawVersion,
|
|
code: warning.code,
|
|
severity: warning.severity,
|
|
level: warning.level,
|
|
issueClass: warning.issueClass,
|
|
compatStatus: warning.compatStatus,
|
|
deprecated: warning.deprecated,
|
|
message: warning.message,
|
|
evidence: warning.evidence,
|
|
authorRemediation: warning.authorRemediation,
|
|
fixture: warning.fixture,
|
|
decision: warning.decision,
|
|
inspectorFindingId: warning.id,
|
|
createdAt: now,
|
|
});
|
|
existingWarningKeys.add(warningKey);
|
|
inserted += 1;
|
|
}
|
|
return {
|
|
ok: true as const,
|
|
inserted,
|
|
shouldEmailOwner:
|
|
shouldNotifyOwner &&
|
|
!notificationCompleted &&
|
|
hasStoredHardError &&
|
|
(args.scanSource === "nightly"
|
|
? findings.length > 0
|
|
: inserted > 0 || existingAuthorWarnings.length > 0),
|
|
};
|
|
}
|
|
|
|
export const markPackageInspectorFindingsEmailedInternal = internalMutation({
|
|
args: {
|
|
packageId: v.id("packages"),
|
|
releaseId: v.id("packageReleases"),
|
|
ownerUserId: v.id("users"),
|
|
ownerPublisherId: v.optional(v.id("publishers")),
|
|
packageName: v.string(),
|
|
version: v.string(),
|
|
findingCount: v.number(),
|
|
email: v.string(),
|
|
inspectorVersion: v.optional(v.string()),
|
|
targetOpenClawVersion: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const existing = await ctx.db
|
|
.query("packageInspectorFindingNotifications")
|
|
.withIndex("by_release", (q) => q.eq("releaseId", args.releaseId))
|
|
.unique();
|
|
const now = Date.now();
|
|
if (!existing) {
|
|
await ctx.db.insert("packageInspectorFindingNotifications", {
|
|
packageId: args.packageId,
|
|
releaseId: args.releaseId,
|
|
ownerUserId: args.ownerUserId,
|
|
ownerPublisherId: args.ownerPublisherId,
|
|
packageName: args.packageName,
|
|
version: args.version,
|
|
email: args.email,
|
|
findingCount: Math.max(0, Math.round(args.findingCount)),
|
|
sentAt: now,
|
|
});
|
|
}
|
|
const { inspectorVersion, targetOpenClawVersion } = args;
|
|
if (inspectorVersion && targetOpenClawVersion) {
|
|
const scanState = await ctx.db
|
|
.query("packageInspectorScanStates")
|
|
.withIndex("by_release_and_inspector_version_and_target_openclaw_version", (q) =>
|
|
q
|
|
.eq("releaseId", args.releaseId)
|
|
.eq("inspectorVersion", inspectorVersion)
|
|
.eq("targetOpenClawVersion", targetOpenClawVersion),
|
|
)
|
|
.unique();
|
|
if (scanState) await ctx.db.patch(scanState._id, { notificationCompletedAt: now });
|
|
}
|
|
return { ok: true as const, created: !existing };
|
|
},
|
|
});
|
|
|
|
export const markPackageInspectorNotificationCompletedInternal = internalMutation({
|
|
args: {
|
|
packageId: v.id("packages"),
|
|
releaseId: v.id("packageReleases"),
|
|
inspectorVersion: v.string(),
|
|
targetOpenClawVersion: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const scanState = await ctx.db
|
|
.query("packageInspectorScanStates")
|
|
.withIndex("by_release_and_inspector_version_and_target_openclaw_version", (q) =>
|
|
q
|
|
.eq("releaseId", args.releaseId)
|
|
.eq("inspectorVersion", args.inspectorVersion)
|
|
.eq("targetOpenClawVersion", args.targetOpenClawVersion),
|
|
)
|
|
.unique();
|
|
if (!scanState || scanState.packageId !== args.packageId) {
|
|
return { ok: true as const, marked: false };
|
|
}
|
|
if (!scanState.notificationCompletedAt) {
|
|
await ctx.db.patch(scanState._id, { notificationCompletedAt: Date.now() });
|
|
}
|
|
return { ok: true as const, marked: true };
|
|
},
|
|
});
|
|
|
|
export const getPackageInspectorEmailContextInternal = internalQuery({
|
|
args: {
|
|
packageId: v.id("packages"),
|
|
releaseId: v.id("packageReleases"),
|
|
inspectorVersion: v.optional(v.string()),
|
|
targetOpenClawVersion: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const [pkg, release] = await Promise.all([
|
|
ctx.db.get(args.packageId),
|
|
ctx.db.get(args.releaseId),
|
|
]);
|
|
if (
|
|
!pkg ||
|
|
pkg.softDeletedAt ||
|
|
!release ||
|
|
release.softDeletedAt ||
|
|
release.packageId !== pkg._id
|
|
) {
|
|
return null;
|
|
}
|
|
const owner = await ctx.db.get(pkg.ownerUserId);
|
|
if (!owner || owner.deletedAt || owner.deactivatedAt || !owner.email) return null;
|
|
const exactScanIdentity = Boolean(args.inspectorVersion && args.targetOpenClawVersion);
|
|
const findings =
|
|
args.inspectorVersion && args.targetOpenClawVersion
|
|
? await takeExactNightlyAuthorRemediationWarningsByRelease(
|
|
ctx,
|
|
release._id,
|
|
args.inspectorVersion,
|
|
args.targetOpenClawVersion,
|
|
100,
|
|
)
|
|
: await takeAuthorRemediationWarningsByRelease(ctx, release._id, 100);
|
|
const hardErrors = findings.filter((finding) => finding.findingKind === "error");
|
|
if (hardErrors.length === 0) return null;
|
|
const notificationCompleted = await hasCompletedPackageInspectorNotification(ctx, {
|
|
releaseId: release._id,
|
|
inspectorVersion: args.inspectorVersion,
|
|
targetOpenClawVersion: args.targetOpenClawVersion,
|
|
exactScanIdentity,
|
|
});
|
|
if (notificationCompleted) return null;
|
|
return {
|
|
packageId: pkg._id,
|
|
releaseId: release._id,
|
|
ownerUserId: pkg.ownerUserId,
|
|
ownerPublisherId: pkg.ownerPublisherId,
|
|
ownerEmail: owner.email,
|
|
ownerHandle: owner.handle,
|
|
packageName: pkg.name,
|
|
version: release.version,
|
|
findings: hardErrors.map(toPublicPackageInspectorFinding),
|
|
};
|
|
},
|
|
});
|
|
|
|
export const sendPackageInspectorFindingsEmailInternal = internalAction({
|
|
args: {
|
|
packageId: v.id("packages"),
|
|
releaseId: v.id("packageReleases"),
|
|
inspectorVersion: v.optional(v.string()),
|
|
targetOpenClawVersion: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const context = await runQueryRef<{
|
|
packageId: Id<"packages">;
|
|
releaseId: Id<"packageReleases">;
|
|
ownerUserId: Id<"users">;
|
|
ownerPublisherId?: Id<"publishers">;
|
|
ownerEmail: string;
|
|
ownerHandle?: string;
|
|
packageName: string;
|
|
version: string;
|
|
findings: Array<{
|
|
findingKind: "warning" | "error";
|
|
code: string;
|
|
issueClass?: string;
|
|
level?: string;
|
|
severity?: string;
|
|
message: string;
|
|
authorRemediation?: PackageInspectorAuthorRemediation;
|
|
inspectorVersion?: string;
|
|
targetOpenClawVersion?: string;
|
|
scanSource?: "publish" | "nightly";
|
|
}>;
|
|
} | null>(ctx, internalRefs.packages.getPackageInspectorEmailContextInternal, args);
|
|
if (!context) return { ok: true as const, sent: false, reason: "no-context" as const };
|
|
|
|
const email = await buildPackageInspectorFindingsEmail({
|
|
handle: context.ownerHandle,
|
|
packageName: context.packageName,
|
|
version: context.version,
|
|
validationUrl: buildPackageInspectorValidationUrl(context.packageName),
|
|
});
|
|
const sent = await sendResendEmail({
|
|
to: context.ownerEmail,
|
|
subject: email.subject,
|
|
text: email.text,
|
|
html: email.html,
|
|
});
|
|
if (sent) {
|
|
await runMutationRef(ctx, internalRefs.packages.markPackageInspectorFindingsEmailedInternal, {
|
|
packageId: context.packageId,
|
|
releaseId: context.releaseId,
|
|
ownerUserId: context.ownerUserId,
|
|
ownerPublisherId: context.ownerPublisherId,
|
|
packageName: context.packageName,
|
|
version: context.version,
|
|
findingCount: context.findings.length,
|
|
email: context.ownerEmail,
|
|
inspectorVersion: args.inspectorVersion,
|
|
targetOpenClawVersion: args.targetOpenClawVersion,
|
|
});
|
|
}
|
|
return { ok: true as const, sent };
|
|
},
|
|
});
|
|
|
|
type PackageInspectorScanBatchItem = {
|
|
packageId: Id<"packages">;
|
|
releaseId: Id<"packageReleases">;
|
|
ownerUserId: Id<"users">;
|
|
ownerPublisherId?: Id<"publishers">;
|
|
packageName: string;
|
|
version: string;
|
|
artifactKind: "legacy-zip" | "npm-pack";
|
|
};
|
|
|
|
async function listPackageInspectorScanBatch(
|
|
ctx: Pick<QueryCtx | MutationCtx, "db">,
|
|
args: {
|
|
cursor?: string | null;
|
|
batchSize?: number;
|
|
inspectorVersion?: string;
|
|
targetOpenClawVersion?: string;
|
|
notifyOwners?: boolean;
|
|
},
|
|
) {
|
|
const batchSize = Math.max(1, Math.min(Math.round(args.batchSize ?? 25), 50));
|
|
const items: PackageInspectorScanBatchItem[] = [];
|
|
let skippedUnchanged = 0;
|
|
const sourceCursor = args.cursor ?? null;
|
|
// Convex permits only one native paginated query per function. The workflow
|
|
// acknowledges this source-page cursor and requests the next page separately.
|
|
const page = await ctx.db
|
|
.query("packageReleases")
|
|
.withIndex("by_active_created", (q) => q.eq("softDeletedAt", undefined))
|
|
.order("asc")
|
|
.paginate({
|
|
cursor: sourceCursor,
|
|
numItems: batchSize,
|
|
});
|
|
if (!page.isDone && page.continueCursor === sourceCursor) {
|
|
throw new Error("Package Inspector scan pagination cursor did not advance");
|
|
}
|
|
for (const release of page.page) {
|
|
if (!isPublishedPackageRelease(release)) continue;
|
|
const pkg = await ctx.db.get(release.packageId);
|
|
if (
|
|
!pkg ||
|
|
pkg.softDeletedAt ||
|
|
(pkg.family !== "code-plugin" && pkg.family !== "bundle-plugin") ||
|
|
pkg.channel === "private" ||
|
|
isPackageBlockedFromPublic(resolvePublicPackageScanStatus(pkg, release))
|
|
) {
|
|
continue;
|
|
}
|
|
const latestReleaseId = pkg.latestReleaseId ?? pkg.tags?.latest;
|
|
if (latestReleaseId !== release._id) continue;
|
|
const { inspectorVersion, targetOpenClawVersion } = args;
|
|
if (inspectorVersion && targetOpenClawVersion) {
|
|
const scanState = await ctx.db
|
|
.query("packageInspectorScanStates")
|
|
.withIndex("by_release_and_inspector_version_and_target_openclaw_version", (q) =>
|
|
q
|
|
.eq("releaseId", release._id)
|
|
.eq("inspectorVersion", inspectorVersion)
|
|
.eq("targetOpenClawVersion", targetOpenClawVersion),
|
|
)
|
|
.unique();
|
|
if (scanState && (!args.notifyOwners || scanState.notificationCompletedAt)) {
|
|
skippedUnchanged += 1;
|
|
continue;
|
|
}
|
|
}
|
|
items.push({
|
|
packageId: pkg._id,
|
|
releaseId: release._id,
|
|
ownerUserId: pkg.ownerUserId,
|
|
ownerPublisherId: pkg.ownerPublisherId,
|
|
packageName: pkg.name,
|
|
version: release.version,
|
|
artifactKind: release.artifactKind ?? "legacy-zip",
|
|
});
|
|
}
|
|
const nextCursor = page.isDone ? null : page.continueCursor;
|
|
return {
|
|
items,
|
|
nextCursor,
|
|
skippedUnchanged,
|
|
};
|
|
}
|
|
|
|
export const previewPackageInspectorScanBatchInternal = internalQuery({
|
|
args: {
|
|
batchSize: v.optional(v.number()),
|
|
cursor: v.optional(v.union(v.string(), v.null())),
|
|
inspectorVersion: v.optional(v.string()),
|
|
targetOpenClawVersion: v.optional(v.string()),
|
|
notifyOwners: v.optional(v.boolean()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const result = await listPackageInspectorScanBatch(ctx, args);
|
|
return { ok: true as const, leased: false as const, ...result };
|
|
},
|
|
});
|
|
|
|
export const claimPackageInspectorScanBatchInternal = internalMutation({
|
|
args: {
|
|
batchSize: v.optional(v.number()),
|
|
leaseMs: v.optional(v.number()),
|
|
cursor: v.optional(v.union(v.string(), v.null())),
|
|
runId: v.string(),
|
|
inspectorVersion: v.optional(v.string()),
|
|
targetOpenClawVersion: v.optional(v.string()),
|
|
notifyOwners: v.optional(v.boolean()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const now = Date.now();
|
|
const runId = args.runId.trim();
|
|
if (!runId) throw new Error("Package Inspector scan runId is required");
|
|
const leaseMs = Math.max(
|
|
60_000,
|
|
Math.min(Math.round(args.leaseMs ?? 30 * 60_000), 2 * 60 * 60_000),
|
|
);
|
|
const cursorName = "nightly";
|
|
const cursorDoc = await ctx.db
|
|
.query("packageInspectorScanCursors")
|
|
.withIndex("by_name", (q) => q.eq("name", cursorName))
|
|
.unique();
|
|
const expectedCursor = cursorDoc?.cursor ?? null;
|
|
const hasPendingBatch = Boolean(cursorDoc && Object.hasOwn(cursorDoc, "pendingCursor"));
|
|
// A GitHub workflow rerun keeps its run id. Let it reclaim an unacknowledged
|
|
// page immediately so a failed attempt does not strand the cursor until expiry.
|
|
const continuingSameRun = cursorDoc?.runId === runId;
|
|
if (cursorDoc?.leaseExpiresAt && cursorDoc.leaseExpiresAt > now && !continuingSameRun) {
|
|
return {
|
|
ok: true as const,
|
|
leased: true as const,
|
|
items: [],
|
|
nextCursor: hasPendingBatch ? (cursorDoc.pendingCursor ?? null) : expectedCursor,
|
|
};
|
|
}
|
|
|
|
const { items, nextCursor, skippedUnchanged } = await listPackageInspectorScanBatch(ctx, {
|
|
cursor: expectedCursor,
|
|
batchSize: args.batchSize,
|
|
inspectorVersion: args.inspectorVersion,
|
|
targetOpenClawVersion: args.targetOpenClawVersion,
|
|
notifyOwners: args.notifyOwners,
|
|
});
|
|
if (cursorDoc) {
|
|
await ctx.db.patch(cursorDoc._id, {
|
|
cursor: expectedCursor,
|
|
pendingCursor: nextCursor,
|
|
runId,
|
|
leaseExpiresAt: now + leaseMs,
|
|
updatedAt: now,
|
|
});
|
|
} else {
|
|
await ctx.db.insert("packageInspectorScanCursors", {
|
|
name: cursorName,
|
|
cursor: expectedCursor,
|
|
pendingCursor: nextCursor,
|
|
runId,
|
|
leaseExpiresAt: now + leaseMs,
|
|
updatedAt: now,
|
|
});
|
|
}
|
|
return {
|
|
ok: true as const,
|
|
leased: false as const,
|
|
items,
|
|
nextCursor,
|
|
skippedUnchanged,
|
|
};
|
|
},
|
|
});
|
|
|
|
export const acknowledgePackageInspectorScanBatchInternal = internalMutation({
|
|
args: {
|
|
runId: v.string(),
|
|
cursor: v.union(v.string(), v.null()),
|
|
leaseMs: v.optional(v.number()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const now = Date.now();
|
|
const runId = args.runId.trim();
|
|
if (!runId) throw new Error("Package Inspector scan runId is required");
|
|
const leaseMs = Math.max(
|
|
60_000,
|
|
Math.min(Math.round(args.leaseMs ?? 30 * 60_000), 2 * 60 * 60_000),
|
|
);
|
|
const cursorDoc = await ctx.db
|
|
.query("packageInspectorScanCursors")
|
|
.withIndex("by_name", (q) => q.eq("name", "nightly"))
|
|
.unique();
|
|
if (!cursorDoc || cursorDoc.runId !== runId) {
|
|
throw new Error("Package Inspector scan run does not own the active lease");
|
|
}
|
|
if (!Object.hasOwn(cursorDoc, "pendingCursor") || cursorDoc.pendingCursor !== args.cursor) {
|
|
throw new Error("Package Inspector scan acknowledgement cursor does not match the lease");
|
|
}
|
|
|
|
const completed = args.cursor === null;
|
|
await ctx.db.patch(cursorDoc._id, {
|
|
cursor: args.cursor,
|
|
pendingCursor: undefined,
|
|
runId: completed ? undefined : runId,
|
|
leaseExpiresAt: completed ? now : now + leaseMs,
|
|
updatedAt: now,
|
|
});
|
|
return { ok: true as const, cursor: args.cursor, completed };
|
|
},
|
|
});
|
|
|
|
export const getPackageInspectorArtifactInternal = internalQuery({
|
|
args: {
|
|
releaseId: v.id("packageReleases"),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const release = await ctx.db.get(args.releaseId);
|
|
if (!isPublishedPackageRelease(release)) return null;
|
|
const pkg = await ctx.db.get(release.packageId);
|
|
if (
|
|
!pkg ||
|
|
pkg.softDeletedAt ||
|
|
(pkg.family !== "code-plugin" && pkg.family !== "bundle-plugin") ||
|
|
pkg.channel === "private" ||
|
|
isPackageBlockedFromPublic(resolvePublicPackageScanStatus(pkg, release))
|
|
) {
|
|
return null;
|
|
}
|
|
return {
|
|
packageName: pkg.name,
|
|
version: release.version,
|
|
artifactKind: release.artifactKind ?? ("legacy-zip" as const),
|
|
clawpackStorageId: release.clawpackStorageId,
|
|
clawpackSha256: release.clawpackSha256,
|
|
npmIntegrity: release.npmIntegrity,
|
|
npmShasum: release.npmShasum,
|
|
npmTarballName: release.npmTarballName,
|
|
files: release.files.map((file) => ({
|
|
path: file.path,
|
|
storageId: file.storageId,
|
|
})),
|
|
};
|
|
},
|
|
});
|
|
|
|
export const ingestPackageInspectorScanResultsInternal = internalMutation({
|
|
args: {
|
|
packageId: v.id("packages"),
|
|
releaseId: v.id("packageReleases"),
|
|
inspectorVersion: v.optional(v.string()),
|
|
targetOpenClawVersion: v.optional(v.string()),
|
|
notifyOwners: v.optional(v.boolean()),
|
|
findings: v.array(packageInspectorFindingInputValidator),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const [pkg, release] = await Promise.all([
|
|
ctx.db.get(args.packageId),
|
|
ctx.db.get(args.releaseId),
|
|
]);
|
|
if (
|
|
!pkg ||
|
|
pkg.softDeletedAt ||
|
|
!release ||
|
|
release.softDeletedAt ||
|
|
release.packageId !== pkg._id
|
|
) {
|
|
throw new ConvexError("Package release not found");
|
|
}
|
|
const result = await insertPackageInspectorFindings(ctx, {
|
|
packageId: pkg._id,
|
|
releaseId: release._id,
|
|
ownerUserId: pkg.ownerUserId,
|
|
ownerPublisherId: pkg.ownerPublisherId,
|
|
packageName: pkg.name,
|
|
version: release.version,
|
|
scanSource: "nightly",
|
|
inspectorVersion: args.inspectorVersion,
|
|
targetOpenClawVersion: args.targetOpenClawVersion,
|
|
notifyOwners: args.notifyOwners,
|
|
findings: args.findings,
|
|
});
|
|
const { inspectorVersion, targetOpenClawVersion } = args;
|
|
if (inspectorVersion && targetOpenClawVersion) {
|
|
const completedAt = Date.now();
|
|
const existingState = await ctx.db
|
|
.query("packageInspectorScanStates")
|
|
.withIndex("by_release_and_inspector_version_and_target_openclaw_version", (q) =>
|
|
q
|
|
.eq("releaseId", release._id)
|
|
.eq("inspectorVersion", inspectorVersion)
|
|
.eq("targetOpenClawVersion", targetOpenClawVersion),
|
|
)
|
|
.unique();
|
|
const state = {
|
|
packageId: pkg._id,
|
|
releaseId: release._id,
|
|
inspectorVersion,
|
|
targetOpenClawVersion,
|
|
completedAt,
|
|
...(args.notifyOwners === true && !result.shouldEmailOwner
|
|
? { notificationCompletedAt: completedAt }
|
|
: {}),
|
|
};
|
|
if (existingState) {
|
|
await ctx.db.patch(existingState._id, {
|
|
completedAt,
|
|
...(state.notificationCompletedAt
|
|
? { notificationCompletedAt: state.notificationCompletedAt }
|
|
: {}),
|
|
});
|
|
} else {
|
|
await ctx.db.insert("packageInspectorScanStates", state);
|
|
}
|
|
}
|
|
return result;
|
|
},
|
|
});
|
|
|
|
async function sendResendEmail(args: { to: string; subject: string; text: string; html: string }) {
|
|
const apiKey = process.env.RESEND_API_KEY?.trim();
|
|
const from = process.env.RESEND_FROM_EMAIL?.trim() || process.env.CLAWHUB_EMAIL_FROM?.trim();
|
|
if (!apiKey || !from) return false;
|
|
try {
|
|
const response = await fetch("https://api.resend.com/emails", {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${apiKey}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
from,
|
|
to: args.to,
|
|
subject: args.subject,
|
|
text: args.text,
|
|
html: args.html,
|
|
}),
|
|
});
|
|
if (!response.ok) {
|
|
console.error(`Resend email failed: ${response.status} ${await response.text()}`);
|
|
return false;
|
|
}
|
|
return true;
|
|
} catch (error) {
|
|
console.error("Resend email failed", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function pendingPackagePublicationMetadata(release: Doc<"packageReleases">) {
|
|
return release.pendingPublication &&
|
|
typeof release.pendingPublication === "object" &&
|
|
!Array.isArray(release.pendingPublication)
|
|
? (release.pendingPublication as Record<string, unknown>)
|
|
: {};
|
|
}
|
|
|
|
function stringPendingField(metadata: Record<string, unknown>, field: string, fallback?: string) {
|
|
const value = metadata[field];
|
|
return typeof value === "string" ? value : fallback;
|
|
}
|
|
|
|
function booleanPendingField(metadata: Record<string, unknown>, field: string, fallback: boolean) {
|
|
const value = metadata[field];
|
|
return typeof value === "boolean" ? value : fallback;
|
|
}
|
|
|
|
function stringArrayPendingField(metadata: Record<string, unknown>, field: string) {
|
|
const value = metadata[field];
|
|
return Array.isArray(value)
|
|
? value.filter((entry): entry is string => typeof entry === "string")
|
|
: undefined;
|
|
}
|
|
|
|
type PackageReleaseTagCleanupAssignment = {
|
|
releaseId: Id<"packageReleases">;
|
|
tags: string[];
|
|
};
|
|
|
|
function resolvePackageReleaseTagsForPublish(params: {
|
|
family: Doc<"packages">["family"];
|
|
currentLatestExists: boolean;
|
|
currentLatestVersion?: string;
|
|
candidateVersion: string;
|
|
requestedTags: string[];
|
|
}) {
|
|
const requestedLatest = params.requestedTags.some((tag) => tag.toLowerCase() === "latest");
|
|
const currentLatestSemver = params.currentLatestVersion
|
|
? semver.valid(params.currentLatestVersion)
|
|
: null;
|
|
const candidateSemver = semver.valid(params.candidateVersion);
|
|
const latestUsesSemver = params.family === "code-plugin" || params.family === "claw";
|
|
// `latest` is reserved for the highest semver on versioned package families.
|
|
// Callers default to this tag, so accepting it blindly would let backports roll the catalog back.
|
|
const shouldPromoteLatest =
|
|
requestedLatest &&
|
|
(!latestUsesSemver ||
|
|
!params.currentLatestExists ||
|
|
(currentLatestSemver !== null &&
|
|
candidateSemver !== null &&
|
|
semver.gt(candidateSemver, currentLatestSemver)));
|
|
const effectiveTags = [
|
|
...new Set(params.requestedTags.filter((tag) => tag.toLowerCase() !== "latest")),
|
|
];
|
|
if (shouldPromoteLatest) effectiveTags.push("latest");
|
|
return { effectiveTags, shouldPromoteLatest };
|
|
}
|
|
|
|
function getPackageTagReleaseId(
|
|
pkg: Pick<Doc<"packages">, "_id" | "latestReleaseId" | "tags">,
|
|
tag: string,
|
|
) {
|
|
return tag === "latest" ? (pkg.tags.latest ?? pkg.latestReleaseId) : pkg.tags[tag];
|
|
}
|
|
|
|
async function resolvePackageCurrentLatestForPublish(
|
|
ctx: Pick<MutationCtx, "db">,
|
|
pkg: Pick<
|
|
Doc<"packages">,
|
|
"_id" | "family" | "latestReleaseId" | "latestVersionSummary" | "tags"
|
|
>,
|
|
) {
|
|
const latestReleaseId = getPackageTagReleaseId(pkg, "latest");
|
|
if (!latestReleaseId) return { exists: false, version: undefined };
|
|
if (pkg.family !== "code-plugin" && pkg.family !== "claw") {
|
|
return { exists: true, version: pkg.latestVersionSummary?.version };
|
|
}
|
|
|
|
const latestRelease = await ctx.db.get(latestReleaseId);
|
|
if (
|
|
latestRelease &&
|
|
latestRelease.packageId === pkg._id &&
|
|
isPublishedPackageRelease(latestRelease)
|
|
) {
|
|
return { exists: true, version: latestRelease.version };
|
|
}
|
|
// Keep the current pointer when its release row is missing or unusable.
|
|
// Promotion without a comparable version could roll installs back to a backport.
|
|
return { exists: true, version: pkg.latestVersionSummary?.version };
|
|
}
|
|
|
|
function planPackageReleaseTagReassignment(
|
|
pkg: Pick<Doc<"packages">, "_id" | "latestReleaseId" | "tags">,
|
|
releaseId: Id<"packageReleases">,
|
|
effectiveTags: string[],
|
|
) {
|
|
const cleanupTagsByRelease = new Map<Id<"packageReleases">, string[]>();
|
|
const nextTags = { ...pkg.tags };
|
|
|
|
for (const tag of new Set(effectiveTags)) {
|
|
const priorReleaseId = getPackageTagReleaseId(pkg, tag);
|
|
nextTags[tag] = releaseId;
|
|
if (priorReleaseId && priorReleaseId !== releaseId) {
|
|
cleanupTagsByRelease.set(priorReleaseId, [
|
|
...(cleanupTagsByRelease.get(priorReleaseId) ?? []),
|
|
tag,
|
|
]);
|
|
}
|
|
}
|
|
|
|
return {
|
|
nextTags,
|
|
cleanupAssignments: [...cleanupTagsByRelease].map(([priorReleaseId, tags]) => ({
|
|
releaseId: priorReleaseId,
|
|
tags,
|
|
})),
|
|
};
|
|
}
|
|
|
|
export const discardPendingPackagePublicationInternal = internalMutation({
|
|
args: {
|
|
packageId: v.id("packages"),
|
|
releaseId: v.id("packageReleases"),
|
|
createdNewParent: v.optional(v.boolean()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const release = await ctx.db.get(args.releaseId);
|
|
if (
|
|
!release ||
|
|
release.packageId !== args.packageId ||
|
|
release.publicationStatus !== "pending"
|
|
) {
|
|
return { deleted: false };
|
|
}
|
|
|
|
const storageIds = new Set<Id<"_storage">>();
|
|
for (const file of release.files ?? []) {
|
|
if (typeof file.storageId === "string") {
|
|
storageIds.add(file.storageId as Id<"_storage">);
|
|
}
|
|
}
|
|
if (typeof release.clawpackStorageId === "string") {
|
|
storageIds.add(release.clawpackStorageId as Id<"_storage">);
|
|
}
|
|
|
|
await ctx.db.delete(release._id);
|
|
await Promise.allSettled([...storageIds].map((storageId) => ctx.storage.delete(storageId)));
|
|
|
|
let parentDeleted = false;
|
|
if (args.createdNewParent) {
|
|
const pkg = await ctx.db.get(args.packageId);
|
|
if (pkg && !pkg.latestReleaseId) {
|
|
const remainingReleases = await ctx.db
|
|
.query("packageReleases")
|
|
.withIndex("by_package", (q) => q.eq("packageId", args.packageId))
|
|
.take(1);
|
|
if (remainingReleases.length === 0) {
|
|
await ctx.db.delete(args.packageId);
|
|
parentDeleted = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return { deleted: true, parentDeleted };
|
|
},
|
|
});
|
|
|
|
export const cleanupReassignedPackageReleaseTagsInternal = internalMutation({
|
|
args: {
|
|
packageId: v.id("packages"),
|
|
assignments: v.array(
|
|
v.object({
|
|
releaseId: v.id("packageReleases"),
|
|
tags: v.array(v.string()),
|
|
}),
|
|
),
|
|
offset: v.optional(v.number()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const offset = Math.max(0, Math.floor(args.offset ?? 0));
|
|
const pkg = await ctx.db.get(args.packageId);
|
|
if (!pkg) return { cleaned: 0, scheduled: false };
|
|
|
|
const batch = args.assignments.slice(offset, offset + PACKAGE_RELEASE_TAG_CLEANUP_BATCH_SIZE);
|
|
let cleaned = 0;
|
|
for (const assignment of batch) {
|
|
const tagsToRemove = assignment.tags.filter(
|
|
(tag) => getPackageTagReleaseId(pkg, tag) !== assignment.releaseId,
|
|
);
|
|
if (tagsToRemove.length === 0) continue;
|
|
|
|
const priorRelease = await ctx.db.get(assignment.releaseId);
|
|
if (
|
|
!priorRelease ||
|
|
priorRelease.packageId !== pkg._id ||
|
|
!isPublishedPackageRelease(priorRelease)
|
|
) {
|
|
continue;
|
|
}
|
|
const tagsToRemoveSet = new Set(tagsToRemove);
|
|
const nextDistTags = (priorRelease.distTags ?? []).filter((tag) => !tagsToRemoveSet.has(tag));
|
|
if (nextDistTags.length === (priorRelease.distTags ?? []).length) continue;
|
|
await ctx.db.patch(priorRelease._id, { distTags: nextDistTags });
|
|
cleaned += 1;
|
|
}
|
|
|
|
const nextOffset = offset + batch.length;
|
|
const scheduled = nextOffset < args.assignments.length;
|
|
if (scheduled) {
|
|
await runAfterRef(ctx, 0, internalRefs.packages.cleanupReassignedPackageReleaseTagsInternal, {
|
|
packageId: args.packageId,
|
|
assignments: args.assignments,
|
|
offset: nextOffset,
|
|
});
|
|
}
|
|
return { cleaned, scheduled };
|
|
},
|
|
});
|
|
|
|
async function schedulePackageReleaseTagCleanup(
|
|
ctx: Pick<MutationCtx, "scheduler">,
|
|
packageId: Id<"packages">,
|
|
assignments: PackageReleaseTagCleanupAssignment[],
|
|
) {
|
|
if (assignments.length === 0) return;
|
|
await runAfterRef(ctx, 0, internalRefs.packages.cleanupReassignedPackageReleaseTagsInternal, {
|
|
packageId,
|
|
assignments,
|
|
});
|
|
}
|
|
|
|
export const publishPendingReleaseInternal = internalMutation({
|
|
args: {
|
|
releaseId: v.id("packageReleases"),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const release = await ctx.db.get(args.releaseId);
|
|
if (!release || release.softDeletedAt) {
|
|
throw new ConvexError("Pending package release not found");
|
|
}
|
|
if (release.publicationStatus === undefined || release.publicationStatus === "published") {
|
|
return {
|
|
ok: true as const,
|
|
packageId: release.packageId,
|
|
releaseId: release._id,
|
|
};
|
|
}
|
|
if (release.publicationStatus !== "pending") {
|
|
throw new ConvexError(`Package release is ${release.publicationStatus}, not pending.`);
|
|
}
|
|
|
|
const pkg = await ctx.db.get(release.packageId);
|
|
if (!pkg || pkg.softDeletedAt || pkg.family === "skill") {
|
|
throw new ConvexError("Package not found");
|
|
}
|
|
|
|
const now = Date.now();
|
|
const metadata = pendingPackagePublicationMetadata(release);
|
|
const currentLatest = await resolvePackageCurrentLatestForPublish(ctx, pkg);
|
|
const { effectiveTags, shouldPromoteLatest } = resolvePackageReleaseTagsForPublish({
|
|
family: pkg.family,
|
|
currentLatestExists: currentLatest.exists,
|
|
currentLatestVersion: currentLatest.version,
|
|
candidateVersion: release.version,
|
|
requestedTags: stringArrayPendingField(metadata, "tags") ?? release.distTags ?? [],
|
|
});
|
|
const scanStatus = resolvePackageReleaseScanStatus(release);
|
|
const releaseVerification = release.verification
|
|
? { ...release.verification, scanStatus }
|
|
: release.verification;
|
|
const publishedRelease = {
|
|
...release,
|
|
publicationStatus: "published" as const,
|
|
pendingPublication: undefined,
|
|
distTags: effectiveTags,
|
|
verification: releaseVerification,
|
|
} as Doc<"packageReleases">;
|
|
|
|
const { nextTags, cleanupAssignments } = planPackageReleaseTagReassignment(
|
|
pkg,
|
|
release._id,
|
|
effectiveTags,
|
|
);
|
|
|
|
await ctx.db.patch(release._id, {
|
|
publicationStatus: "published",
|
|
pendingPublication: undefined,
|
|
distTags: effectiveTags,
|
|
verification: releaseVerification,
|
|
});
|
|
|
|
await ctx.db.patch(pkg._id, {
|
|
displayName: stringPendingField(metadata, "displayName", pkg.displayName),
|
|
ownerUserId: pkg.ownerUserId,
|
|
ownerPublisherId: pkg.ownerPublisherId,
|
|
family: pkg.family,
|
|
summary: shouldPromoteLatest ? release.summary : pkg.summary,
|
|
icon: shouldPromoteLatest ? release.icon : pkg.icon,
|
|
categories: shouldPromoteLatest
|
|
? stringArrayPendingField(metadata, "categories")
|
|
: pkg.categories,
|
|
topics: shouldPromoteLatest ? stringArrayPendingField(metadata, "topics") : pkg.topics,
|
|
...(shouldPromoteLatest
|
|
? {
|
|
inferredCategories: undefined,
|
|
inferredTopics: undefined,
|
|
inferredFromReleaseId: undefined,
|
|
inferredCategoryConfidence: undefined,
|
|
inferredTopicConfidence: undefined,
|
|
inferredClassifierVersion: undefined,
|
|
inferredTopicClassifierVersion: undefined,
|
|
inferredInputHash: undefined,
|
|
inferredTopicInputHash: undefined,
|
|
inferredAt: undefined,
|
|
}
|
|
: {}),
|
|
sourceRepo: stringPendingField(metadata, "sourceRepo", release.sourceRepo),
|
|
runtimeId: shouldPromoteLatest ? release.runtimeId : pkg.runtimeId,
|
|
channel: stringPendingField(metadata, "channel", pkg.channel) as Doc<"packages">["channel"],
|
|
isOfficial: booleanPendingField(metadata, "isOfficial", pkg.isOfficial),
|
|
latestReleaseId: shouldPromoteLatest ? release._id : pkg.latestReleaseId,
|
|
latestVersionSummary: shouldPromoteLatest
|
|
? packageLatestSummaryFromRelease(publishedRelease)
|
|
: pkg.latestVersionSummary,
|
|
tags: nextTags,
|
|
compatibility: shouldPromoteLatest ? release.compatibility : pkg.compatibility,
|
|
verification: shouldPromoteLatest ? releaseVerification : pkg.verification,
|
|
scanStatus: shouldPromoteLatest ? scanStatus : pkg.scanStatus,
|
|
stats: { ...pkg.stats, versions: (pkg.stats?.versions ?? 0) + 1 },
|
|
updatedAt: now,
|
|
});
|
|
await schedulePackageReleaseTagCleanup(ctx, pkg._id, cleanupAssignments);
|
|
|
|
return {
|
|
ok: true as const,
|
|
packageId: pkg._id,
|
|
releaseId: release._id,
|
|
};
|
|
},
|
|
});
|
|
|
|
export const insertReleaseInternal = internalMutation({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
ownerUserId: v.id("users"),
|
|
ownerPublisherId: v.optional(v.id("publishers")),
|
|
publishActor: v.optional(
|
|
v.union(
|
|
v.object({
|
|
kind: v.literal("user"),
|
|
userId: v.id("users"),
|
|
}),
|
|
v.object({
|
|
kind: v.literal("github-actions"),
|
|
repository: v.string(),
|
|
workflow: v.string(),
|
|
runId: v.string(),
|
|
runAttempt: v.string(),
|
|
sha: v.string(),
|
|
}),
|
|
),
|
|
),
|
|
name: v.string(),
|
|
displayName: v.string(),
|
|
family: v.union(
|
|
v.literal("skill"),
|
|
v.literal("code-plugin"),
|
|
v.literal("bundle-plugin"),
|
|
v.literal("claw"),
|
|
),
|
|
version: v.string(),
|
|
publicationStatus: v.optional(v.union(v.literal("pending"), v.literal("published"))),
|
|
changelog: v.string(),
|
|
icon: v.optional(v.string()),
|
|
tags: v.array(v.string()),
|
|
summary: v.string(),
|
|
categories: v.optional(v.array(v.string())),
|
|
topics: v.optional(v.array(v.string())),
|
|
sourceRepo: v.optional(v.string()),
|
|
runtimeId: v.optional(v.string()),
|
|
channel: v.optional(
|
|
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
|
|
),
|
|
compatibility: v.optional(v.any()),
|
|
verification: v.optional(v.any()),
|
|
staticScan: v.optional(v.any()),
|
|
llmAnalysis: v.optional(v.any()),
|
|
allowExistingRelease: v.optional(v.boolean()),
|
|
files: v.array(
|
|
v.object({
|
|
path: v.string(),
|
|
size: v.number(),
|
|
storageId: v.id("_storage"),
|
|
sha256: v.string(),
|
|
contentType: v.optional(v.string()),
|
|
}),
|
|
),
|
|
integritySha256: v.string(),
|
|
sha256hash: v.string(),
|
|
artifactKind: v.optional(v.union(v.literal("legacy-zip"), v.literal("npm-pack"))),
|
|
clawpackStorageId: v.optional(v.id("_storage")),
|
|
clawpackSha256: v.optional(v.string()),
|
|
clawpackSize: v.optional(v.number()),
|
|
clawpackFormat: v.optional(v.literal("tgz")),
|
|
npmIntegrity: v.optional(v.string()),
|
|
npmShasum: v.optional(v.string()),
|
|
npmTarballName: v.optional(v.string()),
|
|
npmUnpackedSize: v.optional(v.number()),
|
|
npmFileCount: v.optional(v.number()),
|
|
extractedPackageJson: v.optional(v.any()),
|
|
extractedPluginManifest: v.optional(v.any()),
|
|
normalizedBundleManifest: v.optional(v.any()),
|
|
pluginManifestSummary: v.optional(v.any()),
|
|
clawManifestSummary: v.optional(v.any()),
|
|
source: v.optional(v.any()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
if (args.family === "claw" && !experimentalClawsEnabled()) {
|
|
throw new ConvexError("Experimental Claw publication is disabled");
|
|
}
|
|
const now = Date.now();
|
|
const publicationStatus = args.publicationStatus ?? "published";
|
|
const pendingPublication = publicationStatus === "pending";
|
|
const prePublicationScanStatus = args.llmAnalysis
|
|
? normalizePackageScanStatus(args.llmAnalysis.verdict ?? args.llmAnalysis.status)
|
|
: undefined;
|
|
const releaseVerification =
|
|
args.verification && prePublicationScanStatus
|
|
? { ...args.verification, scanStatus: prePublicationScanStatus }
|
|
: args.verification;
|
|
const normalizedName = normalizePackageName(args.name);
|
|
const actor = await ctx.db.get(args.actorUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
|
const owner = await ctx.db.get(args.ownerUserId);
|
|
if (!owner || owner.deletedAt || owner.deactivatedAt) {
|
|
throw new ConvexError("Package owner is unavailable");
|
|
}
|
|
const ownerPublisher = args.ownerPublisherId ? await ctx.db.get(args.ownerPublisherId) : null;
|
|
if (
|
|
args.ownerPublisherId &&
|
|
(!ownerPublisher || ownerPublisher.deletedAt || ownerPublisher.deactivatedAt)
|
|
) {
|
|
throw new ConvexError("Package owner publisher is unavailable");
|
|
}
|
|
if (ownerPublisher?.kind === "user" && ownerPublisher.linkedUserId) {
|
|
const linkedPublisherUser = await ctx.db.get(ownerPublisher.linkedUserId);
|
|
if (
|
|
!linkedPublisherUser ||
|
|
linkedPublisherUser.deletedAt ||
|
|
linkedPublisherUser.deactivatedAt
|
|
) {
|
|
throw new ConvexError("Package owner publisher is unavailable");
|
|
}
|
|
}
|
|
if (args.publishActor?.kind === "user" && args.publishActor.userId !== args.actorUserId) {
|
|
throw new ConvexError("Publish actor must match the authenticated actor");
|
|
}
|
|
if (args.publishActor?.kind === "user" && ownerPublisher?.kind === "org") {
|
|
const membership = await getPublisherMembership(
|
|
ctx,
|
|
ownerPublisher._id,
|
|
args.publishActor.userId,
|
|
);
|
|
if (!membership || !isPublisherRoleAllowed(membership.role, ["publisher"])) {
|
|
throw new ConvexError(
|
|
`You do not have publish access for "@${ownerPublisher.handle}". Ask an owner or admin to add you before publishing this package.`,
|
|
);
|
|
}
|
|
}
|
|
if (args.ownerUserId !== args.actorUserId) {
|
|
assertAdmin(actor);
|
|
}
|
|
const officialPublisher = await getOwnerPublisher(ctx, {
|
|
ownerPublisherId: args.ownerPublisherId,
|
|
ownerUserId: args.ownerUserId,
|
|
});
|
|
const publisherOfficial = await isOfficialPublisher(ctx, officialPublisher);
|
|
if (args.channel === "official" && !publisherOfficial) {
|
|
throw new ConvexError("Only official publishers may publish to the official channel");
|
|
}
|
|
const existing = await getPackageByNormalizedName(ctx, normalizedName);
|
|
const existingIsReservation = isReservedPackagePlaceholder(existing);
|
|
const nextNameLabel = typeof args.name === "string" ? args.name : "<unknown>";
|
|
if (existing?.softDeletedAt) {
|
|
throw new ConvexError(
|
|
`Package "${nextNameLabel}" was deleted. Restore it before publishing another release or choose a new package name.`,
|
|
);
|
|
}
|
|
const nextChannel = derivePackagePublisherChannel({
|
|
requestedChannel: args.channel,
|
|
currentChannel: existing?.channel,
|
|
currentIsReservation: existingIsReservation,
|
|
publisherOfficial,
|
|
});
|
|
const nextIsOfficial = nextChannel === "official";
|
|
const nextRuntimeIdLabel = typeof args.runtimeId === "string" ? args.runtimeId : "<unknown>";
|
|
const nextVersionLabel = typeof args.version === "string" ? args.version : "<unknown>";
|
|
if (existing) {
|
|
const existingOwnerKey = getPackageOwnerKey(existing, {
|
|
nextOwnerPublisherId: args.ownerPublisherId,
|
|
ownerPublisher,
|
|
});
|
|
const nextOwnerKey = getRequestedPackageOwnerKey({
|
|
ownerUserId: args.ownerUserId,
|
|
ownerPublisherId: args.ownerPublisherId,
|
|
});
|
|
if (existingOwnerKey !== nextOwnerKey) {
|
|
throw new ConvexError("Package already exists and belongs to another publisher");
|
|
}
|
|
}
|
|
if (existing && existing.family !== args.family && !existingIsReservation) {
|
|
throw new ConvexError(
|
|
`Package "${nextNameLabel}" already exists as a ${existing.family}; family changes are not allowed`,
|
|
);
|
|
}
|
|
if (
|
|
existing &&
|
|
existing.family === "code-plugin" &&
|
|
existing.runtimeId &&
|
|
args.runtimeId &&
|
|
existing.runtimeId !== args.runtimeId
|
|
) {
|
|
throw new ConvexError(
|
|
`Package "${nextNameLabel}" already exists with plugin id "${existing.runtimeId}"; runtime id changes are not allowed`,
|
|
);
|
|
}
|
|
if (args.family === "code-plugin" && args.runtimeId) {
|
|
const runtimeCollisions = await ctx.db
|
|
.query("packages")
|
|
.withIndex("by_runtime_id", (q) => q.eq("runtimeId", args.runtimeId))
|
|
.collect();
|
|
const runtimeCollision = runtimeCollisions.find(
|
|
(candidate) => candidate._id !== existing?._id && !candidate.softDeletedAt,
|
|
);
|
|
if (runtimeCollision) {
|
|
throw new ConvexError(
|
|
`Plugin id "${nextRuntimeIdLabel}" is already claimed by another package`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const createdNewParent = !existing;
|
|
const pkgId =
|
|
existing?._id ??
|
|
(await ctx.db.insert("packages", {
|
|
name: args.name,
|
|
normalizedName,
|
|
displayName: args.displayName,
|
|
summary: args.summary,
|
|
icon: args.icon,
|
|
ownerUserId: args.ownerUserId,
|
|
ownerPublisherId: args.ownerPublisherId,
|
|
family: args.family,
|
|
channel: nextChannel,
|
|
isOfficial: nextIsOfficial,
|
|
runtimeId: args.runtimeId,
|
|
sourceRepo: args.sourceRepo,
|
|
categories: args.categories,
|
|
topics: args.topics,
|
|
tags: {},
|
|
compatibility: args.compatibility,
|
|
verification: releaseVerification,
|
|
scanStatus: releaseVerification?.scanStatus,
|
|
stats: { downloads: 0, installs: 0, stars: 0, versions: 0 },
|
|
...computePackageRecommendationPatch(
|
|
{
|
|
downloads: 0,
|
|
installs: 0,
|
|
stars: 0,
|
|
versions: 0,
|
|
},
|
|
{
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
now,
|
|
},
|
|
),
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}));
|
|
|
|
if (existing) {
|
|
const releaseExists = await ctx.db
|
|
.query("packageReleases")
|
|
.withIndex("by_package_version", (q) =>
|
|
q.eq("packageId", existing._id).eq("version", args.version),
|
|
)
|
|
.unique();
|
|
if (releaseExists) {
|
|
if (
|
|
args.allowExistingRelease &&
|
|
!releaseExists.softDeletedAt &&
|
|
releaseExists.integritySha256 === args.integritySha256
|
|
) {
|
|
return {
|
|
ok: true as const,
|
|
packageId: existing._id,
|
|
releaseId: releaseExists._id,
|
|
};
|
|
}
|
|
throw new ConvexError(
|
|
`Version ${nextVersionLabel} already exists. Increment the version number and try again.`,
|
|
);
|
|
}
|
|
}
|
|
const currentLatest = existing
|
|
? await resolvePackageCurrentLatestForPublish(ctx, existing)
|
|
: { exists: false, version: undefined };
|
|
const { effectiveTags, shouldPromoteLatest } = resolvePackageReleaseTagsForPublish({
|
|
family: args.family,
|
|
currentLatestExists: currentLatest.exists,
|
|
currentLatestVersion: currentLatest.version,
|
|
candidateVersion: args.version,
|
|
requestedTags: args.tags,
|
|
});
|
|
|
|
const releaseId = await ctx.db.insert("packageReleases", {
|
|
packageId: pkgId,
|
|
version: args.version,
|
|
publicationStatus,
|
|
pendingPublication: pendingPublication
|
|
? {
|
|
displayName: args.displayName,
|
|
ownerUserId: args.ownerUserId,
|
|
ownerPublisherId: args.ownerPublisherId,
|
|
family: args.family,
|
|
summary: args.summary,
|
|
icon: args.icon,
|
|
categories: args.categories,
|
|
topics: args.topics,
|
|
sourceRepo: args.sourceRepo,
|
|
runtimeId: args.runtimeId,
|
|
channel: nextChannel,
|
|
isOfficial: nextIsOfficial,
|
|
tags: effectiveTags,
|
|
}
|
|
: undefined,
|
|
changelog: args.changelog,
|
|
summary: args.summary,
|
|
icon: args.icon,
|
|
distTags: effectiveTags,
|
|
files: args.files,
|
|
integritySha256: args.integritySha256,
|
|
sha256hash: args.sha256hash,
|
|
artifactKind: args.artifactKind,
|
|
clawpackStorageId: args.clawpackStorageId,
|
|
clawpackSha256: args.clawpackSha256,
|
|
clawpackSize: args.clawpackSize,
|
|
clawpackFormat: args.clawpackFormat,
|
|
npmIntegrity: args.npmIntegrity,
|
|
npmShasum: args.npmShasum,
|
|
npmTarballName: args.npmTarballName,
|
|
npmUnpackedSize: args.npmUnpackedSize,
|
|
npmFileCount: args.npmFileCount,
|
|
extractedPackageJson: args.extractedPackageJson,
|
|
extractedPluginManifest: args.extractedPluginManifest,
|
|
normalizedBundleManifest: args.normalizedBundleManifest,
|
|
pluginManifestSummary: args.pluginManifestSummary,
|
|
clawManifestSummary: args.clawManifestSummary,
|
|
compatibility: args.compatibility,
|
|
runtimeId: args.runtimeId,
|
|
sourceRepo: args.sourceRepo,
|
|
verification: releaseVerification,
|
|
staticScan: args.staticScan,
|
|
llmAnalysis: args.llmAnalysis,
|
|
source: args.source,
|
|
createdBy: args.actorUserId,
|
|
publishActor: args.publishActor,
|
|
createdAt: now,
|
|
});
|
|
|
|
const pkg = existing ?? (await ctx.db.get(pkgId));
|
|
if (!pkg) throw new ConvexError("Package insert failed");
|
|
if (pendingPublication) {
|
|
return {
|
|
ok: true as const,
|
|
packageId: pkgId,
|
|
releaseId,
|
|
publicationStatus,
|
|
createdNewParent,
|
|
};
|
|
}
|
|
|
|
const { nextTags, cleanupAssignments } = planPackageReleaseTagReassignment(
|
|
pkg,
|
|
releaseId,
|
|
effectiveTags,
|
|
);
|
|
|
|
await ctx.db.patch(pkgId, {
|
|
displayName: args.displayName,
|
|
ownerUserId: args.ownerUserId,
|
|
ownerPublisherId: args.ownerPublisherId ?? pkg.ownerPublisherId,
|
|
family: existingIsReservation ? args.family : pkg.family,
|
|
summary: shouldPromoteLatest ? args.summary : pkg.summary,
|
|
icon: shouldPromoteLatest ? args.icon : pkg.icon,
|
|
categories: shouldPromoteLatest ? args.categories : pkg.categories,
|
|
topics: shouldPromoteLatest ? args.topics : pkg.topics,
|
|
...(shouldPromoteLatest
|
|
? {
|
|
inferredCategories: undefined,
|
|
inferredTopics: undefined,
|
|
inferredFromReleaseId: undefined,
|
|
inferredCategoryConfidence: undefined,
|
|
inferredTopicConfidence: undefined,
|
|
inferredClassifierVersion: undefined,
|
|
inferredTopicClassifierVersion: undefined,
|
|
inferredInputHash: undefined,
|
|
inferredTopicInputHash: undefined,
|
|
inferredAt: undefined,
|
|
}
|
|
: {}),
|
|
sourceRepo: args.sourceRepo,
|
|
runtimeId: shouldPromoteLatest ? args.runtimeId : pkg.runtimeId,
|
|
channel: nextChannel,
|
|
isOfficial: nextIsOfficial,
|
|
latestReleaseId: shouldPromoteLatest ? releaseId : pkg.latestReleaseId,
|
|
latestVersionSummary: shouldPromoteLatest
|
|
? {
|
|
version: args.version,
|
|
createdAt: now,
|
|
changelog: args.changelog,
|
|
icon: args.icon,
|
|
compatibility: args.compatibility,
|
|
verification: releaseVerification,
|
|
artifact: packageArtifactSummary(args),
|
|
}
|
|
: pkg.latestVersionSummary,
|
|
tags: nextTags,
|
|
compatibility: shouldPromoteLatest ? args.compatibility : pkg.compatibility,
|
|
verification: shouldPromoteLatest ? releaseVerification : pkg.verification,
|
|
scanStatus: shouldPromoteLatest ? releaseVerification?.scanStatus : pkg.scanStatus,
|
|
stats: { ...pkg.stats, versions: (pkg.stats?.versions ?? 0) + 1 },
|
|
updatedAt: now,
|
|
});
|
|
await schedulePackageReleaseTagCleanup(ctx, pkgId, cleanupAssignments);
|
|
|
|
return {
|
|
ok: true as const,
|
|
packageId: pkgId,
|
|
releaseId,
|
|
};
|
|
},
|
|
});
|
|
function isReleaseActive(
|
|
release: Doc<"packageReleases"> | null | undefined,
|
|
): release is Doc<"packageReleases"> {
|
|
return Boolean(release && !release.softDeletedAt);
|
|
}
|
|
|
|
async function recordMaliciousPluginReleaseFinding(
|
|
ctx: Pick<MutationCtx, "scheduler">,
|
|
pkg: Doc<"packages">,
|
|
release: Doc<"packageReleases">,
|
|
trigger: string,
|
|
) {
|
|
const artifactSha256 = getPackageReleaseArtifactSha256(release);
|
|
await ctx.scheduler.runAfter(0, internal.users.recordMaliciousArtifactFindingInternal, {
|
|
ownerUserId: release.createdBy,
|
|
artifactKind: "plugin",
|
|
artifactName: pkg.normalizedName,
|
|
version: release.version,
|
|
trigger,
|
|
...(release.llmAnalysis?.summary ? { findingSummary: release.llmAnalysis.summary } : {}),
|
|
...(artifactSha256 ? { sha256hash: artifactSha256 } : {}),
|
|
});
|
|
}
|
|
|
|
async function quarantineMaliciousNonLatestPackageRelease(
|
|
ctx: Pick<MutationCtx, "db"> & Partial<Pick<MutationCtx, "scheduler">>,
|
|
pkg: Doc<"packages">,
|
|
release: Doc<"packageReleases">,
|
|
trigger: string,
|
|
) {
|
|
const now = Date.now();
|
|
const maliciousVerification = release.verification
|
|
? { ...release.verification, scanStatus: "malicious" as const }
|
|
: release.verification;
|
|
await ctx.db.patch(release._id, {
|
|
verification: maliciousVerification,
|
|
softDeletedAt: now,
|
|
});
|
|
|
|
const nextTags = Object.fromEntries(
|
|
Object.entries(pkg.tags ?? {}).filter(([, releaseId]) => releaseId !== release._id),
|
|
) as Doc<"packages">["tags"];
|
|
if (Object.keys(nextTags).length !== Object.keys(pkg.tags ?? {}).length) {
|
|
const packagePatch: Partial<Doc<"packages">> = {
|
|
tags: nextTags,
|
|
updatedAt: now,
|
|
};
|
|
const nextPackage: Doc<"packages"> = { ...pkg, ...packagePatch };
|
|
await ctx.db.patch(pkg._id, packagePatch);
|
|
const owner = await getOwnerPublisher(ctx, {
|
|
ownerPublisherId: pkg.ownerPublisherId,
|
|
ownerUserId: pkg.ownerUserId,
|
|
});
|
|
await upsertPackageSearchDigest(ctx, {
|
|
...extractPackageDigestFields(nextPackage),
|
|
ownerHandle: owner?.handle ?? "",
|
|
ownerKind: owner?.kind,
|
|
});
|
|
}
|
|
|
|
if (ctx.scheduler) {
|
|
await recordMaliciousPluginReleaseFinding(
|
|
ctx as Pick<MutationCtx, "scheduler">,
|
|
pkg,
|
|
release,
|
|
trigger,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function quarantineMaliciousLatestPackageRelease(
|
|
ctx: Pick<MutationCtx, "db"> & Partial<Pick<MutationCtx, "scheduler">>,
|
|
pkg: Doc<"packages">,
|
|
release: Doc<"packageReleases">,
|
|
trigger: string,
|
|
) {
|
|
const now = Date.now();
|
|
const maliciousVerification = release.verification
|
|
? { ...release.verification, scanStatus: "malicious" as const }
|
|
: release.verification;
|
|
const quarantinedRelease = {
|
|
...release,
|
|
verification: maliciousVerification,
|
|
softDeletedAt: now,
|
|
} as Doc<"packageReleases">;
|
|
|
|
await ctx.db.patch(release._id, {
|
|
verification: maliciousVerification,
|
|
softDeletedAt: now,
|
|
});
|
|
|
|
const releases = await ctx.db
|
|
.query("packageReleases")
|
|
.withIndex("by_package", (q) => q.eq("packageId", pkg._id))
|
|
.collect();
|
|
const activeNonMaliciousReleases = releases
|
|
.map((candidate) => (candidate._id === release._id ? quarantinedRelease : candidate))
|
|
.filter(
|
|
(candidate) =>
|
|
!candidate.softDeletedAt && resolvePackageReleaseScanStatus(candidate) !== "malicious",
|
|
);
|
|
const nextLatest = getPreferredRestoredPackageRelease(pkg.family, activeNonMaliciousReleases);
|
|
const nextTags = rebuildPackageTagsFromActiveReleases(activeNonMaliciousReleases);
|
|
if (nextLatest) {
|
|
nextTags.latest = nextLatest._id;
|
|
if (!(nextLatest.distTags ?? []).includes("latest")) {
|
|
await ctx.db.patch(nextLatest._id, {
|
|
distTags: [...(nextLatest.distTags ?? []), "latest"],
|
|
});
|
|
}
|
|
}
|
|
|
|
const restoredRuntimeId = packageRuntimeIdFromRelease(nextLatest);
|
|
const restoredSourceRepo = packageSourceRepoFromRelease(nextLatest);
|
|
const packagePatch: Partial<Doc<"packages">> = {
|
|
tags: nextTags,
|
|
latestReleaseId: nextLatest?._id,
|
|
latestVersionSummary: packageLatestSummaryFromRelease(nextLatest),
|
|
summary: nextLatest?.summary,
|
|
icon: nextLatest?.icon,
|
|
sourceRepo: restoredSourceRepo,
|
|
runtimeId: restoredRuntimeId,
|
|
compatibility: nextLatest?.compatibility,
|
|
verification: nextLatest?.verification,
|
|
scanStatus: nextLatest ? resolvePackageReleaseScanStatus(nextLatest) : "malicious",
|
|
updatedAt: now,
|
|
};
|
|
const nextPackage: Doc<"packages"> = { ...pkg, ...packagePatch };
|
|
await ctx.db.patch(pkg._id, packagePatch);
|
|
const owner = await getOwnerPublisher(ctx, {
|
|
ownerPublisherId: pkg.ownerPublisherId,
|
|
ownerUserId: pkg.ownerUserId,
|
|
});
|
|
await upsertPackageSearchDigest(ctx, {
|
|
...extractPackageDigestFields(nextPackage),
|
|
ownerHandle: owner?.handle ?? "",
|
|
ownerKind: owner?.kind,
|
|
});
|
|
|
|
if (ctx.scheduler) {
|
|
await recordMaliciousPluginReleaseFinding(
|
|
ctx as Pick<MutationCtx, "scheduler">,
|
|
pkg,
|
|
release,
|
|
trigger,
|
|
);
|
|
}
|
|
}
|
|
|
|
type SyncLatestPackageVerificationOptions = {
|
|
quarantineMaliciousLatest?: boolean;
|
|
maliciousTrigger?: string;
|
|
};
|
|
|
|
async function syncLatestPackageVerification(
|
|
ctx: Pick<MutationCtx, "db"> & Partial<Pick<MutationCtx, "scheduler">>,
|
|
release: Doc<"packageReleases">,
|
|
options: SyncLatestPackageVerificationOptions = {},
|
|
) {
|
|
const pkg = await ctx.db.get(release.packageId);
|
|
const scanStatus = resolvePackageReleaseScanStatus(release);
|
|
if (!pkg) return;
|
|
|
|
if (scanStatus === "malicious" && options.quarantineMaliciousLatest) {
|
|
if (pkg.latestReleaseId !== release._id) {
|
|
await quarantineMaliciousNonLatestPackageRelease(
|
|
ctx,
|
|
pkg,
|
|
release,
|
|
options.maliciousTrigger ?? "malicious.llm_malicious",
|
|
);
|
|
return;
|
|
}
|
|
await quarantineMaliciousLatestPackageRelease(
|
|
ctx,
|
|
pkg,
|
|
release,
|
|
options.maliciousTrigger ?? "malicious.llm_malicious",
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pkg.latestReleaseId !== release._id) return;
|
|
|
|
const nextVerification = pkg.verification
|
|
? {
|
|
...pkg.verification,
|
|
scanStatus,
|
|
}
|
|
: pkg.latestVersionSummary?.verification
|
|
? {
|
|
...pkg.latestVersionSummary.verification,
|
|
scanStatus,
|
|
}
|
|
: undefined;
|
|
const nextLatestVersionSummary = pkg.latestVersionSummary
|
|
? {
|
|
...pkg.latestVersionSummary,
|
|
verification: nextVerification,
|
|
}
|
|
: pkg.latestVersionSummary;
|
|
const nextPackage: Doc<"packages"> = {
|
|
...pkg,
|
|
verification: nextVerification,
|
|
scanStatus,
|
|
latestVersionSummary: nextLatestVersionSummary,
|
|
};
|
|
|
|
await ctx.db.patch(pkg._id, {
|
|
verification: nextVerification,
|
|
scanStatus,
|
|
latestVersionSummary: nextLatestVersionSummary,
|
|
});
|
|
const owner = await getOwnerPublisher(ctx, {
|
|
ownerPublisherId: pkg.ownerPublisherId,
|
|
ownerUserId: pkg.ownerUserId,
|
|
});
|
|
await upsertPackageSearchDigest(ctx, {
|
|
...extractPackageDigestFields(nextPackage),
|
|
ownerHandle: owner?.handle ?? "",
|
|
ownerKind: owner?.kind,
|
|
});
|
|
}
|
|
|
|
export const updateReleaseScanResultsInternal = internalMutation({
|
|
args: {
|
|
releaseId: v.id("packageReleases"),
|
|
vtAnalysis: v.optional(vtAnalysisValidator),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const release = await ctx.db.get(args.releaseId);
|
|
if (!release || release.softDeletedAt) return;
|
|
|
|
const patch: Partial<Doc<"packageReleases">> = {};
|
|
if (args.vtAnalysis !== undefined) {
|
|
patch.vtAnalysis = args.vtAnalysis;
|
|
}
|
|
if (Object.keys(patch).length > 0) {
|
|
await ctx.db.patch(args.releaseId, patch);
|
|
}
|
|
},
|
|
});
|
|
|
|
export const updateReleaseSkillSpectorAnalysisInternal = internalMutation({
|
|
args: {
|
|
releaseId: v.id("packageReleases"),
|
|
skillSpectorAnalysis: v.optional(skillSpectorAnalysisValidator),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const release = await ctx.db.get(args.releaseId);
|
|
if (!isReleaseActive(release)) return;
|
|
await ctx.db.patch(args.releaseId, {
|
|
skillSpectorAnalysis: args.skillSpectorAnalysis,
|
|
});
|
|
},
|
|
});
|
|
|
|
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()),
|
|
agenticRiskFindings: v.optional(v.array(llmAgenticRiskFindingValidator)),
|
|
riskSummary: v.optional(
|
|
v.object({
|
|
abnormal_behavior_control: llmRiskSummaryBucketValidator,
|
|
permission_boundary: llmRiskSummaryBucketValidator,
|
|
sensitive_data_protection: llmRiskSummaryBucketValidator,
|
|
}),
|
|
),
|
|
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 });
|
|
const updatedRelease = {
|
|
...release,
|
|
llmAnalysis: args.llmAnalysis,
|
|
} as Doc<"packageReleases">;
|
|
const llmVerdict = (args.llmAnalysis.verdict ?? args.llmAnalysis.status).trim().toLowerCase();
|
|
await syncLatestPackageVerification(ctx, updatedRelease, {
|
|
quarantineMaliciousLatest: llmVerdict === "malicious",
|
|
maliciousTrigger: "malicious.llm_malicious",
|
|
});
|
|
},
|
|
});
|
|
|
|
export const backfillLatestPackageScanStatusInternal = internalMutation({
|
|
args: {
|
|
cursor: v.optional(v.string()),
|
|
batchSize: v.optional(v.number()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const batchSize = Math.max(10, Math.min(args.batchSize ?? 100, 200));
|
|
const { page, continueCursor, isDone } = await ctx.db
|
|
.query("packages")
|
|
.paginate({ cursor: args.cursor ?? null, numItems: batchSize });
|
|
|
|
let patched = 0;
|
|
for (const pkg of page) {
|
|
if (!pkg.latestReleaseId) continue;
|
|
const release = await ctx.db.get(pkg.latestReleaseId);
|
|
if (!isReleaseActive(release)) continue;
|
|
|
|
const scanStatus = resolvePackageReleaseScanStatus(release);
|
|
const releaseVerification = release.verification
|
|
? { ...release.verification, scanStatus }
|
|
: release.verification;
|
|
if (release.verification?.scanStatus !== releaseVerification?.scanStatus) {
|
|
await ctx.db.patch(release._id, { verification: releaseVerification });
|
|
}
|
|
|
|
const nextVerification = pkg.verification
|
|
? { ...pkg.verification, scanStatus }
|
|
: pkg.latestVersionSummary?.verification
|
|
? { ...pkg.latestVersionSummary.verification, scanStatus }
|
|
: undefined;
|
|
const nextLatestVersionSummary = pkg.latestVersionSummary
|
|
? {
|
|
...pkg.latestVersionSummary,
|
|
verification: nextVerification,
|
|
}
|
|
: pkg.latestVersionSummary;
|
|
const nextPackage: Doc<"packages"> = {
|
|
...pkg,
|
|
verification: nextVerification,
|
|
scanStatus,
|
|
latestVersionSummary: nextLatestVersionSummary,
|
|
};
|
|
|
|
if (
|
|
pkg.scanStatus !== scanStatus ||
|
|
pkg.verification?.scanStatus !== nextVerification?.scanStatus ||
|
|
pkg.latestVersionSummary?.verification?.scanStatus !==
|
|
nextLatestVersionSummary?.verification?.scanStatus
|
|
) {
|
|
await ctx.db.patch(pkg._id, {
|
|
verification: nextVerification,
|
|
scanStatus,
|
|
latestVersionSummary: nextLatestVersionSummary,
|
|
});
|
|
patched++;
|
|
}
|
|
const owner = await getOwnerPublisher(ctx, {
|
|
ownerPublisherId: pkg.ownerPublisherId,
|
|
ownerUserId: pkg.ownerUserId,
|
|
});
|
|
await upsertPackageSearchDigest(ctx, {
|
|
...extractPackageDigestFields(nextPackage),
|
|
ownerHandle: owner?.handle ?? "",
|
|
ownerKind: owner?.kind,
|
|
});
|
|
}
|
|
|
|
if (!isDone) {
|
|
await ctx.scheduler.runAfter(0, internal.packages.backfillLatestPackageScanStatusInternal, {
|
|
cursor: continueCursor,
|
|
batchSize: args.batchSize,
|
|
});
|
|
}
|
|
|
|
return { patched, isDone, scanned: page.length };
|
|
},
|
|
});
|
|
|
|
export const backfillLatestPackageScanStatus = action({
|
|
args: {
|
|
batchSize: v.optional(v.number()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
return await runMutationRef(
|
|
ctx,
|
|
internalRefs.packages.backfillLatestPackageScanStatusInternal,
|
|
{
|
|
batchSize: args.batchSize,
|
|
},
|
|
);
|
|
},
|
|
});
|
|
|
|
export const normalizeOfficialPublisherPackagesInternal = internalMutation({
|
|
args: {
|
|
family: v.optional(v.union(v.literal("code-plugin"), v.literal("bundle-plugin"))),
|
|
cursor: v.optional(v.string()),
|
|
batchSize: v.optional(v.number()),
|
|
dryRun: v.optional(v.boolean()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const batchSize = Math.max(10, Math.min(args.batchSize ?? 100, 200));
|
|
const dryRun = args.dryRun !== false;
|
|
const family = args.family ?? "code-plugin";
|
|
const { page, continueCursor, isDone } = await ctx.db
|
|
.query("packages")
|
|
.withIndex("by_family_updated", (q) => q.eq("family", family))
|
|
.paginate({ cursor: args.cursor ?? null, numItems: batchSize });
|
|
|
|
let matched = 0;
|
|
let patched = 0;
|
|
let skippedPrivate = 0;
|
|
for (const pkg of page) {
|
|
if (!pkg.ownerPublisherId || pkg.softDeletedAt) continue;
|
|
const ownerPublisher = await ctx.db.get(pkg.ownerPublisherId);
|
|
if (!(await isOfficialPublisher(ctx, ownerPublisher))) continue;
|
|
if (pkg.channel === "private") {
|
|
skippedPrivate++;
|
|
continue;
|
|
}
|
|
if (pkg.channel === "official" && pkg.isOfficial === true) continue;
|
|
|
|
matched++;
|
|
if (dryRun) continue;
|
|
|
|
const nextPackage = {
|
|
...pkg,
|
|
channel: "official" as const,
|
|
isOfficial: true,
|
|
};
|
|
await ctx.db.patch(pkg._id, {
|
|
channel: nextPackage.channel,
|
|
isOfficial: nextPackage.isOfficial,
|
|
});
|
|
const owner = await getOwnerPublisher(ctx, {
|
|
ownerPublisherId: nextPackage.ownerPublisherId,
|
|
ownerUserId: nextPackage.ownerUserId,
|
|
});
|
|
await upsertPackageSearchDigest(ctx, {
|
|
...extractPackageDigestFields(nextPackage),
|
|
ownerHandle: owner?.handle ?? "",
|
|
ownerKind: owner?.kind,
|
|
});
|
|
patched++;
|
|
}
|
|
|
|
return {
|
|
family,
|
|
cursor: continueCursor,
|
|
isDone,
|
|
scanned: page.length,
|
|
matched,
|
|
patched,
|
|
skippedPrivate,
|
|
dryRun,
|
|
};
|
|
},
|
|
});
|
|
|
|
export const normalizeOfficialPublisherPackages = action({
|
|
args: {
|
|
family: v.optional(v.union(v.literal("code-plugin"), v.literal("bundle-plugin"))),
|
|
cursor: v.optional(v.string()),
|
|
batchSize: v.optional(v.number()),
|
|
dryRun: v.optional(v.boolean()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const { user } = await requireUserFromAction(ctx);
|
|
assertRole(user, ["admin"]);
|
|
return await runMutationRef(
|
|
ctx,
|
|
internalRefs.packages.normalizeOfficialPublisherPackagesInternal,
|
|
{
|
|
family: args.family,
|
|
cursor: args.cursor,
|
|
batchSize: args.batchSize,
|
|
dryRun: args.dryRun,
|
|
},
|
|
);
|
|
},
|
|
});
|
|
|
|
export const updateReleaseStaticScanInternal = internalMutation({
|
|
args: {
|
|
releaseId: v.id("packageReleases"),
|
|
staticScan: 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(),
|
|
}),
|
|
},
|
|
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">> = {
|
|
staticScan: args.staticScan,
|
|
};
|
|
if (activeRelease.verification) {
|
|
const nextScanStatus = resolvePackageReleaseScanStatus({
|
|
...activeRelease,
|
|
staticScan: args.staticScan,
|
|
});
|
|
patch.verification = activeRelease.verification
|
|
? {
|
|
...activeRelease.verification,
|
|
scanStatus: nextScanStatus,
|
|
}
|
|
: activeRelease.verification;
|
|
}
|
|
|
|
await ctx.db.patch(args.releaseId, patch);
|
|
|
|
const updatedRelease = {
|
|
...activeRelease,
|
|
...patch,
|
|
} as Doc<"packageReleases">;
|
|
await syncLatestPackageVerification(ctx, updatedRelease);
|
|
},
|
|
});
|
|
|
|
export const scanPackageReleaseStaticallyInternal = internalAction({
|
|
args: {
|
|
releaseId: v.id("packageReleases"),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const release = await runQueryRef<Doc<"packageReleases"> | null>(
|
|
ctx,
|
|
internalRefs.packages.getReleaseByIdInternal,
|
|
{ releaseId: args.releaseId },
|
|
);
|
|
if (!release || release.softDeletedAt) {
|
|
return { ok: true as const, skipped: "missing_release" as const };
|
|
}
|
|
const activeRelease = release;
|
|
|
|
const pkg = await runQueryRef<Doc<"packages"> | null>(
|
|
ctx,
|
|
internalRefs.packages.getPackageByIdInternal,
|
|
{ packageId: activeRelease.packageId },
|
|
);
|
|
if (!pkg || pkg.softDeletedAt || pkg.family === "skill") {
|
|
return { ok: true as const, skipped: "missing_package" as const };
|
|
}
|
|
|
|
const staticScan = await runStaticPublishScan(ctx, {
|
|
slug: pkg.name,
|
|
displayName: pkg.displayName,
|
|
summary: pkg.summary,
|
|
metadata: {
|
|
packageJson: activeRelease.extractedPackageJson,
|
|
pluginManifest: activeRelease.extractedPluginManifest,
|
|
bundleManifest: activeRelease.normalizedBundleManifest,
|
|
source: activeRelease.source,
|
|
},
|
|
files: activeRelease.files,
|
|
});
|
|
|
|
await runMutationRef(ctx, internalRefs.packages.updateReleaseStaticScanInternal, {
|
|
releaseId: args.releaseId,
|
|
staticScan,
|
|
});
|
|
|
|
return {
|
|
ok: true as const,
|
|
status: staticScan.status,
|
|
};
|
|
},
|
|
});
|
|
|
|
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,
|
|
prioritizeRecent: args.cursor === undefined,
|
|
},
|
|
)) as {
|
|
releases: Array<{
|
|
releaseId: Id<"packageReleases">;
|
|
needsVt: boolean;
|
|
needsLlm: boolean;
|
|
needsStatic: boolean;
|
|
}>;
|
|
nextCursor: number;
|
|
done: boolean;
|
|
};
|
|
|
|
let scheduled = args.scheduled ?? 0;
|
|
const vtEnabled = Boolean(process.env.VT_API_KEY);
|
|
for (const release of batch.releases) {
|
|
if (release.needsVt && vtEnabled) {
|
|
await runAfterRef(ctx, 0, internalRefs.vt.scanPackageReleaseWithVirusTotal, {
|
|
releaseId: release.releaseId,
|
|
});
|
|
}
|
|
if (release.needsLlm) {
|
|
await runMutationRef(ctx, internalRefs.securityScan.enqueuePackageReleaseScanInternal, {
|
|
releaseId: release.releaseId,
|
|
source: "backfill",
|
|
});
|
|
}
|
|
if (release.needsStatic) {
|
|
await runAfterRef(ctx, 0, internalRefs.packages.scanPackageReleaseStaticallyInternal, {
|
|
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,
|
|
});
|
|
},
|
|
});
|
|
|
|
export const setBatch = mutation({
|
|
args: { packageId: v.id("packages"), batch: v.optional(v.string()) },
|
|
handler: async (ctx, args) => {
|
|
const { user } = await requireUser(ctx);
|
|
assertModerator(user);
|
|
const pkg = await ctx.db.get(args.packageId);
|
|
if (!pkg || pkg.softDeletedAt || pkg.family === "skill") {
|
|
throw new ConvexError("Plugin not found");
|
|
}
|
|
const nextBatch = args.batch?.trim() || undefined;
|
|
await setPackageFeaturedForActor(ctx, user, pkg, nextBatch === "highlighted");
|
|
},
|
|
});
|
|
|
|
async function setPackageFeaturedForActor(
|
|
ctx: MutationCtx,
|
|
actor: Doc<"users">,
|
|
pkg: Doc<"packages">,
|
|
featured: boolean,
|
|
) {
|
|
const now = Date.now();
|
|
if (featured) {
|
|
await upsertPackageBadge(ctx, pkg._id, "highlighted", actor._id, now);
|
|
} else {
|
|
await removePackageBadge(ctx, pkg._id, "highlighted");
|
|
}
|
|
|
|
await ctx.db.insert("auditLogs", {
|
|
actorUserId: actor._id,
|
|
action: "package.badge.highlighted",
|
|
targetType: "package",
|
|
targetId: pkg._id,
|
|
metadata: { highlighted: featured },
|
|
createdAt: now,
|
|
});
|
|
|
|
return { ok: true as const, featured, packageId: pkg._id, name: pkg.name };
|
|
}
|
|
|
|
export const setPackageFeaturedForUserInternal = internalMutation({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
name: v.string(),
|
|
featured: v.boolean(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const actor = await ctx.db.get(args.actorUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
|
assertModerator(actor);
|
|
|
|
const pkg = await getPackageByNormalizedName(ctx, normalizePackageName(args.name));
|
|
if (!pkg || pkg.softDeletedAt || pkg.family === "skill") {
|
|
throw new ConvexError("Plugin not found");
|
|
}
|
|
|
|
return await setPackageFeaturedForActor(ctx, actor, pkg, args.featured);
|
|
},
|
|
});
|
|
|
|
export const removeBetaLatestPackageTagsInternal = internalMutation({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
names: v.array(v.string()),
|
|
reason: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const actor = await ctx.db.get(args.actorUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
|
assertAdmin(actor);
|
|
|
|
const results = [];
|
|
const now = Date.now();
|
|
for (const name of args.names) {
|
|
const normalizedName = normalizePackageName(name);
|
|
const pkg = await getPackageByNormalizedName(ctx, normalizedName);
|
|
if (!pkg || pkg.softDeletedAt || pkg.family === "skill") {
|
|
results.push({
|
|
name: normalizedName,
|
|
ok: false as const,
|
|
error: "Package not found",
|
|
});
|
|
continue;
|
|
}
|
|
const latestReleaseId = pkg.latestReleaseId ?? pkg.tags.latest;
|
|
if (!latestReleaseId) {
|
|
results.push({
|
|
name: normalizedName,
|
|
ok: true as const,
|
|
changed: false,
|
|
});
|
|
continue;
|
|
}
|
|
const latestRelease = await ctx.db.get(latestReleaseId);
|
|
if (!latestRelease || latestRelease.softDeletedAt) {
|
|
results.push({
|
|
name: normalizedName,
|
|
ok: false as const,
|
|
error: "Latest release not found",
|
|
});
|
|
continue;
|
|
}
|
|
if (!latestRelease.version.includes("-")) {
|
|
results.push({
|
|
name: normalizedName,
|
|
ok: false as const,
|
|
error: `Latest release ${latestRelease.version} is not a prerelease`,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const nextTags = { ...pkg.tags };
|
|
delete nextTags.latest;
|
|
await ctx.db.patch(pkg._id, {
|
|
latestReleaseId: undefined,
|
|
latestVersionSummary: undefined,
|
|
tags: nextTags,
|
|
updatedAt: now,
|
|
});
|
|
await ctx.db.patch(latestRelease._id, {
|
|
distTags: (latestRelease.distTags ?? []).filter((tag) => tag !== "latest"),
|
|
});
|
|
await ctx.db.insert("auditLogs", {
|
|
actorUserId: args.actorUserId,
|
|
action: "package.tags.remove_beta_latest",
|
|
targetType: "package",
|
|
targetId: pkg._id,
|
|
metadata: {
|
|
name: normalizedName,
|
|
version: latestRelease.version,
|
|
reason: args.reason || undefined,
|
|
},
|
|
createdAt: now,
|
|
});
|
|
results.push({ name: normalizedName, ok: true as const, changed: true });
|
|
}
|
|
return { ok: true as const, results };
|
|
},
|
|
});
|