mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
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:
Vendored
+2
@@ -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;
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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} />);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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 },
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
@@ -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 },
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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/",
|
||||
|
||||
Reference in New Issue
Block a user