feat: stage publishes behind prepublication checks (#2979)

* feat: stage publishes behind prepublication checks

* feat: email publishers when secret publish blocks
This commit is contained in:
Patrick Erichsen
2026-07-06 15:54:04 -05:00
committed by GitHub
parent e7dd2536c2
commit 07e542f7e6
26 changed files with 3162 additions and 117 deletions
+2
View File
@@ -140,6 +140,7 @@ import type * as packagePublishTokens from "../packagePublishTokens.js";
import type * as packages from "../packages.js";
import type * as promotions from "../promotions.js";
import type * as promotionsFeed from "../promotionsFeed.js";
import type * as publishAttempts from "../publishAttempts.js";
import type * as publisherAbuse from "../publisherAbuse.js";
import type * as publisherAbuseDevSeed from "../publisherAbuseDevSeed.js";
import type * as publishers from "../publishers.js";
@@ -301,6 +302,7 @@ declare const fullApi: ApiFromModules<{
packages: typeof packages;
promotions: typeof promotions;
promotionsFeed: typeof promotionsFeed;
publishAttempts: typeof publishAttempts;
publisherAbuse: typeof publisherAbuse;
publisherAbuseDevSeed: typeof publisherAbuseDevSeed;
publishers: typeof publishers;
+26
View File
@@ -11,6 +11,7 @@ import {
buildMaliciousArtifactEmail,
buildPublisherAbuseWarningEmail,
buildRestoredAccountEmail,
buildSecretBlockedPublishEmail,
type NotificationArtifact,
} from "./lib/emails";
@@ -181,6 +182,31 @@ export const sendMaliciousArtifactNotificationInternal = internalAction({
},
});
export const sendSecretPublishBlockedNotificationInternal = internalAction({
args: {
attemptId: v.id("publishAttempts"),
userId: v.id("users"),
to: v.string(),
handle: v.optional(v.string()),
artifact: notificationArtifactValidator,
version: v.optional(v.string()),
},
handler: async (_ctx, args) => {
const email = await buildSecretBlockedPublishEmail({
handle: args.handle,
artifact: args.artifact as NotificationArtifact,
version: args.version,
});
return await sendTransactionalEmail({
idempotencyKey: `secret-publish-block:${args.attemptId}:${args.userId}`,
to: args.to,
subject: email.subject,
text: email.text,
html: email.html,
});
},
});
export const sendPublisherAbuseWarningInternal = internalAction({
args: {
nominationId: v.id("publisherAbuseReviewNominations"),
+7
View File
@@ -11,6 +11,9 @@ import BlockedVersionEmail, { type BlockedVersionEmailProps } from "../../emails
import PluginInspectorFindingsEmail, {
type PluginInspectorFindingsEmailProps,
} from "../../emails/plugin-inspector-findings";
import SecretBlockedPublishEmail, {
type SecretBlockedPublishEmailProps,
} from "../../emails/secret-blocked-publish";
export async function renderAccountSuspendedEmail(props: AccountSuspendedEmailProps) {
return await renderEmail(<AccountSuspendedEmail {...props} />);
@@ -28,6 +31,10 @@ export async function renderPluginInspectorFindingsEmail(props: PluginInspectorF
return await renderEmail(<PluginInspectorFindingsEmail {...props} />);
}
export async function renderSecretBlockedPublishEmail(props: SecretBlockedPublishEmailProps) {
return await renderEmail(<SecretBlockedPublishEmail {...props} />);
}
export async function renderAdminOneOffEmail(props: AdminOneOffEmailProps) {
return await renderEmail(<AdminOneOffEmail {...props} />);
}
+22
View File
@@ -8,6 +8,7 @@ import {
buildPackageInspectorFindingsEmail,
buildPublisherAbuseWarningEmail,
buildRestoredAccountEmail,
buildSecretBlockedPublishEmail,
} from "./emails";
function expectFooterLinksUnderlined(html: string) {
@@ -254,6 +255,27 @@ describe("moderation notification email copy", () => {
expect(email.text).toContain("Increment the version number before uploading the fixed plugin.");
});
it("builds secret-blocked publish copy without raw findings", async () => {
const email = await buildSecretBlockedPublishEmail({
handle: "publisher",
artifact: { kind: "skill", name: "secret-skill" },
version: "1.0.0",
});
expect(email.subject).toBe("ClawHub blocked a skill publish");
expect(email.text).toContain("Hi publisher,");
expect(email.text).toContain("TruffleHog found a secret-looking value");
expect(email.text).toContain("Skill: secret-skill");
expect(email.text).toContain("Version: 1.0.0");
expect(email.text).toContain("Rotate the secret if it was real.");
expect(email.text).toContain("Uploaded files for this attempt were deleted");
expect(email.text).not.toContain("sk-local-e2e-redacted-secret-not-real");
expect(email.html).toContain("ClawHub blocked a skill publish");
expect(email.html).toContain("TruffleHog");
expect(email.html).not.toContain("Repeated malicious rejections");
expect(email.html).not.toContain("appeal this decision");
});
it("builds plugin inspector warning copy with local validation guidance", async () => {
const email = await buildPackageInspectorFindingsEmail({
handle: "octocat",
+55
View File
@@ -88,6 +88,12 @@ export type MaliciousArtifactEmailArgs = {
findingSummary?: string;
};
export type SecretBlockedPublishEmailArgs = {
handle?: string;
artifact: NotificationArtifact;
version?: string;
};
export type PackageInspectorEmailFinding = {
findingKind: "warning" | "error";
code: string;
@@ -307,6 +313,17 @@ function buildScanDownloadCommand(args: MaliciousArtifactEmailArgs) {
return `clawhub scan download ${args.artifact.name} --version ${version}${kindFlag}`;
}
async function renderSecretBlockedPublishTemplate(args: {
artifactKind: "skill" | "plugin";
artifactName: string;
version: string;
preheader: string;
}) {
const { renderSecretBlockedPublishEmail } = await import("./emailRendering");
const rendered = await renderSecretBlockedPublishEmail(args);
return rendered.html;
}
function buildPluginValidateCommand() {
return "clawhub package validate <path-to-plugin>";
}
@@ -478,6 +495,44 @@ export async function buildMaliciousArtifactEmail(args: MaliciousArtifactEmailAr
};
}
export async function buildSecretBlockedPublishEmail(args: SecretBlockedPublishEmailArgs) {
const artifactKind = args.artifact.kind === "skill" ? "skill" : "plugin";
const artifactLabelText = artifactLabel(args.artifact);
const version = args.version?.trim() || "<version>";
const subject = `ClawHub blocked a ${artifactKind} publish`;
const lines = [
greeting(args.handle),
"",
`ClawHub blocked a ${artifactKind} publish because TruffleHog found a secret-looking value in the uploaded files.`,
artifactLabelText,
`Version: ${version}`,
"",
"What changed:",
"- This version was not made public.",
"- Uploaded files for this attempt were deleted from ClawHub storage.",
"- Your account can still sign in.",
"",
"What to do next:",
"- Rotate the secret if it was real.",
`- Remove it from the ${artifactKind}.`,
"- Upload a new version.",
"",
"ClawHub Security",
];
const html = await renderSecretBlockedPublishTemplate({
artifactKind,
artifactName: args.artifact.name,
version,
preheader: `${args.artifact.name}@${version} was blocked before public listing because a secret was found.`,
});
return {
subject,
text: lines.join("\n"),
html,
};
}
export async function buildPackageInspectorFindingsEmail(args: PackageInspectorFindingsEmailArgs) {
const targetOpenClawVersion = args.findings.find(
(finding) => finding.targetOpenClawVersion,
+10
View File
@@ -109,6 +109,16 @@ export const RETENTION_POLICIES = {
skillSlugAliases: permanent("Historical slug routing aliases."),
packages: permanent("Canonical package records."),
packageReleases: permanent("Canonical package release records."),
publishAttempts: ephemeral(
"Private staged publish workflow state expires unless later retained by moderation policy.",
{
expirationField: "expiresAt",
expirationIndex: "by_expires_at",
prune: "future publishAttempts cleanup in CLAW-467 staged-publish follow-up",
retention:
"Pending/finalized attempt TTL; later secret and moderation slices refine blocked retention.",
},
),
catalogClassificationResults: derived(
"Catalog classification output can be recomputed from package and skill metadata.",
"skills/packages",
+276 -24
View File
@@ -1,6 +1,11 @@
import { describe, expect, it, vi } from "vitest";
import { MAX_PUBLISH_FILE_BYTES } from "./publishLimits";
import { publishVersionForUser, __test } from "./skillPublish";
import {
finalizeSkillPublishAttempt,
publishVersionForUser,
stageSkillPublishAttemptForUser,
__test,
} from "./skillPublish";
vi.mock("./embeddings", () => ({
generateEmbedding: vi.fn(async () => [0, 1, 2]),
@@ -50,7 +55,7 @@ description: Automation workflow for recurring reports.
},
};
await publishVersionForUser(
const result = await publishVersionForUser(
ctx as never,
"users:1" as never,
{
@@ -82,6 +87,11 @@ description: Automation workflow for recurring reports.
},
);
expect(result).toEqual({
skillId: "skills:demo",
versionId: "skillVersions:demo",
embeddingId: "skillEmbeddings:demo",
});
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
@@ -182,11 +192,12 @@ description: Org helper.
},
);
await vi.waitFor(() => {
expect(ctx.runQuery).toHaveBeenCalledWith(expect.anything(), {
slug: "org-helper",
ownerHandle: "org-demo",
});
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
expect(ctx.runQuery).toHaveBeenCalledWith(expect.anything(), {
slug: "org-helper",
ownerHandle: "org-demo",
});
} finally {
if (previousWebhookUrl === undefined) {
@@ -352,7 +363,7 @@ description: Research helper for literature reviews.
);
});
it("schedules security scan enqueue after publish instead of awaiting it inline", async () => {
it("stages publish attempts without creating a public version inline", async () => {
const storedFiles = new Map([
[
"_storage:skill",
@@ -364,14 +375,13 @@ description: Security scanner smoke fixture.
],
]);
const runMutation = vi.fn(async (_ref: unknown, args: Record<string, unknown>) => {
if ("version" in args && "embedding" in args) {
if ("skillInsertArgs" in args) {
return {
skillId: "skills:demo",
versionId: "skillVersions:demo",
embeddingId: "skillEmbeddings:demo",
attemptId: "publishAttempts:security-scanner-smoke",
status: "pending_checks",
};
}
throw new Error("publish should not await follow-up scan enqueue mutations");
throw new Error("publish should not create a public version before checks pass");
});
const scheduler = { runAfter: vi.fn() };
const ctx = {
@@ -389,7 +399,7 @@ description: Security scanner smoke fixture.
},
};
await publishVersionForUser(
const result = await stageSkillPublishAttemptForUser(
ctx as never,
"users:1" as never,
{
@@ -414,25 +424,267 @@ description: Security scanner smoke fixture.
},
);
expect(result).toEqual({
status: "pending",
attemptId: "publishAttempts:security-scanner-smoke",
slug: "security-scanner-smoke",
version: "1.0.0",
});
expect(runMutation).toHaveBeenCalledTimes(1);
expect(scheduler.runAfter).toHaveBeenCalledWith(
0,
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
versionId: "skillVersions:demo",
source: "publish",
skillInsertArgs: expect.objectContaining({
slug: "security-scanner-smoke",
version: "1.0.0",
}),
}),
);
expect(scheduler.runAfter).toHaveBeenCalledWith(
15_000,
expect(scheduler.runAfter).not.toHaveBeenCalled();
});
it("rejects duplicate staged skill versions before creating a publish attempt", async () => {
const runMutation = vi.fn(async () => {
throw new Error("duplicate publish should not create an attempt");
});
const ctx = {
runQuery: vi
.fn()
.mockResolvedValueOnce({
_id: "skills:demo",
slug: "security-scanner-smoke",
softDeletedAt: undefined,
})
.mockResolvedValueOnce({
_id: "skillVersions:demo",
skillId: "skills:demo",
version: "1.0.0",
}),
runMutation,
scheduler: { runAfter: vi.fn() },
storage: {
get: vi.fn(),
},
};
await expect(
stageSkillPublishAttemptForUser(
ctx as never,
"users:1" as never,
{
slug: "security-scanner-smoke",
displayName: "Security Scanner Smoke",
version: "1.0.0",
changelog: "Duplicate release",
files: [
{
path: "SKILL.md",
size: 90,
storageId: "_storage:skill" as never,
sha256: "a".repeat(64),
contentType: "text/markdown",
},
],
},
{
bypassGitHubAccountAge: true,
bypassQualityGate: true,
skipWebhook: true,
},
),
).rejects.toThrow("Version 1.0.0 already exists. Increment the version number and try again.");
expect(runMutation).not.toHaveBeenCalled();
expect(ctx.storage.get).not.toHaveBeenCalled();
});
it("finalizes a clean staged publish through insertVersion and then enqueues scans", async () => {
const insertArgs = {
userId: "users:1",
slug: "security-scanner-smoke",
displayName: "Security Scanner Smoke",
version: "1.0.0",
embedding: [0, 1, 2],
};
const runMutation = vi.fn(async (_ref: unknown, args: Record<string, unknown>) => {
if ("claimId" in args && !("result" in args)) {
return {
status: "claimed",
attemptId: "publishAttempts:security-scanner-smoke",
skillInsertArgs: insertArgs,
followup: {
skipWebhook: true,
slug: "security-scanner-smoke",
version: "1.0.0",
displayName: "Security Scanner Smoke",
},
};
}
if ("version" in args && "embedding" in args) {
return {
skillId: "skills:demo",
versionId: "skillVersions:demo",
embeddingId: "skillEmbeddings:demo",
};
}
if ("result" in args) {
return {
attemptId: "publishAttempts:security-scanner-smoke",
status: "finalized",
result: args.result,
};
}
return {
attemptId: "publishAttempts:security-scanner-smoke",
status: "ready_to_finalize",
};
});
const scheduler = { runAfter: vi.fn() };
const ctx = {
runMutation,
scheduler,
};
const result = await finalizeSkillPublishAttempt(
ctx as never,
"publishAttempts:security-scanner-smoke" as never,
);
expect(result).toEqual({
skillId: "skills:demo",
versionId: "skillVersions:demo",
embeddingId: "skillEmbeddings:demo",
});
expect(runMutation).toHaveBeenCalledWith(expect.anything(), insertArgs);
expect(scheduler.runAfter).toHaveBeenCalledWith(0, expect.anything(), {
versionId: "skillVersions:demo",
});
expect(scheduler.runAfter).toHaveBeenCalledWith(0, expect.anything(), {
versionId: "skillVersions:demo",
source: "publish",
});
expect(scheduler.runAfter).toHaveBeenCalledWith(15_000, expect.anything(), {
versionId: "skillVersions:demo",
source: "publish",
preserveActiveJob: true,
preserveExistingJob: true,
});
});
it("releases the staged publish finalization claim when insertion fails", async () => {
const insertArgs = {
userId: "users:1",
slug: "security-scanner-smoke",
displayName: "Security Scanner Smoke",
version: "1.0.0",
embedding: [0, 1, 2],
};
const runMutation = vi.fn(async (_ref: unknown, args: Record<string, unknown>) => {
if ("claimId" in args && !("error" in args) && !("result" in args)) {
return {
status: "claimed",
attemptId: "publishAttempts:security-scanner-smoke",
skillInsertArgs: insertArgs,
followup: {
skipWebhook: true,
slug: "security-scanner-smoke",
version: "1.0.0",
displayName: "Security Scanner Smoke",
},
};
}
if ("version" in args && "embedding" in args) {
throw new Error("transient insert failure");
}
if ("error" in args) {
return {
attemptId: "publishAttempts:security-scanner-smoke",
status: "ready_to_finalize",
};
}
throw new Error("unexpected mutation");
});
const ctx = {
runMutation,
runQuery: vi.fn(async () => null),
scheduler: { runAfter: vi.fn() },
};
await expect(
finalizeSkillPublishAttempt(ctx as never, "publishAttempts:security-scanner-smoke" as never),
).rejects.toThrow("transient insert failure");
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
versionId: "skillVersions:demo",
source: "publish",
preserveActiveJob: true,
preserveExistingJob: true,
attemptId: "publishAttempts:security-scanner-smoke",
error: "transient insert failure",
}),
);
expect(ctx.scheduler.runAfter).not.toHaveBeenCalled();
});
it("recovers an already-created public version when retrying finalization", async () => {
const insertArgs = {
userId: "users:1",
slug: "security-scanner-smoke",
displayName: "Security Scanner Smoke",
version: "1.0.0",
embedding: [0, 1, 2],
};
const recoveredResult = {
skillId: "skills:demo",
versionId: "skillVersions:demo",
embeddingId: "skillEmbeddings:demo",
};
const runMutation = vi.fn(async (_ref: unknown, args: Record<string, unknown>) => {
if ("claimId" in args && !("result" in args)) {
return {
status: "claimed",
attemptId: "publishAttempts:security-scanner-smoke",
skillInsertArgs: insertArgs,
followup: {
skipWebhook: true,
slug: "security-scanner-smoke",
version: "1.0.0",
displayName: "Security Scanner Smoke",
},
};
}
if ("version" in args && "embedding" in args) {
throw new Error(
"Version 1.0.0 already exists. Increment the version number and try again.",
);
}
if ("result" in args) {
return {
attemptId: "publishAttempts:security-scanner-smoke",
status: "finalized",
result: args.result,
};
}
throw new Error("unexpected mutation");
});
const scheduler = { runAfter: vi.fn() };
const ctx = {
runMutation,
runQuery: vi.fn(async () => recoveredResult),
scheduler,
};
const result = await finalizeSkillPublishAttempt(
ctx as never,
"publishAttempts:security-scanner-smoke" as never,
);
expect(result).toEqual(recoveredResult);
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ result: recoveredResult }),
);
expect(scheduler.runAfter).toHaveBeenCalledWith(0, expect.anything(), {
versionId: "skillVersions:demo",
});
});
it("merges github source into metadata", () => {
+382 -26
View File
@@ -54,6 +54,17 @@ const MAX_PUBLISH_SUMMARY_LENGTH = 300;
type FingerprintFile = { path: string; sha256: string };
type SafePublishFile = PublishVersionArgs["files"][number] & { path: string };
type PublishFileBlob = { file: SafePublishFile; blob: Blob };
type DeferredAiEnrichment = {
summary: {
mode: "generate" | "literal";
literal?: string;
currentSummary?: string;
};
changelog: {
source: "auto" | "user";
supplied: string;
};
};
function normalizeStoredSkillCategoryOverride(categories: readonly string[] | undefined) {
if (categories === undefined) return undefined;
@@ -70,6 +81,23 @@ export type PublishResult = {
embeddingId: Id<"skillEmbeddings">;
};
export type PendingPublishResult = {
status: "pending";
attemptId: Id<"publishAttempts">;
slug: string;
version: string;
};
type SkillPublishFollowup = {
skipWebhook?: boolean;
ownerHandle?: string;
slug: string;
version: string;
displayName: string;
};
export type SkillPublishResult = PublishResult | PendingPublishResult;
export type PublishVersionArgs = {
slug: string;
displayName: string;
@@ -112,14 +140,46 @@ export type PublishOptions = {
// publishes (including older CLIs that never pass this flag) can never
// accidentally transfer ownership.
migrateOwner?: boolean;
stagePrePublicationChecks?: boolean;
};
type InternalPublishOptions = PublishOptions;
export async function publishVersionForUser(
ctx: ActionCtx,
userId: Id<"users">,
args: PublishVersionArgs,
options: PublishOptions = {},
): Promise<PublishResult> {
): Promise<SkillPublishResult> {
return await publishVersionForUserInternal(ctx, userId, args, {
...options,
stagePrePublicationChecks:
options.stagePrePublicationChecks ?? stagedPrePublicationPublishesEnabled(),
});
}
export async function stageSkillPublishAttemptForUser(
ctx: ActionCtx,
userId: Id<"users">,
args: PublishVersionArgs,
options: PublishOptions & { stagePrePublicationChecks?: boolean } = {},
): Promise<SkillPublishResult> {
return await publishVersionForUserInternal(ctx, userId, args, {
...options,
stagePrePublicationChecks: options.stagePrePublicationChecks ?? true,
});
}
function stagedPrePublicationPublishesEnabled() {
return process.env.CLAWHUB_STAGED_PREPUBLICATION_PUBLISHES === "1";
}
async function publishVersionForUserInternal(
ctx: ActionCtx,
userId: Id<"users">,
args: PublishVersionArgs,
options: InternalPublishOptions,
): Promise<SkillPublishResult> {
const version = args.version.trim();
// Normalize first so we can look up the existing skill before deciding
// how strictly to validate. The reserved-word blocklist and length floor
@@ -145,6 +205,20 @@ export async function publishVersionForUser(
sourceOwnerPublisherId: options.sourceOwnerPublisherId,
migrateOwner: options.migrateOwner,
})) as Doc<"skills"> | null;
if (options.stagePrePublicationChecks && existingSkill && !existingSkill.softDeletedAt) {
const existingVersion = (await ctx.runQuery(
internal.skills.getVersionBySkillAndVersionInternal,
{
skillId: existingSkill._id,
version,
},
)) as Doc<"skillVersions"> | null;
if (existingVersion) {
throw new ConvexError(
`Version ${version} already exists. Increment the version number and try again.`,
);
}
}
const isNewSkill = !existingSkill;
// For new skills, enforce the full write-path rules (length, pattern,
@@ -208,14 +282,17 @@ export async function publishVersionForUser(
if (explicitSummary && explicitSummary.length > MAX_PUBLISH_SUMMARY_LENGTH) {
throw new ConvexError(`Summary must be ${MAX_PUBLISH_SUMMARY_LENGTH} characters or less`);
}
const shouldDeferAiEnrichment = options.stagePrePublicationChecks === true;
const summary =
explicitSummary ||
(await generateSkillSummary({
slug,
displayName,
readmeText,
currentSummary: summaryFromFrontmatter ?? existingSkill?.summary ?? undefined,
}));
(shouldDeferAiEnrichment
? (summaryFromFrontmatter ?? existingSkill?.summary ?? "")
: await generateSkillSummary({
slug,
displayName,
readmeText,
currentSummary: summaryFromFrontmatter ?? existingSkill?.summary ?? undefined,
}));
let qualityAssessment: QualityAssessment | null = null;
if (isNewSkill && !options.bypassQualityGate) {
@@ -310,7 +387,7 @@ export async function publishVersionForUser(
);
const changelogPromise =
changelogSource === "user"
changelogSource === "user" || shouldDeferAiEnrichment
? Promise.resolve(suppliedChangelog)
: generateChangelogForPublish(ctx, {
slug,
@@ -319,7 +396,9 @@ export async function publishVersionForUser(
files: publishFiles.map((file) => ({ path: file.path, sha256: file.sha256 })),
});
const embeddingPromise = generateEmbedding(embeddingText);
const embeddingPromise = shouldDeferAiEnrichment
? Promise.resolve([] as number[])
: generateEmbedding(embeddingText);
const [fingerprint, changelogText, embedding] = await Promise.all([
fingerprintPromise,
@@ -329,7 +408,7 @@ export async function publishVersionForUser(
}),
]);
const publishResult = (await ctx.runMutation(internal.skills.insertVersion, {
const skillInsertArgs = {
userId,
ownerPublisherId: options.ownerPublisherId,
sourceOwnerPublisherId: options.sourceOwnerPublisherId,
@@ -365,6 +444,20 @@ export async function publishVersionForUser(
summary,
staticScan,
embedding,
deferredAiEnrichment: shouldDeferAiEnrichment
? ({
summary: explicitSummary
? { mode: "literal", literal: explicitSummary }
: {
mode: "generate",
currentSummary: summaryFromFrontmatter ?? existingSkill?.summary ?? undefined,
},
changelog: {
source: changelogSource,
supplied: suppliedChangelog,
},
} satisfies DeferredAiEnrichment)
: undefined,
qualityAssessment: qualityAssessment
? {
decision: qualityAssessment.decision,
@@ -375,8 +468,245 @@ export async function publishVersionForUser(
signals: qualityAssessment.signals,
}
: undefined,
})) as PublishResult;
};
let ownerHandle = options.ownerHandle;
if (!ownerHandle && options.ownerPublisherId !== undefined) {
const targetPublisher = (await ctx.runQuery(internal.publishers.getByIdInternal, {
publisherId: options.ownerPublisherId,
})) as Doc<"publishers"> | null;
ownerHandle = targetPublisher?.handle;
}
ownerHandle ??= owner?.handle ?? owner?.displayName ?? owner?.name;
const followup = {
skipWebhook: options.skipWebhook || undefined,
ownerHandle,
slug,
version,
displayName,
};
if (!options.stagePrePublicationChecks) {
const publishResult = (await ctx.runMutation(
internal.skills.insertVersion,
skillInsertArgs,
)) as PublishResult;
await scheduleSkillPublishFollowups(ctx, publishResult, followup);
return publishResult;
}
const staged = (await ctx.runMutation(
internal.publishAttempts.createSkillPublishAttemptInternal,
{
userId,
ownerPublisherId: options.ownerPublisherId,
sourceOwnerPublisherId: options.sourceOwnerPublisherId,
slug,
displayName,
version,
idempotencyKey: buildSkillPublishAttemptIdempotencyKey({
userId,
ownerPublisherId: options.ownerPublisherId,
slug,
version,
fingerprint,
}),
artifactFingerprint: fingerprint,
files: publishFiles.map((file) => ({
...file,
path: file.path,
})),
skillInsertArgs: stripUndefinedForStoredAttempt(skillInsertArgs),
followup: {
skipWebhook: followup.skipWebhook,
ownerHandle,
},
},
)) as {
attemptId: Id<"publishAttempts">;
status: string;
result?: PublishResult;
};
if (staged.status === "finalized" && staged.result) {
return staged.result;
}
return { status: "pending", attemptId: staged.attemptId, slug, version };
}
export async function finalizeSkillPublishAttempt(
ctx: ActionCtx,
attemptId: Id<"publishAttempts">,
): Promise<PublishResult> {
const claimId = buildFinalizationClaimId();
const claim = (await ctx.runMutation(
internal.publishAttempts.claimSkillPublishAttemptForFinalizationInternal,
{ attemptId, claimId },
)) as
| {
status: "claimed";
attemptId: Id<"publishAttempts">;
skillInsertArgs: unknown;
followup: SkillPublishFollowup;
}
| {
status: "finalized";
attemptId: Id<"publishAttempts">;
result: PublishResult;
followup: SkillPublishFollowup;
};
if (claim.status === "finalized") {
return claim.result;
}
let publishResult: PublishResult;
try {
const skillInsertArgs = await prepareSkillInsertArgsForFinalization(ctx, claim.skillInsertArgs);
publishResult = (await ctx.runMutation(
internal.skills.insertVersion,
skillInsertArgs as never,
)) as PublishResult;
} catch (error) {
const existingResult = (await ctx.runQuery(
internal.publishAttempts.findSkillPublishAttemptPublicResultInternal,
{ attemptId: claim.attemptId },
)) as PublishResult | null;
if (!existingResult) {
await releaseSkillPublishAttemptFinalizationClaim(ctx, claim.attemptId, claimId, error);
throw error;
}
publishResult = existingResult;
}
try {
await scheduleSkillPublishFollowups(ctx, publishResult, claim.followup);
await ctx.runMutation(internal.publishAttempts.recordSkillPublishAttemptFinalizedInternal, {
attemptId: claim.attemptId,
claimId,
result: publishResult,
});
} catch (error) {
await releaseSkillPublishAttemptFinalizationClaim(ctx, claim.attemptId, claimId, error);
throw error;
}
return publishResult;
}
async function prepareSkillInsertArgsForFinalization(
ctx: ActionCtx,
rawInsertArgs: unknown,
): Promise<unknown> {
if (!rawInsertArgs || typeof rawInsertArgs !== "object" || Array.isArray(rawInsertArgs)) {
return rawInsertArgs;
}
const insertArgs = rawInsertArgs as Record<string, unknown>;
const deferred = insertArgs.deferredAiEnrichment as DeferredAiEnrichment | undefined;
if (!deferred) return rawInsertArgs;
const { deferredAiEnrichment: _deferredAiEnrichment, ...prepared } = insertArgs;
const files = Array.isArray(prepared.files)
? (prepared.files as Array<{
path?: unknown;
storageId?: unknown;
contentType?: unknown;
sha256?: unknown;
}>)
: [];
const readmeFile = files.find((file) => {
const path = typeof file.path === "string" ? file.path.toLowerCase() : "";
return path === "skill.md" || path === "skills.md";
});
if (!readmeFile?.storageId || typeof readmeFile.storageId !== "string") {
throw new ConvexError("SKILL.md is required");
}
const readmeText = await fetchText(ctx, readmeFile.storageId as Id<"_storage">);
const frontmatter = parseFrontmatter(readmeText);
const otherFiles: Array<{ path: string; content: string }> = [];
for (const file of files) {
if (file === readmeFile || typeof file.path !== "string") continue;
if (
!isTextFile(file.path, typeof file.contentType === "string" ? file.contentType : undefined)
) {
continue;
}
if (!file.storageId || typeof file.storageId !== "string") continue;
const content = await fetchText(ctx, file.storageId as Id<"_storage">);
otherFiles.push({ path: file.path, content });
if (otherFiles.length >= MAX_FILES_FOR_EMBEDDING) break;
}
const summary =
deferred.summary.mode === "literal"
? (deferred.summary.literal ?? "")
: await generateSkillSummary({
slug: stringField(prepared, "slug"),
displayName: stringField(prepared, "displayName"),
readmeText,
currentSummary: deferred.summary.currentSummary,
});
const changelog =
deferred.changelog.source === "user"
? deferred.changelog.supplied
: await generateChangelogForPublish(ctx, {
slug: stringField(prepared, "slug"),
version: stringField(prepared, "version"),
readmeText,
files: files
.filter(
(file): file is { path: string; sha256: string } =>
typeof file.path === "string" && typeof file.sha256 === "string",
)
.map((file) => ({ path: file.path, sha256: file.sha256 })),
});
const embeddingText = buildEmbeddingText({
frontmatter,
readme: readmeText,
otherFiles,
});
const embedding = await generateEmbedding(embeddingText).catch((error) => {
throw new ConvexError(formatEmbeddingError(error));
});
return {
...prepared,
summary,
changelog,
embedding,
};
}
function stringField(record: Record<string, unknown>, field: string) {
const value = record[field];
return typeof value === "string" ? value : "";
}
async function releaseSkillPublishAttemptFinalizationClaim(
ctx: ActionCtx,
attemptId: Id<"publishAttempts">,
claimId: string,
error: unknown,
) {
await ctx.runMutation(
internal.publishAttempts.releaseSkillPublishAttemptFinalizationClaimInternal,
{
attemptId,
claimId,
error: formatPublishAttemptFinalizationError(error),
},
);
}
async function scheduleSkillPublishFollowups(
ctx: ActionCtx,
publishResult: PublishResult,
followup: SkillPublishFollowup,
) {
await ctx.scheduler.runAfter(0, internal.vt.scanWithVirusTotal, {
versionId: publishResult.versionId,
});
@@ -402,24 +732,14 @@ export async function publishVersionForUser(
},
);
if (!options.skipWebhook && getWebhookConfig().url) {
let ownerHandle = options.ownerHandle;
if (!ownerHandle && options.ownerPublisherId !== undefined) {
const targetPublisher = (await ctx.runQuery(internal.publishers.getByIdInternal, {
publisherId: options.ownerPublisherId,
})) as Doc<"publishers"> | null;
ownerHandle = targetPublisher?.handle;
}
ownerHandle ??= owner?.handle ?? owner?.displayName ?? owner?.name;
if (!followup.skipWebhook && getWebhookConfig().url) {
void schedulePublishWebhook(ctx, {
slug,
version,
displayName,
ownerHandle,
slug: followup.slug,
version: followup.version,
displayName: followup.displayName,
ownerHandle: followup.ownerHandle,
});
}
return publishResult;
}
function mergeSourceIntoMetadata(
@@ -459,6 +779,36 @@ function mergeSourceIntoMetadata(
return Object.keys(base).length ? base : undefined;
}
function buildSkillPublishAttemptIdempotencyKey(args: {
userId: Id<"users">;
ownerPublisherId?: Id<"publishers">;
slug: string;
version: string;
fingerprint: string;
}) {
const ownerScope = args.ownerPublisherId
? `publisher:${args.ownerPublisherId}`
: `user:${args.userId}`;
return ["skill", ownerScope, args.slug, args.version, args.fingerprint].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 buildFinalizationClaimId() {
return typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}:${Math.random().toString(16).slice(2)}`;
}
async function buildPublishSourceFingerprint(files: FingerprintFile[]) {
return await hashSkillFiles(files.filter((file) => !isSkillCardPath(file.path)));
}
@@ -470,6 +820,7 @@ export const __test = {
evaluateQuality,
toStructuralFingerprint,
derivePublishFilesFromStorage,
buildSkillPublishAttemptIdempotencyKey,
};
export async function queueHighlightedWebhook(ctx: MutationCtx, skillId: Id<"skills">) {
@@ -589,6 +940,11 @@ function formatEmbeddingError(error: unknown) {
return "Embedding failed. Please try again.";
}
function formatPublishAttemptFinalizationError(error: unknown) {
if (error instanceof Error) return error.message.slice(0, 500);
return String(error).slice(0, 500);
}
async function schedulePublishWebhook(
ctx: ActionCtx,
params: { slug: string; version: string; displayName: string; ownerHandle?: string },
+265
View File
@@ -40,6 +40,7 @@ import {
getVersionByName,
getVersionSecurityByNameForViewerInternal,
insertReleaseInternal,
findPackagePublishResultInternal,
listPackageModerationQueueInternal,
listPluginExportPageInternal,
reservePackageNameInternal,
@@ -259,6 +260,18 @@ const insertReleaseInternalHandler = (
unknown
>
)._handler;
const findPackagePublishResultInternalHandler = (
findPackagePublishResultInternal as unknown as WrappedHandler<
{
name: string;
version: string;
integritySha256: string;
ownerUserId: string;
ownerPublisherId?: string;
},
{ ok: true; packageId: string; releaseId: string } | null
>
)._handler;
const reservePackageNameInternalHandler = (
reservePackageNameInternal as unknown as WrappedHandler<
{
@@ -6883,6 +6896,85 @@ describe("packages public queries", () => {
);
});
it("recovers idempotent package publish results for the same owner", async () => {
const release = makeReleaseDoc({ integritySha256: "abc123" });
const ctx = {
db: {
query: vi.fn((table: string) => {
if (table === "packages") {
return {
withIndex: vi.fn(() => ({
unique: vi.fn().mockResolvedValue(
makePackageDoc({
ownerUserId: "users:owner",
ownerPublisherId: "publishers:owner",
}),
),
})),
};
}
if (table === "packageReleases") {
return {
withIndex: vi.fn(() => ({
unique: vi.fn().mockResolvedValue(release),
})),
};
}
throw new Error(`Unexpected table ${table}`);
}),
},
};
await expect(
findPackagePublishResultInternalHandler(ctx as never, {
name: "demo-plugin",
version: "1.0.0",
integritySha256: "abc123",
ownerUserId: "users:owner",
ownerPublisherId: "publishers:owner",
}),
).resolves.toEqual({
ok: true,
packageId: "packages:demo",
releaseId: "packageReleases:demo-1",
});
});
it("does not recover idempotent package publish results for another owner", async () => {
const ctx = {
db: {
query: vi.fn((table: string) => {
if (table === "packages") {
return {
withIndex: vi.fn(() => ({
unique: vi.fn().mockResolvedValue(
makePackageDoc({
ownerUserId: "users:other",
ownerPublisherId: "publishers:other",
}),
),
})),
};
}
if (table === "packageReleases") {
throw new Error("release query should be skipped for owner mismatch");
}
throw new Error(`Unexpected table ${table}`);
}),
},
};
await expect(
findPackagePublishResultInternalHandler(ctx as never, {
name: "demo-plugin",
version: "1.0.0",
integritySha256: "abc123",
ownerUserId: "users:owner",
ownerPublisherId: "publishers:owner",
}),
).resolves.toBeNull();
});
it("clears inferred catalog state when a publisher promotes a latest package release", async () => {
const ctx = makeInsertReleaseCtx(
makePackageDoc({
@@ -8325,6 +8417,179 @@ describe("packages public queries", () => {
});
});
it("revokes trusted publish tokens when a staged publish is accepted for checks", async () => {
const previousFlag = process.env.CLAWHUB_STAGED_PREPUBLICATION_PUBLISHES;
process.env.CLAWHUB_STAGED_PREPUBLICATION_PUBLISHES = "1";
const runMutation = vi.fn(async (_ref: unknown, args: unknown) => {
if (
typeof args === "object" &&
args !== null &&
"packageInsertArgs" in args &&
"packageFollowup" in args
) {
return {
attemptId: "publishAttempts:demo",
status: "pending_checks",
};
}
return null;
});
const trustedPublisher = {
_id: "packageTrustedPublishers:1",
packageId: "packages:demo",
provider: "github-actions",
repository: "openclaw/openclaw",
repositoryId: "1",
repositoryOwner: "openclaw",
repositoryOwnerId: "2",
workflowFilename: "plugin-clawhub-release.yml",
environment: "clawhub-release",
};
const ctx = {
runQuery: vi
.fn()
.mockResolvedValueOnce({
_id: "packagePublishTokens:1",
packageId: "packages:demo",
provider: "github-actions",
repository: "openclaw/openclaw",
repositoryId: "1",
repositoryOwner: "openclaw",
repositoryOwnerId: "2",
workflowFilename: "plugin-clawhub-release.yml",
environment: "clawhub-release",
version: "1.0.0",
sha: "abc123",
ref: "refs/heads/main",
runId: "100",
runAttempt: "1",
expiresAt: Date.now() + 60_000,
})
.mockResolvedValueOnce(trustedPublisher)
.mockResolvedValueOnce(makePackageDoc({ family: "bundle-plugin" }))
.mockResolvedValueOnce(trustedPublisher)
.mockResolvedValueOnce(null),
runMutation,
runAction: vi.fn(async () => makeCleanPackageInspectorResult()),
scheduler: {
runAfter: vi.fn(),
},
storage: makePackageManifestStorage(),
};
try {
await expect(
publishPackageForTrustedPublisherInternalHandler(ctx as never, {
publishTokenId: "packagePublishTokens:1",
payload: {
name: "demo-plugin",
family: "bundle-plugin",
version: "1.0.0",
changelog: "init",
bundle: { hostTargets: ["desktop"] },
files: [packageManifestFile],
},
}),
).resolves.toMatchObject({
ok: true,
status: "pending",
attemptId: "publishAttempts:demo",
packageName: "demo-plugin",
version: "1.0.0",
});
} finally {
if (previousFlag === undefined) {
delete process.env.CLAWHUB_STAGED_PREPUBLICATION_PUBLISHES;
} else {
process.env.CLAWHUB_STAGED_PREPUBLICATION_PUBLISHES = previousFlag;
}
}
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
tokenId: "packagePublishTokens:1",
});
});
it("rejects duplicate staged package releases before creating a publish attempt", async () => {
const previousFlag = process.env.CLAWHUB_STAGED_PREPUBLICATION_PUBLISHES;
process.env.CLAWHUB_STAGED_PREPUBLICATION_PUBLISHES = "1";
const runMutation = vi.fn(async () => {
throw new Error("duplicate publish should not create an attempt");
});
const trustedPublisher = {
_id: "packageTrustedPublishers:1",
packageId: "packages:demo",
provider: "github-actions",
repository: "openclaw/openclaw",
repositoryId: "1",
repositoryOwner: "openclaw",
repositoryOwnerId: "2",
workflowFilename: "plugin-clawhub-release.yml",
environment: "clawhub-release",
};
const ctx = {
runQuery: vi
.fn()
.mockResolvedValueOnce({
_id: "packagePublishTokens:1",
packageId: "packages:demo",
provider: "github-actions",
repository: "openclaw/openclaw",
repositoryId: "1",
repositoryOwner: "openclaw",
repositoryOwnerId: "2",
workflowFilename: "plugin-clawhub-release.yml",
environment: "clawhub-release",
version: "1.0.0",
sha: "abc123",
ref: "refs/heads/main",
runId: "100",
runAttempt: "1",
expiresAt: Date.now() + 60_000,
})
.mockResolvedValueOnce(trustedPublisher)
.mockResolvedValueOnce(makePackageDoc({ family: "bundle-plugin" }))
.mockResolvedValueOnce(trustedPublisher)
.mockResolvedValueOnce(
makeReleaseDoc({
integritySha256: "different-existing-artifact",
}),
),
runMutation,
runAction: vi.fn(async () => makeCleanPackageInspectorResult()),
scheduler: {
runAfter: vi.fn(),
},
storage: makePackageManifestStorage(),
};
try {
await expect(
publishPackageForTrustedPublisherInternalHandler(ctx as never, {
publishTokenId: "packagePublishTokens:1",
payload: {
name: "demo-plugin",
family: "bundle-plugin",
version: "1.0.0",
changelog: "duplicate",
bundle: { hostTargets: ["desktop"] },
files: [packageManifestFile],
},
}),
).rejects.toThrow(
"Version 1.0.0 already exists. Increment the version number and try again.",
);
} finally {
if (previousFlag === undefined) {
delete process.env.CLAWHUB_STAGED_PREPUBLICATION_PUBLISHES;
} else {
process.env.CLAWHUB_STAGED_PREPUBLICATION_PUBLISHES = previousFlag;
}
}
expect(runMutation).not.toHaveBeenCalled();
});
it("accepts trusted publish tokens when no environment is pinned", async () => {
const runMutation = vi.fn(async (_ref: unknown, args: unknown) => {
if (
+413 -8
View File
@@ -469,10 +469,12 @@ const internalRefs = internal as unknown as {
scanPackageReleaseStaticallyInternal: unknown;
insertReleaseInternal: unknown;
getPackageByNameInternal: unknown;
findPackagePublishResultInternal: unknown;
getTrustedPublisherByPackageIdInternal: unknown;
getByNameForViewerInternal: unknown;
getPackageByIdInternal: unknown;
getReleaseByIdInternal: unknown;
getReleaseByPackageAndVersionInternal: unknown;
getPackageReleaseScanBackfillBatchInternal: unknown;
listVersionsForViewerInternal: unknown;
getVersionByNameForViewerInternal: unknown;
@@ -507,6 +509,12 @@ const internalRefs = internal as unknown as {
getByIdInternal: unknown;
resolvePublishTargetForUserInternal: unknown;
};
publishAttempts: {
createPackagePublishAttemptInternal: unknown;
claimPackagePublishAttemptForFinalizationInternal: unknown;
releasePackagePublishAttemptFinalizationClaimInternal: unknown;
recordPackagePublishAttemptFinalizedInternal: unknown;
};
securityScan: {
enqueuePackageReleaseScanInternal: unknown;
};
@@ -630,6 +638,9 @@ type PackagePublishAuthContext =
publishToken: Doc<"packagePublishTokens">;
};
type PackageTrustedPublisherDoc = Doc<"packageTrustedPublishers">;
type PackagePublishOptions = {
stagePrePublicationChecks?: boolean;
};
type PackageDoc = Doc<"packages">;
type PublicPackageListItem = {
name: string;
@@ -4301,6 +4312,31 @@ export const getPackageByNameInternal = internalQuery({
},
});
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 (!release || release.softDeletedAt || 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);
@@ -7331,6 +7367,7 @@ async function publishPackageImpl(
Pick<ActionCtx, "storage" | "scheduler" | "runAction">,
auth: PackagePublishAuthContext,
rawPayload: unknown,
options: PackagePublishOptions = {},
) {
const payload = parseArk<ServerPackagePublishRequest>(
ServerPackagePublishRequestSchema,
@@ -7633,11 +7670,7 @@ async function publishPackageImpl(
files: await withSkillMarkdownTextsForManifestSummary(ctx, files),
});
const publishResult = await runMutationRef<{
ok: true;
packageId: Id<"packages">;
releaseId: Id<"packageReleases">;
}>(ctx, internalRefs.packages.insertReleaseInternal, {
const packageInsertArgs = {
actorUserId,
ownerUserId,
ownerPublisherId,
@@ -7679,12 +7712,116 @@ async function publishPackageImpl(
normalizedBundleManifest: family === "bundle-plugin" ? storedBundleManifest : undefined,
pluginManifestSummary,
source: effectiveSource,
});
};
const inspectorFindings =
inspectorResult?.warnings.map((finding) =>
toPackageInspectorPublishResponseFinding(finding, inspectorResult.metadata),
) ?? [];
if (options.stagePrePublicationChecks) {
if (existingPackage) {
const existingRelease = await runQueryRef<Doc<"packageReleases"> | null>(
ctx,
internalRefs.packages.getReleaseByPackageAndVersionInternal,
{ packageId: existingPackage._id, version },
);
const canReuseExistingRelease =
packageInsertArgs.allowExistingRelease &&
existingRelease &&
!existingRelease.softDeletedAt &&
existingRelease.integritySha256 === integritySha256;
if (existingRelease && !canReuseExistingRelease) {
throw new ConvexError(
`Version ${version} already exists. Increment the version number and try again.`,
);
}
}
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,
name,
displayName,
version,
idempotencyKey: buildPackagePublishAttemptIdempotencyKey({
actorUserId,
ownerPublisherId,
ownerUserId,
name,
version,
integritySha256,
}),
artifactFingerprint: integritySha256,
files,
packageInsertArgs: stripUndefinedForStoredAttempt(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,
}),
});
if (auth.kind === "github-actions") {
await runMutationRef(ctx, internalRefs.packagePublishTokens.revokeInternal, {
tokenId: auth.publishToken._id,
});
}
if (staged.status === "finalized" && staged.result) {
return inspectorFindings.length > 0 ? { ...staged.result, inspectorFindings } : staged.result;
}
return {
ok: true as const,
status: "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;
@@ -7802,6 +7939,7 @@ export const publishPackageForUserInternal = internalAction({
ctx,
{ kind: "user", actorUserId: args.actorUserId },
args.payload,
{ stagePrePublicationChecks: stagedPrePublicationPublishesEnabled() },
);
},
});
@@ -7812,7 +7950,98 @@ export const publishRelease: ReturnType<typeof action> = action({
},
handler: async (ctx, args) => {
const { userId } = await requireUserFromAction(ctx);
return await publishPackageImpl(ctx, { kind: "user", actorUserId: userId }, args.payload);
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">;
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 = await runMutationRef(
ctx,
internalRefs.packages.insertReleaseInternal,
claim.packageInsertArgs,
);
} catch (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;
},
});
@@ -7840,10 +8069,186 @@ export const publishPackageForTrustedPublisherInternal = internalAction({
"Trusted publish token no longer matches the current package trusted publisher",
);
}
return await publishPackageImpl(ctx, { kind: "github-actions", publishToken }, args.payload);
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 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"),
+261
View File
@@ -0,0 +1,261 @@
import { describe, expect, it, vi } from "vitest";
import { completePendingPublishAttemptChecksInternal } from "./publishAttempts";
const completePendingChecksHandler = (
completePendingPublishAttemptChecksInternal as unknown as {
_handler: (ctx: unknown, args: unknown) => Promise<unknown>;
}
)._handler;
describe("publishAttempts", () => {
it("lets worker completion retries reclaim expired finalization leases", async () => {
const now = Date.now();
const ctx = {
db: {
get: vi.fn(async () => ({
_id: "publishAttempts:demo",
kind: "skill",
status: "finalizing",
artifactFingerprint: "fingerprint",
finalizationClaimExpiresAt: now - 1,
})),
patch: vi.fn(),
insert: vi.fn(),
replace: vi.fn(),
delete: vi.fn(),
query: vi.fn(),
normalizeId: vi.fn(),
system: {},
},
storage: {
delete: vi.fn(),
},
};
await expect(
completePendingChecksHandler(ctx, {
attemptId: "publishAttempts:demo",
claimId: "checks:claim",
artifactFingerprint: "fingerprint",
trufflehog: { status: "clean" },
clawscan: { status: "clean" },
}),
).resolves.toEqual({
attemptId: "publishAttempts:demo",
kind: "skill",
status: "ready_to_finalize",
});
expect(ctx.db.patch).not.toHaveBeenCalled();
});
it("emails the publisher when TruffleHog blocks a staged publish", async () => {
const ctx = {
db: {
get: vi
.fn()
.mockResolvedValueOnce({
_id: "publishAttempts:demo",
kind: "skill",
status: "pending_checks",
userId: "users:publisher",
slug: "secret-skill",
version: "1.0.0",
artifactFingerprint: "fingerprint",
checkClaimId: "checks:claim",
checkClaimExpiresAt: Date.now() + 60_000,
files: [{ storageId: "_storage:secret-skill" }],
})
.mockResolvedValueOnce({
_id: "users:publisher",
handle: "publisher",
email: "publisher@example.com",
}),
patch: vi.fn(),
insert: vi.fn(),
replace: vi.fn(),
delete: vi.fn(),
query: vi.fn(),
normalizeId: vi.fn(),
system: {},
},
scheduler: {
runAfter: vi.fn(),
},
storage: {
delete: vi.fn(),
},
};
await expect(
completePendingChecksHandler(ctx, {
attemptId: "publishAttempts:demo",
claimId: "checks:claim",
artifactFingerprint: "fingerprint",
trufflehog: {
status: "blocked",
summary: "redacted TruffleHog finding",
redactedFindings: ["redacted-secret"],
},
clawscan: { status: "clean" },
}),
).resolves.toMatchObject({
attemptId: "publishAttempts:demo",
kind: "skill",
status: "blocked",
});
expect(ctx.storage.delete).toHaveBeenCalledWith("_storage:secret-skill");
expect(ctx.db.patch).toHaveBeenCalledWith(
"publishAttempts:demo",
expect.objectContaining({
status: "blocked",
files: [],
skillInsertArgs: undefined,
packageInsertArgs: undefined,
followup: undefined,
packageFollowup: undefined,
}),
);
expect(ctx.scheduler.runAfter).toHaveBeenCalledWith(0, expect.anything(), {
attemptId: "publishAttempts:demo",
userId: "users:publisher",
to: "publisher@example.com",
handle: "publisher",
artifact: { kind: "skill", name: "secret-skill" },
version: "1.0.0",
});
});
it("keeps TruffleHog-positive attempts pending when secret storage deletion fails", async () => {
const ctx = {
db: {
get: vi.fn(async () => ({
_id: "publishAttempts:demo",
kind: "skill",
status: "pending_checks",
userId: "users:publisher",
slug: "secret-skill",
version: "1.0.0",
artifactFingerprint: "fingerprint",
checkClaimId: "checks:claim",
checkClaimExpiresAt: Date.now() + 60_000,
files: [{ storageId: "_storage:secret-skill" }],
})),
patch: vi.fn(),
insert: vi.fn(),
replace: vi.fn(),
delete: vi.fn(),
query: vi.fn(),
normalizeId: vi.fn(),
system: {},
},
scheduler: {
runAfter: vi.fn(),
},
storage: {
delete: vi.fn(async () => {
throw new Error("storage unavailable");
}),
},
};
await expect(
completePendingChecksHandler(ctx, {
attemptId: "publishAttempts:demo",
claimId: "checks:claim",
artifactFingerprint: "fingerprint",
trufflehog: {
status: "blocked",
summary: "redacted TruffleHog finding",
redactedFindings: ["redacted-secret"],
},
clawscan: { status: "clean" },
}),
).rejects.toThrow("storage unavailable");
expect(ctx.db.patch).not.toHaveBeenCalled();
expect(ctx.scheduler.runAfter).not.toHaveBeenCalled();
});
it("deletes package artifacts when TruffleHog blocks a staged package publish", async () => {
const ctx = {
db: {
get: vi
.fn()
.mockResolvedValueOnce({
_id: "publishAttempts:demo-package",
kind: "package",
status: "pending_checks",
userId: "users:publisher",
slug: "@demo/plugin",
version: "1.0.0",
artifactFingerprint: "fingerprint",
checkClaimId: "checks:claim",
checkClaimExpiresAt: Date.now() + 60_000,
files: [{ storageId: "_storage:manifest" }, { storageId: "_storage:artifact" }],
packageInsertArgs: { clawpackStorageId: "_storage:artifact" },
})
.mockResolvedValueOnce({
_id: "users:publisher",
handle: "publisher",
email: "publisher@example.com",
}),
patch: vi.fn(),
insert: vi.fn(),
replace: vi.fn(),
delete: vi.fn(),
query: vi.fn(),
normalizeId: vi.fn(),
system: {},
},
scheduler: {
runAfter: vi.fn(),
},
storage: {
delete: vi.fn(),
},
};
await expect(
completePendingChecksHandler(ctx, {
attemptId: "publishAttempts:demo-package",
claimId: "checks:claim",
artifactFingerprint: "fingerprint",
trufflehog: {
status: "blocked",
summary: "redacted TruffleHog finding",
redactedFindings: ["redacted-secret"],
},
clawscan: { status: "clean" },
}),
).resolves.toMatchObject({
attemptId: "publishAttempts:demo-package",
kind: "package",
status: "blocked",
});
expect(ctx.storage.delete).toHaveBeenCalledTimes(2);
expect(ctx.storage.delete).toHaveBeenCalledWith("_storage:manifest");
expect(ctx.storage.delete).toHaveBeenCalledWith("_storage:artifact");
expect(ctx.db.patch).toHaveBeenCalledWith(
"publishAttempts:demo-package",
expect.objectContaining({
status: "blocked",
files: [],
skillInsertArgs: undefined,
packageInsertArgs: undefined,
followup: undefined,
packageFollowup: undefined,
}),
);
expect(ctx.scheduler.runAfter).toHaveBeenCalledWith(0, expect.anything(), {
attemptId: "publishAttempts:demo-package",
userId: "users:publisher",
to: "publisher@example.com",
handle: "publisher",
artifact: { kind: "plugin", name: "@demo/plugin" },
version: "1.0.0",
});
});
});
+894
View File
@@ -0,0 +1,894 @@
import { ConvexError, v } from "convex/values";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import type { MutationCtx } from "./_generated/server";
import { action, internalAction, internalMutation, internalQuery } from "./functions";
import { finalizeSkillPublishAttempt } from "./lib/skillPublish";
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
const CHECK_CLAIM_LEASE_MS = 10 * 60 * 1000;
const FINALIZATION_CLAIM_LEASE_MS = 10 * 60 * 1000;
const publishResultValidator = v.object({
skillId: v.id("skills"),
versionId: v.id("skillVersions"),
embeddingId: v.id("skillEmbeddings"),
});
const packagePublishResultValidator = v.object({
ok: v.boolean(),
packageId: v.id("packages"),
releaseId: v.id("packageReleases"),
});
const workerCheckResultValidator = v.object({
status: v.union(v.literal("clean"), v.literal("blocked"), v.literal("failed")),
summary: v.optional(v.string()),
redactedFindings: v.optional(v.array(v.string())),
});
export const createSkillPublishAttemptInternal = internalMutation({
args: {
userId: v.id("users"),
ownerPublisherId: v.optional(v.id("publishers")),
sourceOwnerPublisherId: v.optional(v.id("publishers")),
slug: v.string(),
displayName: v.string(),
version: v.string(),
idempotencyKey: v.string(),
artifactFingerprint: v.string(),
files: v.array(
v.object({
path: v.string(),
size: v.number(),
storageId: v.id("_storage"),
sha256: v.string(),
contentType: v.optional(v.string()),
}),
),
skillInsertArgs: v.any(),
followup: v.object({
skipWebhook: v.optional(v.boolean()),
ownerHandle: v.optional(v.string()),
}),
},
handler: async (ctx, args) => {
const existing = await findReusablePublishAttemptByIdempotencyKey(ctx, args.idempotencyKey);
if (existing) {
return {
attemptId: existing._id,
status: existing.status,
result: existing.result,
};
}
const now = Date.now();
const attemptId = await ctx.db.insert("publishAttempts", {
kind: "skill",
status: "pending_checks",
userId: args.userId,
ownerPublisherId: args.ownerPublisherId,
sourceOwnerPublisherId: args.sourceOwnerPublisherId,
slug: args.slug,
displayName: args.displayName,
version: args.version,
idempotencyKey: args.idempotencyKey,
artifactFingerprint: args.artifactFingerprint,
files: args.files,
checks: {
trufflehog: { status: "pending" },
clawscan: { status: "pending" },
},
skillInsertArgs: args.skillInsertArgs,
followup: args.followup,
createdAt: now,
updatedAt: now,
expiresAt: now + THIRTY_DAYS_MS,
});
return { attemptId, status: "pending_checks" as const, result: undefined };
},
});
async function findReusablePublishAttemptByIdempotencyKey(
ctx: MutationCtx,
idempotencyKey: string,
) {
const attempts = await ctx.db
.query("publishAttempts")
.withIndex("by_idempotency_key", (q) => q.eq("idempotencyKey", idempotencyKey))
.order("desc")
.take(10);
return attempts.find((attempt) => !isTerminalRetriableAttemptStatus(attempt.status)) ?? null;
}
function isTerminalRetriableAttemptStatus(status: string) {
return status === "blocked" || status === "failed" || status === "expired";
}
export const createPackagePublishAttemptInternal = internalMutation({
args: {
userId: v.id("users"),
ownerUserId: v.id("users"),
ownerPublisherId: v.optional(v.id("publishers")),
name: v.string(),
displayName: v.string(),
version: v.string(),
idempotencyKey: v.string(),
artifactFingerprint: v.string(),
files: v.array(
v.object({
path: v.string(),
size: v.number(),
storageId: v.id("_storage"),
sha256: v.string(),
contentType: v.optional(v.string()),
}),
),
packageInsertArgs: v.any(),
packageFollowup: v.any(),
},
handler: async (ctx, args) => {
const existing = await findReusablePublishAttemptByIdempotencyKey(ctx, args.idempotencyKey);
if (existing) {
return {
attemptId: existing._id,
status: existing.status,
result: existing.result,
};
}
const now = Date.now();
const attemptId = await ctx.db.insert("publishAttempts", {
kind: "package",
status: "pending_checks",
userId: args.userId,
ownerUserId: args.ownerUserId,
ownerPublisherId: args.ownerPublisherId,
slug: args.name,
displayName: args.displayName,
version: args.version,
idempotencyKey: args.idempotencyKey,
artifactFingerprint: args.artifactFingerprint,
files: args.files,
checks: {
trufflehog: { status: "pending" },
clawscan: { status: "pending" },
},
packageInsertArgs: args.packageInsertArgs,
packageFollowup: args.packageFollowup,
createdAt: now,
updatedAt: now,
expiresAt: now + THIRTY_DAYS_MS,
});
return { attemptId, status: "pending_checks" as const, result: undefined };
},
});
function getSecretBlockedStorageIds(attempt: {
files: Array<{ storageId: Id<"_storage"> }>;
packageInsertArgs?: unknown;
}) {
const storageIds = new Set<Id<"_storage">>(attempt.files.map((file) => file.storageId));
const packageInsertArgs = attempt.packageInsertArgs;
if (packageInsertArgs && typeof packageInsertArgs === "object") {
const clawpackStorageId = (packageInsertArgs as { clawpackStorageId?: unknown })
.clawpackStorageId;
if (typeof clawpackStorageId === "string") {
storageIds.add(clawpackStorageId as Id<"_storage">);
}
}
return [...storageIds];
}
export const recordSkillPublishAttemptChecksPassedInternal = internalMutation({
args: {
attemptId: v.id("publishAttempts"),
trufflehogSummary: v.optional(v.string()),
clawscanSummary: v.optional(v.string()),
},
handler: async (ctx, args) => {
const attempt = await requireSkillPublishAttempt(ctx, args.attemptId);
if (attempt.status === "finalized") {
return { attemptId: attempt._id, status: attempt.status, result: attempt.result };
}
if (attempt.status !== "pending_checks" && attempt.status !== "ready_to_finalize") {
throw new ConvexError(`Publish attempt is ${attempt.status}, not pending checks.`);
}
const now = Date.now();
await ctx.db.patch(attempt._id, {
status: "ready_to_finalize",
checks: {
trufflehog: {
status: "clean",
checkedAt: now,
summary: args.trufflehogSummary,
},
clawscan: {
status: "clean",
checkedAt: now,
summary: args.clawscanSummary,
},
},
updatedAt: now,
});
return { attemptId: attempt._id, status: "ready_to_finalize" as const, result: undefined };
},
});
export const completePendingPublishAttemptChecksInternal = internalMutation({
args: {
attemptId: v.id("publishAttempts"),
claimId: v.string(),
artifactFingerprint: v.string(),
trufflehog: workerCheckResultValidator,
clawscan: workerCheckResultValidator,
},
handler: async (ctx, args) => {
const attempt = await ctx.db.get(args.attemptId);
if (!attempt) throw new ConvexError("Publish attempt not found.");
if (attempt.artifactFingerprint !== args.artifactFingerprint) {
throw new ConvexError("Publish attempt artifact fingerprint does not match scanned input.");
}
if (
attempt.status === "finalizing" &&
(attempt.finalizationClaimExpiresAt ?? 0) <= Date.now()
) {
return { attemptId: attempt._id, kind: attempt.kind, status: "ready_to_finalize" as const };
}
if (attempt.status !== "pending_checks") {
return { attemptId: attempt._id, kind: attempt.kind, status: attempt.status };
}
if (attempt.checkClaimId !== args.claimId || (attempt.checkClaimExpiresAt ?? 0) <= Date.now()) {
throw new ConvexError("Publish attempt check claim is not active.");
}
const now = Date.now();
const checks = {
trufflehog: {
status: args.trufflehog.status,
checkedAt: now,
summary: args.trufflehog.summary,
redactedFindings: args.trufflehog.redactedFindings,
},
clawscan: {
status: args.clawscan.status,
checkedAt: now,
summary: args.clawscan.summary,
redactedFindings: args.clawscan.redactedFindings,
},
};
if (args.trufflehog.status === "blocked") {
await Promise.all(
getSecretBlockedStorageIds(attempt).map((storageId) => ctx.storage.delete(storageId)),
);
await ctx.db.patch(attempt._id, {
status: "blocked",
checks,
files: [],
skillInsertArgs: undefined,
packageInsertArgs: undefined,
followup: undefined,
packageFollowup: undefined,
checkClaimId: undefined,
checkClaimedAt: undefined,
checkClaimExpiresAt: undefined,
checkClaimLastError: undefined,
blockedAt: now,
updatedAt: now,
});
await scheduleSecretPublishBlockedEmail(ctx, attempt);
return { attemptId: attempt._id, kind: attempt.kind, status: "blocked" as const };
}
if (args.clawscan.status === "blocked") {
await ctx.db.patch(attempt._id, {
status: "blocked",
checks,
checkClaimId: undefined,
checkClaimedAt: undefined,
checkClaimExpiresAt: undefined,
checkClaimLastError: undefined,
blockedAt: now,
updatedAt: now,
});
return { attemptId: attempt._id, kind: attempt.kind, status: "blocked" as const };
}
if (args.trufflehog.status === "failed" || args.clawscan.status === "failed") {
await ctx.db.patch(attempt._id, {
status: "failed",
checks,
checkClaimId: undefined,
checkClaimedAt: undefined,
checkClaimExpiresAt: undefined,
checkClaimLastError: undefined,
failedAt: now,
updatedAt: now,
});
return { attemptId: attempt._id, kind: attempt.kind, status: "failed" as const };
}
await ctx.db.patch(attempt._id, {
status: "ready_to_finalize",
checks,
checkClaimId: undefined,
checkClaimedAt: undefined,
checkClaimExpiresAt: undefined,
checkClaimLastError: undefined,
updatedAt: now,
});
return { attemptId: attempt._id, kind: attempt.kind, status: "ready_to_finalize" as const };
},
});
export const claimPendingPublishAttemptChecksInternal = internalMutation({
args: {
claimId: v.string(),
attemptId: v.optional(v.id("publishAttempts")),
kind: v.optional(v.union(v.literal("skill"), v.literal("package"))),
slug: v.optional(v.string()),
version: v.optional(v.string()),
},
handler: async (ctx, args) => {
const now = Date.now();
const attempt = args.attemptId
? await ctx.db.get(args.attemptId)
: (
await ctx.db
.query("publishAttempts")
.withIndex("by_status_and_created", (q) => q.eq("status", "pending_checks"))
.order("asc")
.take(25)
).find((candidate) => {
if ((candidate.checkClaimExpiresAt ?? 0) > now) return false;
if (args.kind && candidate.kind !== args.kind) return false;
if (args.slug && candidate.slug !== args.slug) return false;
if (args.version && candidate.version !== args.version) return false;
return true;
});
if (!attempt) return null;
if (attempt.status !== "pending_checks") {
throw new ConvexError(`Publish attempt is ${attempt.status}, not pending checks.`);
}
if (args.kind && attempt.kind !== args.kind) {
throw new ConvexError("Publish attempt kind does not match worker claim.");
}
if (args.slug && attempt.slug !== args.slug) {
throw new ConvexError("Publish attempt slug does not match worker claim.");
}
if (args.version && attempt.version !== args.version) {
throw new ConvexError("Publish attempt version does not match worker claim.");
}
if ((attempt.checkClaimExpiresAt ?? 0) > now && attempt.checkClaimId !== args.claimId) {
throw new ConvexError("Publish attempt checks are already claimed.");
}
const checkClaimExpiresAt = now + CHECK_CLAIM_LEASE_MS;
await ctx.db.patch(attempt._id, {
checkClaimId: args.claimId,
checkClaimedAt: now,
checkClaimExpiresAt,
checkClaimLastError: undefined,
updatedAt: now,
});
return {
attemptId: attempt._id,
claimId: args.claimId,
kind: attempt.kind,
userId: attempt.userId,
ownerUserId: attempt.ownerUserId,
ownerPublisherId: attempt.ownerPublisherId,
sourceOwnerPublisherId: attempt.sourceOwnerPublisherId,
slug: attempt.slug,
displayName: attempt.displayName,
version: attempt.version,
artifactFingerprint: attempt.artifactFingerprint,
files: attempt.files,
checkClaimExpiresAt,
createdAt: attempt.createdAt,
};
},
});
export const claimSkillPublishAttemptForFinalizationInternal = internalMutation({
args: {
attemptId: v.id("publishAttempts"),
claimId: v.string(),
},
handler: async (ctx, args) => {
const attempt = await requireSkillPublishAttempt(ctx, args.attemptId);
const now = Date.now();
if (attempt.status === "finalized" && attempt.result) {
return {
status: "finalized" as const,
attemptId: attempt._id,
result: attempt.result,
followup: buildSkillPublishFollowup(attempt),
};
}
if (attempt.status === "finalizing" && (attempt.finalizationClaimExpiresAt ?? 0) > now) {
throw new ConvexError("Publish attempt is already finalizing.");
}
if (attempt.status !== "ready_to_finalize" && attempt.status !== "finalizing") {
throw new ConvexError(`Publish attempt is ${attempt.status}, not ready to finalize.`);
}
await ctx.db.patch(attempt._id, {
status: "finalizing",
finalizationClaimId: args.claimId,
finalizationClaimedAt: now,
finalizationClaimExpiresAt: now + FINALIZATION_CLAIM_LEASE_MS,
finalizationLastError: undefined,
updatedAt: now,
});
return {
status: "claimed" as const,
attemptId: attempt._id,
skillInsertArgs: attempt.skillInsertArgs,
followup: buildSkillPublishFollowup(attempt),
};
},
});
export const claimPackagePublishAttemptForFinalizationInternal = internalMutation({
args: {
attemptId: v.id("publishAttempts"),
claimId: v.string(),
},
handler: async (ctx, args) => {
const attempt = await requirePackagePublishAttempt(ctx, args.attemptId);
const now = Date.now();
if (attempt.status === "finalized" && attempt.result) {
return {
status: "finalized" as const,
attemptId: attempt._id,
result: attempt.result,
packageFollowup: attempt.packageFollowup,
};
}
if (attempt.status === "finalizing" && (attempt.finalizationClaimExpiresAt ?? 0) > now) {
throw new ConvexError("Publish attempt is already finalizing.");
}
if (attempt.status !== "ready_to_finalize" && attempt.status !== "finalizing") {
throw new ConvexError(`Publish attempt is ${attempt.status}, not ready to finalize.`);
}
await ctx.db.patch(attempt._id, {
status: "finalizing",
finalizationClaimId: args.claimId,
finalizationClaimedAt: now,
finalizationClaimExpiresAt: now + FINALIZATION_CLAIM_LEASE_MS,
finalizationLastError: undefined,
updatedAt: now,
});
return {
status: "claimed" as const,
attemptId: attempt._id,
packageInsertArgs: attempt.packageInsertArgs,
packageFollowup: attempt.packageFollowup,
};
},
});
export const releaseSkillPublishAttemptFinalizationClaimInternal = internalMutation({
args: {
attemptId: v.id("publishAttempts"),
claimId: v.string(),
error: v.optional(v.string()),
},
handler: async (ctx, args) => {
const attempt = await requireSkillPublishAttempt(ctx, args.attemptId);
if (attempt.status !== "finalizing" || attempt.finalizationClaimId !== args.claimId) {
return { attemptId: attempt._id, status: attempt.status };
}
await ctx.db.patch(attempt._id, {
status: "ready_to_finalize",
finalizationClaimId: undefined,
finalizationClaimedAt: undefined,
finalizationClaimExpiresAt: undefined,
finalizationLastError: args.error,
updatedAt: Date.now(),
});
return { attemptId: attempt._id, status: "ready_to_finalize" as const };
},
});
export const releasePackagePublishAttemptFinalizationClaimInternal = internalMutation({
args: {
attemptId: v.id("publishAttempts"),
claimId: v.string(),
error: v.optional(v.string()),
},
handler: async (ctx, args) => {
const attempt = await requirePackagePublishAttempt(ctx, args.attemptId);
if (attempt.status !== "finalizing" || attempt.finalizationClaimId !== args.claimId) {
return { attemptId: attempt._id, status: attempt.status };
}
await ctx.db.patch(attempt._id, {
status: "ready_to_finalize",
finalizationClaimId: undefined,
finalizationClaimedAt: undefined,
finalizationClaimExpiresAt: undefined,
finalizationLastError: args.error,
updatedAt: Date.now(),
});
return { attemptId: attempt._id, status: "ready_to_finalize" as const };
},
});
export const recordSkillPublishAttemptFinalizedInternal = internalMutation({
args: {
attemptId: v.id("publishAttempts"),
claimId: v.string(),
result: publishResultValidator,
},
handler: async (ctx, args) => {
const attempt = await requireSkillPublishAttempt(ctx, args.attemptId);
if (attempt.status === "finalized" && attempt.result) {
return { attemptId: attempt._id, status: attempt.status, result: attempt.result };
}
const now = Date.now();
if (
attempt.status !== "finalizing" ||
attempt.finalizationClaimId !== args.claimId ||
(attempt.finalizationClaimExpiresAt ?? 0) <= now
) {
throw new ConvexError("Publish attempt finalization claim is not active.");
}
await ctx.db.patch(attempt._id, {
status: "finalized",
finalizationClaimId: undefined,
finalizationClaimedAt: undefined,
finalizationClaimExpiresAt: undefined,
finalizationLastError: undefined,
result: args.result,
finalizedAt: now,
updatedAt: now,
});
return { attemptId: attempt._id, status: "finalized" as const, result: args.result };
},
});
export const recordPackagePublishAttemptFinalizedInternal = internalMutation({
args: {
attemptId: v.id("publishAttempts"),
claimId: v.string(),
result: packagePublishResultValidator,
},
handler: async (ctx, args) => {
const attempt = await requirePackagePublishAttempt(ctx, args.attemptId);
if (attempt.status === "finalized" && attempt.result) {
return { attemptId: attempt._id, status: attempt.status, result: attempt.result };
}
const now = Date.now();
if (
attempt.status !== "finalizing" ||
attempt.finalizationClaimId !== args.claimId ||
(attempt.finalizationClaimExpiresAt ?? 0) <= now
) {
throw new ConvexError("Publish attempt finalization claim is not active.");
}
await ctx.db.patch(attempt._id, {
status: "finalized",
finalizationClaimId: undefined,
finalizationClaimedAt: undefined,
finalizationClaimExpiresAt: undefined,
finalizationLastError: undefined,
result: args.result,
finalizedAt: now,
updatedAt: now,
});
return { attemptId: attempt._id, status: "finalized" as const, result: args.result };
},
});
export const findSkillPublishAttemptPublicResultInternal = internalQuery({
args: {
attemptId: v.id("publishAttempts"),
},
handler: async (ctx, args) => {
const attempt = await requireSkillPublishAttempt(ctx, args.attemptId);
let ownerPublisherId = attempt.ownerPublisherId;
if (!ownerPublisherId) {
const personalPublishers = await ctx.db
.query("publishers")
.withIndex("by_linked_user", (q) => q.eq("linkedUserId", attempt.userId))
.take(5);
ownerPublisherId = personalPublishers.find(
(publisher) =>
publisher.kind === "user" && !publisher.deletedAt && !publisher.deactivatedAt,
)?._id;
}
const skill = ownerPublisherId
? await ctx.db
.query("skills")
.withIndex("by_owner_publisher_slug", (q) =>
q.eq("ownerPublisherId", ownerPublisherId).eq("slug", attempt.slug),
)
.unique()
: await ctx.db
.query("skills")
.withIndex("by_owner_slug", (q) =>
q.eq("ownerUserId", attempt.userId).eq("slug", attempt.slug),
)
.unique();
if (!skill) return null;
const version = await ctx.db
.query("skillVersions")
.withIndex("by_skill_version", (q) =>
q.eq("skillId", skill._id).eq("version", attempt.version),
)
.unique();
if (!version || version.softDeletedAt || version.fingerprint !== attempt.artifactFingerprint) {
return null;
}
const embedding = await ctx.db
.query("skillEmbeddings")
.withIndex("by_version", (q) => q.eq("versionId", version._id))
.unique();
if (!embedding) return null;
return {
skillId: skill._id,
versionId: version._id,
embeddingId: embedding._id,
};
},
});
export const finalizeSkillPublishAttemptInternal = internalAction({
args: {
attemptId: v.id("publishAttempts"),
},
handler: async (ctx, args) => {
return await finalizeSkillPublishAttempt(ctx, args.attemptId);
},
});
export const claimPrePublicationChecks: ReturnType<typeof action> = action({
args: {
token: v.string(),
attemptId: v.optional(v.id("publishAttempts")),
kind: v.optional(v.union(v.literal("skill"), v.literal("package"))),
slug: v.optional(v.string()),
version: v.optional(v.string()),
},
handler: async (ctx, args): Promise<unknown> => {
assertWorkerToken(args.token);
const claimId = buildCheckClaimId();
const claimed = (await ctx.runMutation(
internal.publishAttempts.claimPendingPublishAttemptChecksInternal,
{
claimId,
attemptId: args.attemptId,
kind: args.kind,
slug: args.slug,
version: args.version,
},
)) as null | {
attemptId: Id<"publishAttempts">;
claimId: string;
kind: "skill" | "package";
userId: Id<"users">;
ownerUserId?: Id<"users">;
ownerPublisherId?: Id<"publishers">;
sourceOwnerPublisherId?: Id<"publishers">;
slug: string;
displayName: string;
version: string;
artifactFingerprint: string;
files: Array<{
path: string;
size: number;
storageId: Id<"_storage">;
sha256: string;
contentType?: string;
}>;
checkClaimExpiresAt: number;
createdAt: number;
};
if (!claimed) return null;
const files = await Promise.all(
claimed.files.map(async (file) => ({
...file,
url: await ctx.storage.getUrl(file.storageId),
})),
);
return { ...claimed, files };
},
});
export const completePrePublicationChecks: ReturnType<typeof action> = action({
args: {
token: v.string(),
attemptId: v.id("publishAttempts"),
claimId: v.string(),
artifactFingerprint: v.string(),
trufflehog: workerCheckResultValidator,
clawscan: workerCheckResultValidator,
},
handler: async (ctx, args): Promise<unknown> => {
assertWorkerToken(args.token);
const completed = (await ctx.runMutation(
internal.publishAttempts.completePendingPublishAttemptChecksInternal,
{
attemptId: args.attemptId,
claimId: args.claimId,
artifactFingerprint: args.artifactFingerprint,
trufflehog: args.trufflehog,
clawscan: args.clawscan,
},
)) as {
attemptId: Id<"publishAttempts">;
kind: "skill" | "package";
status: "blocked" | "failed" | "ready_to_finalize";
};
if (completed.status !== "ready_to_finalize") return completed;
if (completed.kind === "skill") {
const result = await finalizeSkillPublishAttempt(ctx, completed.attemptId);
return { ...completed, status: "finalized" as const, result };
}
const result: unknown = await ctx.runAction(
internal.packages.finalizePackagePublishAttemptInternal,
{
attemptId: completed.attemptId,
},
);
return { ...completed, status: "finalized" as const, result };
},
});
function assertWorkerToken(token: string) {
const expected = process.env.SECURITY_SCAN_WORKER_TOKEN;
if (!expected || token !== expected) throw new ConvexError("Unauthorized");
}
function buildCheckClaimId() {
return typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}:${Math.random().toString(16).slice(2)}`;
}
async function scheduleSecretPublishBlockedEmail(
ctx: MutationCtx,
attempt: {
_id: Id<"publishAttempts">;
userId: Id<"users">;
kind: "skill" | "package";
slug: string;
version: string;
},
) {
const user = await ctx.db.get(attempt.userId);
if (!user?.email) return;
await ctx.scheduler.runAfter(
0,
internal.emailsNode.sendSecretPublishBlockedNotificationInternal,
{
attemptId: attempt._id,
userId: attempt.userId,
to: user.email,
handle: user.handle,
artifact: {
kind: attempt.kind === "skill" ? "skill" : "plugin",
name: attempt.slug,
},
version: attempt.version,
},
);
}
async function requireSkillPublishAttempt(
ctx: { db: { get: (id: Id<"publishAttempts">) => Promise<unknown> } },
attemptId: Id<"publishAttempts">,
) {
const attempt = await ctx.db.get(attemptId);
if (!attempt || typeof attempt !== "object") {
throw new ConvexError("Publish attempt not found.");
}
const typed = attempt as {
_id: Id<"publishAttempts">;
kind: "skill" | "package";
status:
| "pending_checks"
| "ready_to_finalize"
| "finalizing"
| "finalized"
| "blocked"
| "failed"
| "expired";
skillInsertArgs: unknown;
followup: { skipWebhook?: boolean; ownerHandle?: string };
userId: Id<"users">;
ownerPublisherId?: Id<"publishers">;
slug: string;
version: string;
displayName: string;
artifactFingerprint: string;
finalizationClaimId?: string;
finalizationClaimExpiresAt?: number;
result?: {
skillId: Id<"skills">;
versionId: Id<"skillVersions">;
embeddingId: Id<"skillEmbeddings">;
};
};
if (typed.kind !== "skill" || !typed.skillInsertArgs || !typed.followup) {
throw new ConvexError("Skill publish attempt not found.");
}
return typed as typeof typed & {
kind: "skill";
skillInsertArgs: unknown;
followup: { skipWebhook?: boolean; ownerHandle?: string };
};
}
async function requirePackagePublishAttempt(
ctx: { db: { get: (id: Id<"publishAttempts">) => Promise<unknown> } },
attemptId: Id<"publishAttempts">,
) {
const attempt = await ctx.db.get(attemptId);
if (!attempt || typeof attempt !== "object") {
throw new ConvexError("Publish attempt not found.");
}
const typed = attempt as {
_id: Id<"publishAttempts">;
kind: "skill" | "package";
status:
| "pending_checks"
| "ready_to_finalize"
| "finalizing"
| "finalized"
| "blocked"
| "failed"
| "expired";
packageInsertArgs?: unknown;
packageFollowup?: unknown;
finalizationClaimId?: string;
finalizationClaimExpiresAt?: number;
result?: {
ok: true;
packageId: Id<"packages">;
releaseId: Id<"packageReleases">;
};
};
if (typed.kind !== "package" || !typed.packageInsertArgs) {
throw new ConvexError("Package publish attempt not found.");
}
return typed;
}
function buildSkillPublishFollowup(attempt: {
followup: { skipWebhook?: boolean; ownerHandle?: string };
slug: string;
version: string;
displayName: string;
}) {
return {
...attempt.followup,
slug: attempt.slug,
version: attempt.version,
displayName: attempt.displayName,
};
}
+72
View File
@@ -1079,6 +1079,77 @@ const skillVersions = defineTable({
.index("by_sha256hash", ["sha256hash"])
.index("by_dep_registry_scan_status_and_created", ["depRegistryScanStatus", "createdAt"]);
const publishAttemptStatusValidator = v.union(
v.literal("pending_checks"),
v.literal("ready_to_finalize"),
v.literal("finalizing"),
v.literal("finalized"),
v.literal("blocked"),
v.literal("failed"),
v.literal("expired"),
);
const publishAttemptCheckStateValidator = v.object({
status: v.union(
v.literal("pending"),
v.literal("clean"),
v.literal("blocked"),
v.literal("failed"),
),
checkedAt: v.optional(v.number()),
summary: v.optional(v.string()),
redactedFindings: v.optional(v.array(v.string())),
});
const publishAttempts = defineTable({
kind: v.union(v.literal("skill"), v.literal("package")),
status: publishAttemptStatusValidator,
userId: v.id("users"),
ownerUserId: v.optional(v.id("users")),
ownerPublisherId: v.optional(v.id("publishers")),
sourceOwnerPublisherId: v.optional(v.id("publishers")),
slug: v.string(),
displayName: v.string(),
version: v.string(),
idempotencyKey: v.string(),
artifactFingerprint: v.string(),
files: packageFilesValidator,
checks: v.object({
trufflehog: publishAttemptCheckStateValidator,
clawscan: publishAttemptCheckStateValidator,
}),
skillInsertArgs: v.optional(v.any()),
packageInsertArgs: v.optional(v.any()),
followup: v.optional(
v.object({
skipWebhook: v.optional(v.boolean()),
ownerHandle: v.optional(v.string()),
}),
),
packageFollowup: v.optional(v.any()),
checkClaimId: v.optional(v.string()),
checkClaimedAt: v.optional(v.number()),
checkClaimExpiresAt: v.optional(v.number()),
checkClaimLastError: v.optional(v.string()),
finalizationClaimId: v.optional(v.string()),
finalizationClaimedAt: v.optional(v.number()),
finalizationClaimExpiresAt: v.optional(v.number()),
finalizationLastError: v.optional(v.string()),
result: v.optional(v.any()),
createdAt: v.number(),
updatedAt: v.number(),
expiresAt: v.number(),
finalizedAt: v.optional(v.number()),
blockedAt: v.optional(v.number()),
failedAt: v.optional(v.number()),
})
.index("by_idempotency_key", ["idempotencyKey"])
.index("by_status_and_created", ["status", "createdAt"])
.index("by_expires_at", ["expiresAt"])
.index("by_kind_status_slug_version_created", ["kind", "status", "slug", "version", "createdAt"])
.index("by_user_status_created", ["userId", "status", "createdAt"])
.index("by_owner_publisher_status_created", ["ownerPublisherId", "status", "createdAt"]);
const skillVersionFingerprints = defineTable({
skillId: v.id("skills"),
versionId: v.id("skillVersions"),
@@ -3132,6 +3203,7 @@ export default defineSchema({
packageTopicSearchDigest,
packagePluginCategorySearchDigest,
skillVersions,
publishAttempts,
skillVersionFingerprints,
skillBadges,
skillEmbeddings,
+9 -4
View File
@@ -135,9 +135,9 @@ import {
import { isPublicSkillVersionAvailableForSkill } from "./lib/skillFileAccess";
import {
fetchText,
type PublishResult,
publishVersionForUser,
queueHighlightedWebhook,
stageSkillPublishAttemptForUser,
type SkillPublishResult,
} from "./lib/skillPublish";
import { getFrontmatterValue, hashSkillFiles } from "./lib/skills";
import {
@@ -9705,7 +9705,7 @@ export const publishVersion: ReturnType<typeof action> = action({
}),
),
},
handler: async (ctx, args): Promise<PublishResult> => {
handler: async (ctx, args): Promise<SkillPublishResult> => {
if (args.acceptLicenseTerms !== true) {
throw new ConvexError("MIT-0 license terms must be accepted to publish skills");
}
@@ -9728,15 +9728,20 @@ export const publishVersion: ReturnType<typeof action> = action({
})) as { publisherId: Id<"publishers"> })
: null;
const { icon: _legacyIcon, ...publishArgs } = args;
return publishVersionForUser(ctx, userId, publishArgs, {
return stageSkillPublishAttemptForUser(ctx, userId, publishArgs, {
ownerPublisherId: target.publisherId,
ownerHandle: target.handle,
sourceOwnerPublisherId: source?.publisherId,
migrateOwner: args.migrateOwner,
stagePrePublicationChecks: stagedPrePublicationPublishesEnabled(),
});
},
});
function stagedPrePublicationPublishesEnabled() {
return process.env.CLAWHUB_STAGED_PREPUBLICATION_PUBLISHES === "1";
}
export const generateChangelogPreview = action({
args: {
slug: v.string(),
+14
View File
@@ -2,12 +2,19 @@ import { expect, type ConsoleMessage, type Page } from "@playwright/test";
import { isKnownOpenClawMediaUrl } from "./externalMedia";
const EXTERNAL_RESOURCE_DNS_ERROR = "Failed to load resource: net::ERR_NAME_NOT_RESOLVED";
const TRANSIENT_CHROMIUM_RESOURCE_ERRORS = new Set([
"Failed to load resource: net::ERR_NETWORK_CHANGED",
]);
function isIgnoredExternalResourceDnsError(message: ConsoleMessage) {
if (message.text() !== EXTERNAL_RESOURCE_DNS_ERROR) return false;
return isKnownOpenClawMediaUrl(message.location().url);
}
function isIgnoredTransientResourceError(message: ConsoleMessage) {
return TRANSIENT_CHROMIUM_RESOURCE_ERRORS.has(message.text());
}
export function trackRuntimeErrors(page: Page) {
const errors: string[] = [];
@@ -18,12 +25,19 @@ export function trackRuntimeErrors(page: Page) {
page.on("console", (message) => {
if (message.type() !== "error") return;
if (isIgnoredExternalResourceDnsError(message)) return;
if (isIgnoredTransientResourceError(message)) return;
errors.push(`console:${message.text()}`);
});
return errors;
}
// React production builds report recoverable hydration mismatches as #418 page errors.
// Keep the filter opt-in so tests still fail on unexpected hydration regressions by default.
export function withoutRecoverableReactHydrationErrors(errors: string[]) {
return errors.filter((error) => !error.includes("pageerror:Minified React error #418"));
}
export async function expectNoRuntimeErrors(page: Page, errors: string[]) {
await expect
.poll(() => errors, {
+94 -10
View File
@@ -1,6 +1,8 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, type Page, type TestInfo } from "@playwright/test";
import convexBrowser from "convex/browser";
import { api } from "../../convex/_generated/api";
import { buildPublisherProfileHref, buildSkillDetailHref } from "../../src/lib/ownerRoute";
import {
buildPluginDetailHref,
@@ -10,6 +12,8 @@ import {
import { waitForHydration } from "../helpers/runtimeErrors";
type DevPersona = "owner" | "user" | "admin" | "abusePublisher";
const WORKER_TOKEN = process.env.SECURITY_SCAN_WORKER_TOKEN ?? "local-e2e-worker-token";
const { ConvexHttpClient } = convexBrowser;
// The quality gate fingerprints line shape, so vary local-auth fixtures by slug.
const FINGERPRINT_SALT_LINES = [
@@ -90,7 +94,7 @@ function skillDetailPath(ownerHandle: string, slug: string) {
return buildSkillDetailHref(ownerHandle, slug);
}
async function publishedSkillVersionExists(
export async function publishedSkillVersionExists(
page: Page,
args: {
ownerHandle: string;
@@ -109,6 +113,53 @@ async function publishedSkillVersionExists(
return body?.version?.version === args.version;
}
function convexClient() {
const convexUrl = process.env.VITE_CONVEX_URL;
if (!convexUrl) throw new Error("VITE_CONVEX_URL is required");
return new ConvexHttpClient(convexUrl);
}
export async function completeMockPrePublicationChecks(args: {
kind: "skill" | "package";
slug: string;
version: string;
trufflehog?: "clean" | "blocked";
clawscan?: "clean" | "blocked";
}) {
const claim = (await convexClient().action(api.publishAttempts.claimPrePublicationChecks, {
token: WORKER_TOKEN,
kind: args.kind,
slug: args.slug,
version: args.version,
})) as null | {
attemptId: string;
claimId: string;
artifactFingerprint: string;
};
if (!claim) {
throw new Error(`No pending ${args.kind} publish attempt for ${args.slug}@${args.version}`);
}
return await convexClient().action(api.publishAttempts.completePrePublicationChecks, {
token: WORKER_TOKEN,
attemptId: claim.attemptId,
claimId: claim.claimId,
artifactFingerprint: claim.artifactFingerprint,
trufflehog: {
status: args.trufflehog ?? "clean",
summary:
args.trufflehog === "blocked"
? "Mock TruffleHog found a redacted secret in the local e2e fixture."
: "Mock TruffleHog found no secrets in the local e2e fixture.",
redactedFindings: args.trufflehog === "blocked" ? ["redacted-secret"] : undefined,
},
clawscan: {
status: args.clawscan ?? "clean",
summary: "Mock ClawScan completed for the local e2e fixture.",
},
});
}
function devPersonaHeaderPattern(persona: DevPersona, expectedHandle: string) {
const displayName =
persona === "owner"
@@ -246,15 +297,28 @@ function parseOwnerHandle(text: string) {
async function isNativeOwnerSelect(page: Page, selector: string) {
const ownerControl = page.locator(selector);
await ownerControl.waitFor({ state: "attached" });
return await ownerControl.evaluate((node) => node.tagName.toLowerCase() === "select");
return await ownerControl.evaluate(
(node) => node.tagName.toLowerCase() === "select" && node.checkVisibility(),
);
}
async function getSelectedOwnerHandle(page: Page, selector: string) {
const ownerControl = page.locator(selector);
const ownerControl = page.locator(selector).first();
if (await isNativeOwnerSelect(page, selector)) {
return await ownerControl.inputValue();
const value = await ownerControl.inputValue();
if (value) return value;
}
return parseOwnerHandle(await ownerControl.innerText());
const directText = await ownerControl.innerText().catch(() => "");
const directHandle = parseOwnerHandle(directText);
if (directHandle) return directHandle;
const visibleComboboxText = await page
.getByRole("combobox", { name: "Publishing as" })
.filter({ hasText: /@/ })
.first()
.innerText({ timeout: 500 })
.catch(() => "");
return parseOwnerHandle(visibleComboboxText);
}
export async function expectOwnerHandleSelected(
@@ -362,17 +426,20 @@ export async function publishSkillVersion(
versionLabel: string;
changelog: string;
versionExists?: () => Promise<boolean>;
skillMarkdown?: string;
completeChecks?: boolean;
},
) {
const skillDir = testInfo.outputPath(`${args.slug}-${args.version}`);
await mkdir(skillDir, { recursive: true });
await writeFile(
join(skillDir, "SKILL.md"),
skillMd({
slug: args.slug,
displayName: args.displayName,
versionLabel: args.versionLabel,
}),
args.skillMarkdown ??
skillMd({
slug: args.slug,
displayName: args.displayName,
versionLabel: args.versionLabel,
}),
"utf8",
);
@@ -388,11 +455,15 @@ export async function publishSkillVersion(
await expect(publishButton).toBeEnabled({ timeout: 30_000 });
publishUrl = page.url();
await publishButton.click({ timeout: 15_000 });
const pendingChecks = page.getByText("Running TruffleHog and ClawScan", { exact: false });
await expect
.poll(
async () => {
if (await hasDuplicateVersionAlert(page, args.version)) return "duplicate";
if (await versionExists()) return "published";
if (await pendingChecks.isVisible({ timeout: 500 }).catch(() => false)) {
return "pending";
}
if (!args.versionExists && detailUrlPattern.test(new URL(page.url()).pathname)) {
return "detail";
}
@@ -401,6 +472,19 @@ export async function publishSkillVersion(
{ timeout: 60_000, intervals: [500, 1_000, 2_000] },
)
.not.toBe("");
if (await pendingChecks.isVisible({ timeout: 500 }).catch(() => false)) {
if (args.completeChecks === false) {
return args.ownerHandle;
}
await completeMockPrePublicationChecks({
kind: "skill",
slug: args.slug,
version: args.version,
});
await expect
.poll(versionExists, { timeout: 60_000, intervals: [500, 1_000, 2_000] })
.toBe(true);
}
if (detailUrlPattern.test(new URL(page.url()).pathname)) break;
await page.goto(skillDetailPath(args.ownerHandle, args.slug), {
waitUntil: "domcontentloaded",
@@ -4,7 +4,12 @@ import { expect, test } from "@playwright/test";
import convexBrowser from "convex/browser";
import { api } from "../../convex/_generated/api";
import type { Id } from "../../convex/_generated/dataModel";
import { expectHealthyPage, trackRuntimeErrors, waitForHydration } from "../helpers/runtimeErrors";
import {
expectHealthyPage,
trackRuntimeErrors,
waitForHydration,
withoutRecoverableReactHydrationErrors,
} from "../helpers/runtimeErrors";
import { buildSkillDetailHref, publishSkillVersion, signInAsLocalPublisher } from "./helpers";
test.skip(
@@ -233,7 +238,7 @@ function withoutExpectedBannedSessionTeardownErrors(errors: string[]) {
"CONVEX Q(publishers:getMyProfileHandle)",
"CONVEX M(packages:applyBanToOwnedPackagesBatchInternal)",
];
return errors.filter(
return withoutRecoverableReactHydrationErrors(errors).filter(
(error) =>
error !==
"console:Failed to load resource: the server responded with a status of 503 (Service Unavailable)" &&
@@ -1,5 +1,5 @@
import { writeFile } from "node:fs/promises";
import { expect, type Page, test, type TestInfo } from "@playwright/test";
import { expect, type APIRequestContext, type Page, test, type TestInfo } from "@playwright/test";
import { strToU8, zipSync } from "fflate";
import {
expectNoFatalErrorUi,
@@ -7,7 +7,13 @@ import {
trackRuntimeErrors,
waitForHydration,
} from "../helpers/runtimeErrors";
import { buildPluginValidationHref, escapeRegExp, signInAsLocalPersona } from "./helpers";
import {
buildPluginDetailHref,
buildPluginValidationHref,
completeMockPrePublicationChecks,
escapeRegExp,
signInAsLocalPersona,
} from "./helpers";
test.skip(
process.env.VITE_ENABLE_DEV_AUTH !== "1",
@@ -162,6 +168,25 @@ async function expectValidationSectionVisible(page: Page, warningName: string) {
await expect(validationSection).toBeVisible({ timeout: 10_000 });
}
async function publicPackageVersionExists(
request: APIRequestContext,
name: string,
version: string,
) {
const siteUrl = process.env.VITE_CONVEX_SITE_URL;
if (!siteUrl) throw new Error("VITE_CONVEX_SITE_URL is required");
const url = `${siteUrl.replace(/\/$/u, "")}/api/v1/packages/${encodeURIComponent(
name,
)}/versions/${encodeURIComponent(version)}`;
const response = await request.get(url, { timeout: 2_000 }).catch(() => null);
if (!response?.ok()) return false;
const body = (await response.json().catch(() => null)) as {
package?: { name?: unknown };
version?: { version?: unknown };
} | null;
return body?.package?.name === name && body?.version?.version === version;
}
async function publishWarningPluginWithRetry(args: {
errors: string[];
page: Page;
@@ -192,9 +217,14 @@ async function publishWarningPluginWithRetry(args: {
const publishButton = args.page.getByRole("button", { name: "Publish plugin" });
await expect(publishButton).toBeEnabled({ timeout: 60_000 });
await publishButton.click({ timeout: 15_000 });
await expect(args.page.getByText("Published. Pending security checks")).toBeVisible({
await expect(args.page.getByText("Running TruffleHog and ClawScan")).toBeVisible({
timeout: 60_000,
});
await completeMockPrePublicationChecks({
kind: "package",
slug: warningName,
version: "1.0.0",
});
return { warningDisplayName, warningName };
} catch (error) {
lastError = error;
@@ -247,6 +277,57 @@ async function publishHardErrorPluginWithRetry(args: {
throw lastError;
}
test("plugin publish stays private until mocked TruffleHog and ClawScan pass", async ({
page,
request,
}, testInfo) => {
const errors = trackRuntimeErrors(page);
const suffix = Date.now().toString(36);
const name = `pw-staged-plugin-${suffix}`;
const displayName = `Playwright Staged Plugin ${suffix}`;
const version = "1.0.0";
await signInAsLocalPersona(page, "admin");
await page.goto("/plugins/publish", { waitUntil: "domcontentloaded" });
await waitForHydration(page);
await uploadPluginZip(
page,
await writePluginZip(testInfo, {
name,
displayName,
kind: "warning",
}),
);
await expect(page.locator("#pluginName")).toHaveValue(name);
await page.locator("#pluginSourceCommit").fill("abc123");
const publishButton = page.getByRole("button", { name: "Publish plugin" });
await expect(publishButton).toBeEnabled({ timeout: 60_000 });
await publishButton.click({ timeout: 15_000 });
await expect(page.getByText("Running TruffleHog and ClawScan")).toBeVisible({
timeout: 60_000,
});
await expect(await publicPackageVersionExists(request, name, version)).toBe(false);
await completeMockPrePublicationChecks({
kind: "package",
slug: name,
version,
});
await expect
.poll(() => publicPackageVersionExists(request, name, version), {
timeout: 60_000,
intervals: [500, 1_000, 2_000],
})
.toBe(true);
await page.goto(buildPluginDetailHref(name), { waitUntil: "domcontentloaded" });
await waitForHydration(page);
await expect(page.locator("h1.skill-page-title", { hasText: displayName })).toBeVisible({
timeout: 30_000,
});
await expectHealthyInspectorPage(page, errors);
});
test("plugin inspector blocks hard publish errors and publishes warning findings", async ({
page,
}, testInfo) => {
@@ -1,4 +1,4 @@
import { expect, type Page, test } from "@playwright/test";
import { expect, type APIRequestContext, type Page, test } from "@playwright/test";
import convexBrowser from "convex/browser";
import { api } from "../../convex/_generated/api";
import type { Id } from "../../convex/_generated/dataModel";
@@ -7,8 +7,16 @@ import {
expectNoRuntimeErrors,
trackRuntimeErrors,
waitForHydration,
withoutRecoverableReactHydrationErrors,
} from "../helpers/runtimeErrors";
import { expectOwnerHandleSelected, publishSkillVersion, signInAsLocalPublisher } from "./helpers";
import {
completeMockPrePublicationChecks,
expectOwnerHandleSelected,
publishedSkillVersionExists,
publishSkillVersion,
signInAsLocalPublisher,
skillMd,
} from "./helpers";
test.skip(
process.env.VITE_ENABLE_DEV_AUTH !== "1",
@@ -66,7 +74,7 @@ async function expectHealthyPublishPage(page: Page, errors: string[]) {
await expectNoFatalErrorUi(page);
await expectNoRuntimeErrors(
page,
errors.filter(
withoutRecoverableReactHydrationErrors(errors).filter(
(error) =>
!(
error.includes("Function execution timed out (maximum duration: 1s)") &&
@@ -168,6 +176,25 @@ async function waitForSkillCardEndpoint(page: Page, slug: string, markdown: stri
);
}
async function publicSkillVersionExists(
request: APIRequestContext,
args: {
ownerHandle: string;
slug: string;
version: string;
},
) {
const url = `${convexSiteUrl()}/api/v1/skills/${encodeURIComponent(args.slug)}/versions/${encodeURIComponent(
args.version,
)}?ownerHandle=${encodeURIComponent(args.ownerHandle)}`;
const response = await request.get(url, { timeout: 2_000 }).catch(() => null);
if (!response?.ok()) return false;
const body = (await response.json().catch(() => null)) as {
version?: { version?: unknown };
} | null;
return body?.version?.version === args.version;
}
async function completeScanJob(
client: ConvexHttpClientInstance,
scanJob: ClaimedScanJob,
@@ -280,6 +307,67 @@ test("publishing a skill queues scan, queues skill-card generation, and shows th
await expectHealthyPublishPage(page, errors);
});
test("mocked TruffleHog blocks a secret-positive skill upload until the secret is removed", async ({
page,
request,
}, testInfo) => {
const errors = trackRuntimeErrors(page);
const slug = `pw-secret-${Date.now().toString(36)}`;
const displayName = "Playwright Secret Block Skill";
const ownerHandle = await signInAsLocalPublisher(page, "admin");
const version = "1.0.0";
const secretMarkdown = `${skillMd({
slug,
displayName,
versionLabel: "secret-positive release",
})}
## Local secret fixture
This fake token is intentionally redacted by the mocked TruffleHog worker:
OPENAI_API_KEY=sk-local-e2e-redacted-secret-not-real
`;
await publishSkillVersion(page, testInfo, {
ownerHandle,
slug,
displayName,
version,
versionLabel: "secret-positive release",
changelog: "Secret-positive release should remain private.",
skillMarkdown: secretMarkdown,
completeChecks: false,
});
await completeMockPrePublicationChecks({
kind: "skill",
slug,
version,
trufflehog: "blocked",
});
await expect(await publicSkillVersionExists(request, { ownerHandle, slug, version })).toBe(false);
await expect(await publishedSkillVersionExists(page, { ownerHandle, slug, version })).toBe(false);
await page.goto("/skills/publish", { waitUntil: "domcontentloaded" });
await publishSkillVersion(page, testInfo, {
ownerHandle,
slug,
displayName,
version,
versionLabel: "clean retry release",
changelog: "Clean retry after removing the secret.",
skillMarkdown: skillMd({
slug,
displayName,
versionLabel: "clean retry release",
}),
});
await expectCurrentVersion(page, version);
await expectHealthyPublishPage(page, errors);
});
test("skill publishers can create a skill and publish a new version", async ({
page,
}, testInfo) => {
+58
View File
@@ -0,0 +1,58 @@
import {
Badge,
ClawHubEmailLayout,
DetailTable,
EmailHeading,
FindingCard,
Paragraph,
} from "./_components/clawhub";
export type SecretBlockedPublishEmailProps = {
artifactKind: "skill" | "plugin";
artifactName: string;
version: string;
preheader: string;
};
export default function SecretBlockedPublishEmail({
artifactKind,
artifactName,
version,
preheader,
}: SecretBlockedPublishEmailProps) {
const title = `ClawHub blocked a ${artifactKind} publish`;
const railLabel = artifactKind === "plugin" ? "Plugin Review" : "Skill Review";
return (
<ClawHubEmailLayout preview={preheader} railLabel={railLabel}>
<Badge>Secret found</Badge>
<EmailHeading>{title}</EmailHeading>
<Paragraph>
TruffleHog found a secret-looking value in this upload. This version was not made public.
</Paragraph>
<DetailTable
rows={[
[artifactKind === "plugin" ? "Plugin" : "Skill", `${artifactName}@${version}`],
[
"Status",
<span key="status" style={{ color: "#e8443a" }}>
BLOCKED
</span>,
],
]}
/>
<FindingCard
kind="error"
meta="TruffleHog"
message="A secret-looking value was found in the uploaded files."
fix={`Rotate the secret if it was real, remove it from the ${artifactKind}, and upload a new version.`}
/>
</ClawHubEmailLayout>
);
}
SecretBlockedPublishEmail.PreviewProps = {
artifactKind: "skill",
artifactName: "secret-skill",
version: "1.0.0",
preheader: "secret-skill@1.0.0 was blocked before public listing because a secret was found.",
} satisfies SecretBlockedPublishEmailProps;
+1
View File
@@ -553,6 +553,7 @@ async function main() {
{ name: "AUTH_GITHUB_SECRET", value: e2eEnv.AUTH_GITHUB_SECRET ?? "local-dev" },
{ name: "CLAWHUB_DISABLE_CRONS", value: "1" },
{ name: "CLAWHUB_EMAIL_CAPTURE_FILE", value: e2eEnv.CLAWHUB_EMAIL_CAPTURE_FILE ?? "" },
{ name: "CLAWHUB_STAGED_PREPUBLICATION_PUBLISHES", value: "1" },
{ name: "DEV_AUTH_CONVEX_DEPLOYMENT", value: localAuthDeployment },
{ name: "DEV_AUTH_ENABLED", value: "1" },
{ name: "JWKS", value: authKeys.JWKS },
+16 -2
View File
@@ -284,6 +284,13 @@ describe("plugins publish route", () => {
await waitFor(() => {
expect(publishRelease).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(
screen.getByText(
/Published\. Pending security checks and verification before public listing\./i,
),
).toBeTruthy();
});
expect(generateUploadUrl).toHaveBeenCalledTimes(3);
expect(fetchMock).toHaveBeenCalledTimes(3);
@@ -829,7 +836,14 @@ describe("plugins publish route", () => {
expect(publishRelease).not.toHaveBeenCalled();
});
it("shows pending verification messaging after plugin publish", async () => {
it("shows pending verification messaging after staged plugin publish", async () => {
publishRelease.mockResolvedValueOnce({
ok: true,
status: "pending",
attemptId: "publishAttempts:1",
packageName: "demo-plugin",
version: "1.0.0",
});
renderPublishRoute();
const packageJson = withRelativePath(
@@ -865,7 +879,7 @@ describe("plugins publish route", () => {
fireEvent.click(screen.getByRole("button", { name: "Publish plugin" }));
expect(
await screen.findByText(/Pending security checks and verification before public listing\./i),
await screen.findByText(/Running TruffleHog and ClawScan before public listing\./i),
).toBeTruthy();
expect(
screen.getByRole("button", { name: "Publish plugin" }).getAttribute("disabled"),
@@ -967,6 +967,49 @@ describe("Upload route", () => {
expect(Object.hasOwn(args!, "icon")).toBe(false);
});
it("keeps publish disabled after staged skill publish is accepted", async () => {
generateUploadUrl.mockResolvedValue("https://upload.local");
publishVersion.mockResolvedValueOnce({
status: "pending",
attemptId: "publishAttempts:1",
slug: "pending-skill",
version: "1.0.0",
});
render(<Upload />);
fireEvent.change(screen.getByPlaceholderText("skill-name"), {
target: { value: "pending-skill" },
});
fireEvent.change(screen.getByPlaceholderText("My skill"), {
target: { value: "Pending Skill" },
});
fireEvent.change(screen.getByPlaceholderText("1.0.0"), {
target: { value: "1.0.0" },
});
fireEvent.change(screen.getByPlaceholderText("latest, stable"), {
target: { value: "latest" },
});
fireEvent.change(screen.getByTestId("upload-input"), {
target: { files: [new File(["hello"], "SKILL.md", { type: "text/markdown" })] },
});
fireEvent.click(
screen.getByRole("checkbox", {
name: /i have the rights to publish this skill under mit-0/i,
}),
);
const publishButton = screen.getByRole("button", { name: /publish skill/i });
await waitFor(() => {
expect(publishButton.getAttribute("disabled")).toBeNull();
});
fireEvent.click(publishButton);
expect(
await screen.findByText(/Running TruffleHog and ClawScan before public listing\./i),
).toBeTruthy();
expect(publishButton.getAttribute("disabled")).not.toBeNull();
});
it("omits icon when republishing a skill that still has a stored legacy icon", async () => {
useSearchMock.mockReturnValue({ updateSlug: "with-icon" });
useQueryMock.mockImplementation((fn: unknown, args: unknown) => {
+17 -5
View File
@@ -273,7 +273,8 @@ export function PublishPluginRoute() {
}, [family, isMetadataLocked, name, sourceCommit, sourceRepo, version]);
const hasPackageBlocker =
Boolean(validationError) || Boolean(ownerScopeError) || codePluginFieldIssues.length > 0;
const hasPublished = status?.startsWith("Published.") ?? false;
const hasPublished =
status?.startsWith("Published.") || status?.startsWith("Publish received.") || false;
const isPublishDisabled =
!isAuthenticated ||
isMetadataLocked ||
@@ -809,7 +810,7 @@ export function PublishPluginRoute() {
uploadFile,
});
setStatus("Publishing release...");
await publishRelease({
const result = await publishRelease({
payload: {
name: name.trim(),
displayName: displayName.trim() || undefined,
@@ -852,9 +853,20 @@ export function PublishPluginRoute() {
files: uploaded,
},
});
setStatus(
"Published. Pending security checks and verification before public listing.",
);
if (
result &&
typeof result === "object" &&
"status" in result &&
result.status === "pending"
) {
setStatus(
"Publish received. Running TruffleHog and ClawScan before public listing.",
);
} else {
setStatus(
"Published. Pending security checks and verification before public listing.",
);
}
} catch (publishError) {
const message = formatPublishError(publishError);
setError(message);
+41 -30
View File
@@ -154,7 +154,7 @@ export function Upload() {
const changelogRequestRef = useRef(0);
const changelogKeyRef = useRef<string | null>(null);
const [status, setStatus] = useState<string | null>(null);
const isSubmitting = status !== null;
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const publisherMemberships = useQuery(api.publishers.listMine, me ? {} : "skip") as
| PublisherOwnerMembership[]
@@ -591,6 +591,9 @@ export function Upload() {
return false;
});
const hasFilePanelFooter = Boolean(ignoredLocalMetadataNote || visibleFileIssues.length > 0);
const hasPublished =
status?.startsWith("Published.") || status?.startsWith("Publish received.") || false;
const isPublishDisabled = !validation.ready || isSubmitting || hasPublished;
const publishBlockerSummary =
!validation.ready && !isSubmitting ? summarizePublishBlockers(validation.issues) : null;
@@ -679,6 +682,7 @@ export function Upload() {
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
setHasAttempted(true);
if (hasPublished) return;
if (!validation.ready) {
const message = validation.issues[0] ?? "Fix validation issues to continue.";
setError(message);
@@ -715,36 +719,36 @@ export function Upload() {
toast.error(msg);
return;
}
setIsSubmitting(true);
setStatus("Uploading files…");
const uploaded = [] as Array<{
path: string;
size: number;
storageId: string;
sha256: string;
contentType?: string;
}>;
for (const file of files) {
const uploadUrl = await generateUploadUrl();
const rawPath = (file.webkitRelativePath || file.name).replace(/^\.\//, "");
const path =
stripRoot && rawPath.startsWith(`${stripRoot}/`)
? rawPath.slice(stripRoot.length + 1)
: rawPath;
const sha256 = await hashFile(file);
const storageId = await uploadFile(uploadUrl, file);
uploaded.push({
path,
size: file.size,
storageId,
sha256,
contentType: normalizeTextContentType(path, file.type) ?? file.type ?? undefined,
});
}
setStatus("Publishing…");
try {
const uploaded = [] as Array<{
path: string;
size: number;
storageId: string;
sha256: string;
contentType?: string;
}>;
for (const file of files) {
const uploadUrl = await generateUploadUrl();
const rawPath = (file.webkitRelativePath || file.name).replace(/^\.\//, "");
const path =
stripRoot && rawPath.startsWith(`${stripRoot}/`)
? rawPath.slice(stripRoot.length + 1)
: rawPath;
const sha256 = await hashFile(file);
const storageId = await uploadFile(uploadUrl, file);
uploaded.push({
path,
size: file.size,
storageId,
sha256,
contentType: normalizeTextContentType(path, file.type) ?? file.type ?? undefined,
});
}
setStatus("Publishing…");
const result = await publishVersion({
ownerHandle: ownerHandle || undefined,
sourceOwnerHandle:
@@ -773,6 +777,11 @@ export function Upload() {
setHasAttempted(false);
setChangelogSource("user");
if (result) {
if (typeof result === "object" && "status" in result && result.status === "pending") {
setStatus("Publish received. Running TruffleHog and ClawScan before public listing.");
toast.success("Publish received. Security checks are running.");
return;
}
const ownerParam = ownerHandle || me?.handle || (me?._id ? String(me._id) : "unknown");
const didSetPostPublishFlash = setPostPublishFlash(ownerParam, trimmedSlug);
if (!didSetPostPublishFlash) {
@@ -789,6 +798,8 @@ export function Upload() {
const message = formatPublishError(publishError);
setError(message);
toast.error(message);
} finally {
setIsSubmitting(false);
}
}
@@ -1315,7 +1326,7 @@ export function Upload() {
variant="primary"
size="lg"
type="submit"
disabled={!validation.ready || isSubmitting}
disabled={isPublishDisabled}
loading={isSubmitting}
>
{!validation.ready && !isSubmitting ? (
+2
View File
@@ -34,6 +34,7 @@ export default defineConfig({
hookTimeout: 15_000,
exclude: [
"**/node_modules/**",
"**/.artifacts/**",
"**/.vercel/output/**",
"**/.output/**",
"**/.nitro/**",
@@ -64,6 +65,7 @@ export default defineConfig({
],
exclude: [
"node_modules/",
".artifacts/",
".vercel/output/",
".output/",
".nitro/",