mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 17:02:11 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2ea75c921 | ||
|
|
1e3bc9b02a | ||
|
|
af9cfce93b | ||
|
|
be29a89a72 | ||
|
|
273d11c5a9 | ||
|
|
cf6441add5 | ||
|
|
869e340ecc | ||
|
|
2a2e6c538c | ||
|
|
9a2342d627 | ||
|
|
85be25bccb | ||
|
|
e953810d8b | ||
|
|
bdeb8d0120 | ||
|
|
47454585be | ||
|
|
bbc5e2c385 | ||
|
|
8683f18d3a | ||
|
|
a01323e835 |
Vendored
+4
@@ -24,6 +24,7 @@ import type * as functions from "../functions.js";
|
||||
import type * as githubAccountAgeBackfill from "../githubAccountAgeBackfill.js";
|
||||
import type * as githubBackups from "../githubBackups.js";
|
||||
import type * as githubBackupsNode from "../githubBackupsNode.js";
|
||||
import type * as githubApp from "../githubApp.js";
|
||||
import type * as githubIdentity from "../githubIdentity.js";
|
||||
import type * as githubImport from "../githubImport.js";
|
||||
import type * as githubRestore from "../githubRestore.js";
|
||||
@@ -61,6 +62,7 @@ import type * as lib_emails from "../lib/emails.js";
|
||||
import type * as lib_embeddingVisibility from "../lib/embeddingVisibility.js";
|
||||
import type * as lib_embeddings from "../lib/embeddings.js";
|
||||
import type * as lib_githubAccount from "../lib/githubAccount.js";
|
||||
import type * as lib_githubAppSync from "../lib/githubAppSync.js";
|
||||
import type * as lib_githubActionsOidc from "../lib/githubActionsOidc.js";
|
||||
import type * as lib_githubAuth from "../lib/githubAuth.js";
|
||||
import type * as lib_githubBackup from "../lib/githubBackup.js";
|
||||
@@ -169,6 +171,7 @@ declare const fullApi: ApiFromModules<{
|
||||
githubAccountAgeBackfill: typeof githubAccountAgeBackfill;
|
||||
githubBackups: typeof githubBackups;
|
||||
githubBackupsNode: typeof githubBackupsNode;
|
||||
githubApp: typeof githubApp;
|
||||
githubIdentity: typeof githubIdentity;
|
||||
githubImport: typeof githubImport;
|
||||
githubRestore: typeof githubRestore;
|
||||
@@ -206,6 +209,7 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/embeddingVisibility": typeof lib_embeddingVisibility;
|
||||
"lib/embeddings": typeof lib_embeddings;
|
||||
"lib/githubAccount": typeof lib_githubAccount;
|
||||
"lib/githubAppSync": typeof lib_githubAppSync;
|
||||
"lib/githubActionsOidc": typeof lib_githubActionsOidc;
|
||||
"lib/githubAuth": typeof lib_githubAuth;
|
||||
"lib/githubBackup": typeof lib_githubBackup;
|
||||
|
||||
@@ -210,7 +210,7 @@ describe("downloads helpers", () => {
|
||||
expect(storageGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks the exact requested skill version when its ClawScan verdict is malicious", async () => {
|
||||
it("blocks explicit downloads of a malicious historical version even when the skill is staff-cleared", async () => {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
if ("slug" in args) {
|
||||
@@ -227,6 +227,8 @@ describe("downloads helpers", () => {
|
||||
isPendingScan: false,
|
||||
isHiddenByMod: false,
|
||||
isRemoved: false,
|
||||
overrideActive: true,
|
||||
verdict: "clean",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,13 +69,6 @@ export async function downloadZipHandler(
|
||||
headers: mergeHeaders(rate.headers, corsHeaders()),
|
||||
});
|
||||
}
|
||||
if (version.softDeletedAt) {
|
||||
return new Response("Version not available", {
|
||||
status: 410,
|
||||
headers: mergeHeaders(rate.headers, corsHeaders()),
|
||||
});
|
||||
}
|
||||
|
||||
const moderationBlock = getPublicSkillVersionDownloadBlock(
|
||||
skillResult.moderationInfo,
|
||||
version,
|
||||
|
||||
+2175
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import { ApiRoutes, LegacyApiRoutes } from "clawhub-schema";
|
||||
import { httpRouter } from "convex/server";
|
||||
import { auth } from "./auth";
|
||||
import { downloadZip } from "./downloads";
|
||||
import { githubWebhookHttp } from "./githubApp";
|
||||
import {
|
||||
cliPublishHttp,
|
||||
cliDeviceCodeHttp,
|
||||
@@ -315,6 +316,12 @@ http.route({
|
||||
handler: preflightHandler,
|
||||
});
|
||||
|
||||
http.route({
|
||||
path: "/api/webhooks/github-app",
|
||||
method: "POST",
|
||||
handler: githubWebhookHttp,
|
||||
});
|
||||
|
||||
// TODO: remove legacy /api routes after deprecation window.
|
||||
http.route({
|
||||
path: LegacyApiRoutes.download,
|
||||
|
||||
@@ -8314,6 +8314,55 @@ describe("httpApiV1 handlers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("packages version detail blocks malicious skill compatibility versions", async () => {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("name" in args) return null;
|
||||
if ("slug" in args) {
|
||||
return {
|
||||
skill: {
|
||||
_id: "skills:demo",
|
||||
slug: "demo",
|
||||
displayName: "Demo Skill",
|
||||
summary: "Skill summary",
|
||||
latestVersionId: "skillVersions:demo-2",
|
||||
tags: { latest: "skillVersions:demo-2" },
|
||||
badges: {},
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
latestVersion: null,
|
||||
owner: { handle: "steipete", displayName: "Peter" },
|
||||
moderationInfo: null,
|
||||
};
|
||||
}
|
||||
if (args.version === "1.0.0") {
|
||||
return {
|
||||
_id: "skillVersions:demo-1",
|
||||
skillId: "skills:demo",
|
||||
version: "1.0.0",
|
||||
createdAt: 3,
|
||||
changelog: "init",
|
||||
files: [{ path: "SKILL.md", size: 11, sha256: "abc" }],
|
||||
llmAnalysis: {
|
||||
status: "malicious",
|
||||
verdict: "malicious",
|
||||
checkedAt: 4,
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
const response = await __handlers.packagesGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request("https://example.com/api/v1/packages/demo/versions/1.0.0"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.text()).toContain("flagged as malicious");
|
||||
});
|
||||
|
||||
it("packages detail returns not found for invalid package lookup names", async () => {
|
||||
const runQuery = vi.fn(async () => {
|
||||
throw new Error("unexpected package lookup");
|
||||
|
||||
@@ -55,8 +55,7 @@ import {
|
||||
} from "../lib/publishLimits";
|
||||
import { compareRecommendationStats } from "../lib/recommendationScore";
|
||||
import {
|
||||
getPublicSkillVersionAccessBlock,
|
||||
getPublicSkillVersionDownloadBlock,
|
||||
getPublicSkillVersionFileAccessBlock,
|
||||
getSkillFileModerationInfoFromSkill,
|
||||
isSkillVersionForSkill,
|
||||
} from "../lib/skillFileAccess";
|
||||
@@ -456,6 +455,10 @@ type SkillVersionLike = {
|
||||
contentType?: string;
|
||||
}>;
|
||||
softDeletedAt?: number;
|
||||
sha256hash?: string;
|
||||
vtAnalysis?: Doc<"skillVersions">["vtAnalysis"];
|
||||
llmAnalysis?: Doc<"skillVersions">["llmAnalysis"];
|
||||
staticScan?: Doc<"skillVersions">["staticScan"];
|
||||
};
|
||||
|
||||
type ReleaseLike = {
|
||||
@@ -2927,9 +2930,9 @@ async function getUnavailableSkillPackageVersionBlock(
|
||||
if (!version || !isSkillVersionForSkill(version, skill._id)) return null;
|
||||
if (version.softDeletedAt) return { status: 410, message: "Version not available" };
|
||||
|
||||
return getPublicSkillVersionAccessBlock(
|
||||
return getPublicSkillVersionFileAccessBlock(
|
||||
version,
|
||||
getSkillFileModerationInfoFromSkill(skill),
|
||||
version._id,
|
||||
skill.latestVersionId ?? skill.tags?.latest,
|
||||
);
|
||||
}
|
||||
@@ -3491,13 +3494,13 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
|
||||
if (!version || version.softDeletedAt) return text("Version not found", 404, rate.headers);
|
||||
const effectiveLatestVersionId =
|
||||
skillDetail.skill.latestVersionId ?? skillDetail.skill.tags?.latest;
|
||||
const moderationBlock = getPublicSkillVersionAccessBlock(
|
||||
const versionAccessBlock = getPublicSkillVersionFileAccessBlock(
|
||||
version,
|
||||
skillDetail.moderationInfo,
|
||||
version._id,
|
||||
effectiveLatestVersionId,
|
||||
);
|
||||
if (moderationBlock)
|
||||
return text(moderationBlock.message, moderationBlock.status, rate.headers);
|
||||
if (versionAccessBlock)
|
||||
return text(versionAccessBlock.message, versionAccessBlock.status, rate.headers);
|
||||
const tags = await resolveSkillTags(ctx, skillDetail.skill._id, skillDetail.skill.tags);
|
||||
return json(
|
||||
{
|
||||
@@ -3583,13 +3586,13 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
|
||||
if (!version || version.softDeletedAt) return text("Version not found", 404, rate.headers);
|
||||
const effectiveLatestVersionId =
|
||||
skillDetail.skill.latestVersionId ?? skillDetail.skill.tags?.latest;
|
||||
const moderationBlock = getPublicSkillVersionDownloadBlock(
|
||||
skillDetail.moderationInfo,
|
||||
const versionAccessBlock = getPublicSkillVersionFileAccessBlock(
|
||||
version,
|
||||
skillDetail.moderationInfo,
|
||||
effectiveLatestVersionId,
|
||||
);
|
||||
if (moderationBlock)
|
||||
return text(moderationBlock.message, moderationBlock.status, rate.headers);
|
||||
if (versionAccessBlock)
|
||||
return text(versionAccessBlock.message, versionAccessBlock.status, rate.headers);
|
||||
const file = resolveSkillFilePath(version, path);
|
||||
if (!file) return text("File not found", 404, rate.headers);
|
||||
if (!("storageId" in file) || !file.storageId)
|
||||
|
||||
@@ -36,7 +36,7 @@ import { selectGeneratedSkillCardFile, sourceSkillVersionFiles } from "../lib/sk
|
||||
import {
|
||||
getPublicSkillFileAccessBlock,
|
||||
getPublicSkillVersionAccessBlock,
|
||||
getPublicSkillVersionDownloadBlock,
|
||||
getPublicSkillVersionFileAccessBlock,
|
||||
getSkillFileModerationInfoFromSkill,
|
||||
isSkillVersionForSkill,
|
||||
} from "../lib/skillFileAccess";
|
||||
@@ -1624,9 +1624,9 @@ async function getUnavailableSkillVersionBlock(
|
||||
if (!version || !isSkillVersionForSkill(version, skill._id)) return null;
|
||||
if (version.softDeletedAt) return { status: 410, message: "Version not available" };
|
||||
|
||||
return getPublicSkillVersionAccessBlock(
|
||||
return getPublicSkillVersionFileAccessBlock(
|
||||
version,
|
||||
getSkillFileModerationInfoFromSkill(skill),
|
||||
version._id,
|
||||
skill.latestVersionId ?? skill.tags?.latest,
|
||||
);
|
||||
}
|
||||
@@ -1766,9 +1766,9 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
|
||||
const latestVersionId =
|
||||
result.skill.latestVersionId ?? result.skill.tags?.latest ?? result.latestVersion?._id;
|
||||
const descriptionAccessBlock = result.latestVersion
|
||||
? getPublicSkillVersionAccessBlock(
|
||||
? getPublicSkillVersionFileAccessBlock(
|
||||
result.latestVersion,
|
||||
result.moderationInfo,
|
||||
result.latestVersion._id,
|
||||
latestVersionId,
|
||||
)
|
||||
: getPublicSkillFileAccessBlock(result.moderationInfo);
|
||||
@@ -1957,13 +1957,13 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
|
||||
if (version.softDeletedAt) return text("Version not available", 410, rate.headers);
|
||||
const effectiveLatestVersionId =
|
||||
skillResult.skill.latestVersionId ?? skillResult.skill.tags?.latest;
|
||||
const moderationBlock = getPublicSkillVersionAccessBlock(
|
||||
const versionAccessBlock = getPublicSkillVersionFileAccessBlock(
|
||||
version,
|
||||
skillResult.moderationInfo,
|
||||
version._id,
|
||||
effectiveLatestVersionId,
|
||||
);
|
||||
if (moderationBlock) {
|
||||
return text(moderationBlock.message, moderationBlock.status, rate.headers);
|
||||
if (versionAccessBlock) {
|
||||
return text(versionAccessBlock.message, versionAccessBlock.status, rate.headers);
|
||||
}
|
||||
const security = buildSkillSecuritySnapshot(version);
|
||||
|
||||
@@ -2252,13 +2252,13 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
|
||||
if (version.softDeletedAt) return text("Version not available", 410, rate.headers);
|
||||
const effectiveLatestVersionId =
|
||||
skillResult.skill.latestVersionId ?? skillResult.skill.tags?.latest;
|
||||
const moderationBlock = getPublicSkillVersionDownloadBlock(
|
||||
skillResult.moderationInfo,
|
||||
const versionAccessBlock = getPublicSkillVersionFileAccessBlock(
|
||||
version,
|
||||
skillResult.moderationInfo,
|
||||
effectiveLatestVersionId,
|
||||
);
|
||||
if (moderationBlock) {
|
||||
return text(moderationBlock.message, moderationBlock.status, rate.headers);
|
||||
if (versionAccessBlock) {
|
||||
return text(versionAccessBlock.message, versionAccessBlock.status, rate.headers);
|
||||
}
|
||||
|
||||
const fingerprintEntries = ((await ctx.runQuery(
|
||||
@@ -2317,13 +2317,13 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
|
||||
if (version.softDeletedAt) return text("Version not available", 410, rate.headers);
|
||||
const effectiveLatestVersionId =
|
||||
skillResult.skill.latestVersionId ?? skillResult.skill.tags?.latest;
|
||||
const moderationBlock = getPublicSkillVersionDownloadBlock(
|
||||
skillResult.moderationInfo,
|
||||
const versionAccessBlock = getPublicSkillVersionFileAccessBlock(
|
||||
version,
|
||||
skillResult.moderationInfo,
|
||||
effectiveLatestVersionId,
|
||||
);
|
||||
if (moderationBlock) {
|
||||
return text(moderationBlock.message, moderationBlock.status, rate.headers);
|
||||
if (versionAccessBlock) {
|
||||
return text(versionAccessBlock.message, versionAccessBlock.status, rate.headers);
|
||||
}
|
||||
|
||||
const normalized = path.trim();
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { generateKeyPairSync } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildGitHubAppInstallUrl,
|
||||
createGitHubAppJwt,
|
||||
deriveSlugFromCandidatePath,
|
||||
hashGitHubAppState,
|
||||
isPathUnderAnyRoot,
|
||||
normalizeGitHubRepoFullName,
|
||||
normalizeGitHubSyncRoots,
|
||||
signGitHubAppState,
|
||||
sourceLinkMatchesProvenance,
|
||||
verifyGitHubAppState,
|
||||
verifyGitHubWebhookSignature,
|
||||
} from "./githubAppSync";
|
||||
|
||||
describe("github app sync helpers", () => {
|
||||
it("normalizes repository identity and sync roots", () => {
|
||||
expect(normalizeGitHubRepoFullName("https://github.com/OpenClaw/Skills.git")).toBe(
|
||||
"OpenClaw/Skills",
|
||||
);
|
||||
expect(normalizeGitHubRepoFullName("git+https://github.com/OpenClaw/Skills.git")).toBe(
|
||||
"OpenClaw/Skills",
|
||||
);
|
||||
expect(normalizeGitHubRepoFullName("git@github.com:OpenClaw/Skills.git")).toBe(
|
||||
"OpenClaw/Skills",
|
||||
);
|
||||
expect(normalizeGitHubRepoFullName("https://www.github.com/OpenClaw/Skills/tree/main")).toBe(
|
||||
"OpenClaw/Skills",
|
||||
);
|
||||
expect(normalizeGitHubRepoFullName("not a repo")).toBeNull();
|
||||
expect(isPathUnderAnyRoot("skills/demo/SKILL.md", ["skills"])).toBe(true);
|
||||
expect(isPathUnderAnyRoot("packages/demo/package.json", ["skills"])).toBe(false);
|
||||
expect(normalizeGitHubSyncRoots(["", "skills/demo"])).toEqual(["", "skills/demo"]);
|
||||
expect(() => normalizeGitHubSyncRoots(["../skills"])).toThrow(/Invalid sync root/);
|
||||
});
|
||||
|
||||
it("derives stable skill slugs from candidate paths", () => {
|
||||
expect(deriveSlugFromCandidatePath("skills/Demo Skill", "OpenClaw/catalog")).toBe("demo-skill");
|
||||
expect(deriveSlugFromCandidatePath("", "OpenClaw/Catalog Repo")).toBe("catalog-repo");
|
||||
});
|
||||
|
||||
it("signs setup state, verifies it, and rejects tampering", async () => {
|
||||
const secret = "state-secret";
|
||||
const state = await signGitHubAppState(
|
||||
{
|
||||
publisherId: "publishers:org",
|
||||
requestedByUserId: "users:admin",
|
||||
nonce: "nonce",
|
||||
targetAccountId: "12345",
|
||||
exp: 2_000,
|
||||
},
|
||||
secret,
|
||||
1_000,
|
||||
);
|
||||
await expect(hashGitHubAppState(state)).resolves.toMatch(/^[a-f0-9]{64}$/);
|
||||
await expect(verifyGitHubAppState(state, secret, 1_500)).resolves.toEqual({
|
||||
publisherId: "publishers:org",
|
||||
requestedByUserId: "users:admin",
|
||||
nonce: "nonce",
|
||||
targetAccountId: "12345",
|
||||
exp: 2_000,
|
||||
});
|
||||
await expect(verifyGitHubAppState(`${state}x`, secret, 1_500)).rejects.toThrow(
|
||||
/Invalid GitHub setup state/,
|
||||
);
|
||||
await expect(verifyGitHubAppState(state, secret, 2_001)).rejects.toThrow(
|
||||
/GitHub setup state expired/,
|
||||
);
|
||||
});
|
||||
|
||||
it("builds the app install URL with signed state", () => {
|
||||
const url = buildGitHubAppInstallUrl({
|
||||
appSlug: "clawhub-test",
|
||||
state: "signed-state",
|
||||
targetId: "123",
|
||||
});
|
||||
expect(url).toBe(
|
||||
"https://github.com/apps/clawhub-test/installations/new?state=signed-state&target_id=123",
|
||||
);
|
||||
});
|
||||
|
||||
it("creates app JWTs from PKCS#8 and GitHub-style PKCS#1 RSA private keys", async () => {
|
||||
const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
||||
const pkcs8Pem = privateKey.export({ type: "pkcs8", format: "pem" });
|
||||
const pkcs1Pem = privateKey.export({ type: "pkcs1", format: "pem" });
|
||||
|
||||
await expect(
|
||||
createGitHubAppJwt({ appId: "12345", privateKeyPem: pkcs8Pem, now: 1_700_000_000_000 }),
|
||||
).resolves.toMatch(/^[^.]+\.[^.]+\.[^.]+$/);
|
||||
await expect(
|
||||
createGitHubAppJwt({ appId: "12345", privateKeyPem: pkcs1Pem, now: 1_700_000_000_000 }),
|
||||
).resolves.toMatch(/^[^.]+\.[^.]+\.[^.]+$/);
|
||||
});
|
||||
|
||||
it("verifies webhook signatures and rejects bad signatures", async () => {
|
||||
const body = new TextEncoder().encode(JSON.stringify({ zen: "Keep it logically awesome." }));
|
||||
const signature = await buildGitHubWebhookSignature(body, "webhook-secret");
|
||||
await expect(
|
||||
verifyGitHubWebhookSignature({
|
||||
body: body.buffer as ArrayBuffer,
|
||||
signatureHeader: signature,
|
||||
secret: "webhook-secret",
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
await expect(
|
||||
verifyGitHubWebhookSignature({
|
||||
body: body.buffer as ArrayBuffer,
|
||||
signatureHeader: signature,
|
||||
secret: "wrong-secret",
|
||||
}),
|
||||
).resolves.toEqual({ ok: false, reason: "bad-signature" });
|
||||
});
|
||||
|
||||
it("requires exact source sync context for source-managed publishes", () => {
|
||||
const link = {
|
||||
repoFullName: "OpenClaw/catalog",
|
||||
path: "skills/demo",
|
||||
status: "active",
|
||||
};
|
||||
const sourceProvenance = {
|
||||
kind: "github" as const,
|
||||
repo: "openclaw/catalog",
|
||||
path: "skills/demo",
|
||||
};
|
||||
expect(
|
||||
sourceLinkMatchesProvenance({
|
||||
link,
|
||||
sourceProvenance,
|
||||
sourceSync: { sourceLinkId: "skillSourceLinks:1" },
|
||||
expectedSourceLinkId: "skillSourceLinks:1",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
sourceLinkMatchesProvenance({
|
||||
link: { ...link, status: "conflict" },
|
||||
sourceProvenance,
|
||||
sourceSync: { sourceLinkId: "skillSourceLinks:1" },
|
||||
expectedSourceLinkId: "skillSourceLinks:1",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
sourceLinkMatchesProvenance({
|
||||
link: { ...link, status: "disabled" },
|
||||
sourceProvenance,
|
||||
sourceSync: { sourceLinkId: "skillSourceLinks:1" },
|
||||
expectedSourceLinkId: "skillSourceLinks:1",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
sourceLinkMatchesProvenance({
|
||||
link,
|
||||
sourceProvenance,
|
||||
sourceSync: undefined,
|
||||
expectedSourceLinkId: "skillSourceLinks:1",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
sourceLinkMatchesProvenance({
|
||||
link,
|
||||
sourceProvenance: { ...sourceProvenance, path: "skills/other" },
|
||||
sourceSync: { sourceLinkId: "skillSourceLinks:1" },
|
||||
expectedSourceLinkId: "skillSourceLinks:1",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
async function buildGitHubWebhookSignature(body: Uint8Array, secret: string) {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
new TextEncoder().encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
const signature = await crypto.subtle.sign("HMAC", key, body.buffer as ArrayBuffer);
|
||||
const hex = Array.from(new Uint8Array(signature), (byte) => byte.toString(16).padStart(2, "0"));
|
||||
return `sha256=${hex.join("")}`;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { ConvexError } from "convex/values";
|
||||
import {
|
||||
base64UrlDecode,
|
||||
base64UrlEncode,
|
||||
hmacSha256Base64Url,
|
||||
hmacSha256Hex,
|
||||
isRepoPathUnderRoot,
|
||||
normalizeGitHubRepo,
|
||||
sha256Hex,
|
||||
timingSafeEqual,
|
||||
} from "./githubCommon";
|
||||
import { normalizeRepoPath } from "./githubImport";
|
||||
import { normalizeSkillSlug } from "./skillSlugValidator";
|
||||
|
||||
export { createGitHubAppJwt } from "./githubCommon";
|
||||
|
||||
export type GitHubWebhookVerificationResult =
|
||||
| { ok: true }
|
||||
| {
|
||||
ok: false;
|
||||
reason: "missing-secret" | "missing-signature" | "malformed-signature" | "bad-signature";
|
||||
};
|
||||
|
||||
export type GitHubAppStatePayload = {
|
||||
publisherId: string;
|
||||
requestedByUserId: string;
|
||||
nonce: string;
|
||||
targetAccountId?: string;
|
||||
exp: number;
|
||||
};
|
||||
|
||||
const DEFAULT_SETUP_STATE_TTL_MS = 10 * 60 * 1000;
|
||||
const MAX_SYNC_ROOTS = 25;
|
||||
|
||||
export function normalizeGitHubRepoFullName(value: string) {
|
||||
return normalizeGitHubRepo(value);
|
||||
}
|
||||
|
||||
export function normalizeGitHubSyncRef(value: string | undefined | null, defaultBranch: string) {
|
||||
const raw = value?.trim() || defaultBranch.trim();
|
||||
if (!raw) throw new ConvexError("Sync ref is required");
|
||||
return raw.replace(/^refs\/heads\//, "");
|
||||
}
|
||||
|
||||
export function normalizeGitHubSyncRoots(roots: string[] | undefined | null) {
|
||||
const normalized = new Set<string>();
|
||||
for (const root of roots?.length ? roots : [""]) {
|
||||
const trimmed = root.trim();
|
||||
if (!trimmed) {
|
||||
normalized.add("");
|
||||
continue;
|
||||
}
|
||||
const value = normalizeRepoPath(root);
|
||||
if (!value) throw new ConvexError("Invalid sync root");
|
||||
normalized.add(value);
|
||||
if (normalized.size > MAX_SYNC_ROOTS) throw new ConvexError("Too many sync roots");
|
||||
}
|
||||
return Array.from(normalized).sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
export function isPathUnderAnyRoot(path: string, roots: string[]) {
|
||||
const normalizedPath = normalizeRepoPath(path);
|
||||
const normalizedRoots = normalizeGitHubSyncRoots(roots);
|
||||
if (normalizedRoots.includes("")) return true;
|
||||
return normalizedRoots.some((root) => isRepoPathUnderRoot(normalizedPath, root));
|
||||
}
|
||||
|
||||
export function deriveSlugFromCandidatePath(candidatePath: string, repoFullName: string) {
|
||||
const repoName = repoFullName.split("/").at(1) ?? repoFullName;
|
||||
const base = candidatePath ? (candidatePath.split("/").at(-1) ?? candidatePath) : repoName;
|
||||
return normalizeSkillSlug(
|
||||
base
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]+/g, "-")
|
||||
.replace(/^-+/, "")
|
||||
.replace(/-+$/, "")
|
||||
.replace(/--+/g, "-"),
|
||||
);
|
||||
}
|
||||
|
||||
export function sourceLinkMatchesProvenance(params: {
|
||||
link: { repoFullName: string; path: string; status: string };
|
||||
sourceProvenance?: { kind: "github"; repo: string; path?: string } | null;
|
||||
sourceSync?: { sourceLinkId: string } | null;
|
||||
expectedSourceLinkId: string;
|
||||
}) {
|
||||
if (params.link.status === "disabled") return false;
|
||||
if (params.sourceSync?.sourceLinkId !== params.expectedSourceLinkId) return false;
|
||||
const provenance = params.sourceProvenance;
|
||||
if (!provenance || provenance.kind !== "github") return false;
|
||||
return (
|
||||
normalizeGitHubRepoFullName(provenance.repo)?.toLowerCase() ===
|
||||
normalizeGitHubRepoFullName(params.link.repoFullName)?.toLowerCase() &&
|
||||
normalizeRepoPath(provenance.path ?? "") === normalizeRepoPath(params.link.path)
|
||||
);
|
||||
}
|
||||
|
||||
export async function signGitHubAppState(
|
||||
payload: Omit<GitHubAppStatePayload, "exp"> & { exp?: number },
|
||||
secret: string,
|
||||
now = Date.now(),
|
||||
) {
|
||||
const exp = payload.exp ?? now + DEFAULT_SETUP_STATE_TTL_MS;
|
||||
const body = base64UrlEncode(
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
publisherId: payload.publisherId,
|
||||
requestedByUserId: payload.requestedByUserId,
|
||||
nonce: payload.nonce,
|
||||
targetAccountId: payload.targetAccountId,
|
||||
exp,
|
||||
} satisfies GitHubAppStatePayload),
|
||||
),
|
||||
);
|
||||
const signature = await hmacSha256Base64Url(secret, body);
|
||||
return `${body}.${signature}`;
|
||||
}
|
||||
|
||||
export async function verifyGitHubAppState(
|
||||
state: string,
|
||||
secret: string,
|
||||
now = Date.now(),
|
||||
): Promise<GitHubAppStatePayload> {
|
||||
const [body, signature, extra] = state.split(".");
|
||||
if (!body || !signature || extra) throw new ConvexError("Invalid GitHub setup state");
|
||||
const expected = await hmacSha256Base64Url(secret, body);
|
||||
if (!timingSafeEqual(signature, expected)) throw new ConvexError("Invalid GitHub setup state");
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(new TextDecoder().decode(base64UrlDecode(body)));
|
||||
} catch {
|
||||
throw new ConvexError("Invalid GitHub setup state");
|
||||
}
|
||||
const payload = parsed as Partial<GitHubAppStatePayload>;
|
||||
if (
|
||||
typeof payload.publisherId !== "string" ||
|
||||
typeof payload.requestedByUserId !== "string" ||
|
||||
typeof payload.nonce !== "string" ||
|
||||
(payload.targetAccountId !== undefined && typeof payload.targetAccountId !== "string") ||
|
||||
typeof payload.exp !== "number"
|
||||
) {
|
||||
throw new ConvexError("Invalid GitHub setup state");
|
||||
}
|
||||
if (payload.exp < now) throw new ConvexError("GitHub setup state expired");
|
||||
return {
|
||||
publisherId: payload.publisherId,
|
||||
requestedByUserId: payload.requestedByUserId,
|
||||
nonce: payload.nonce,
|
||||
targetAccountId: payload.targetAccountId,
|
||||
exp: payload.exp,
|
||||
};
|
||||
}
|
||||
|
||||
export async function hashGitHubAppState(state: string) {
|
||||
return sha256Hex(state);
|
||||
}
|
||||
|
||||
export async function verifyGitHubWebhookSignature(params: {
|
||||
body: ArrayBuffer;
|
||||
signatureHeader: string | null;
|
||||
secret: string | undefined;
|
||||
}): Promise<GitHubWebhookVerificationResult> {
|
||||
const secret = params.secret?.trim();
|
||||
if (!secret) return { ok: false, reason: "missing-secret" };
|
||||
const signature = params.signatureHeader?.trim();
|
||||
if (!signature) return { ok: false, reason: "missing-signature" };
|
||||
if (!signature.startsWith("sha256=")) return { ok: false, reason: "malformed-signature" };
|
||||
const expected = `sha256=${await hmacSha256Hex(secret, params.body)}`;
|
||||
if (!timingSafeEqual(signature, expected)) return { ok: false, reason: "bad-signature" };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function buildGitHubAppInstallUrl(params: {
|
||||
appSlug: string;
|
||||
state: string;
|
||||
targetId?: string;
|
||||
}) {
|
||||
const url = new URL(`https://github.com/apps/${params.appSlug}/installations/new`);
|
||||
url.searchParams.set("state", params.state);
|
||||
if (params.targetId) url.searchParams.set("target_id", params.targetId);
|
||||
return url.toString();
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
const GITHUB_HOSTS = new Set(["github.com", "www.github.com"]);
|
||||
|
||||
export function normalizeGitHubRepo(value: string) {
|
||||
const trimmed = value
|
||||
.trim()
|
||||
.replace(/^git\+/, "")
|
||||
.replace(/\.git$/i, "")
|
||||
.replace(/^git@github\.com:/i, "https://github.com/");
|
||||
if (!trimmed) return null;
|
||||
|
||||
const shorthand = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(trimmed);
|
||||
if (shorthand) return `${shorthand[1]}/${shorthand[2]}`;
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (!GITHUB_HOSTS.has(url.hostname)) return null;
|
||||
const segments = decodePathSegments(url.pathname);
|
||||
const owner = segments[0] ?? "";
|
||||
const repo = (segments[1] ?? "").replace(/\.git$/i, "");
|
||||
if (!owner || !repo) return null;
|
||||
return `${owner}/${repo}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isRepoPathUnderRoot(path: string, root: string) {
|
||||
if (!root) return true;
|
||||
return path === root || path.startsWith(`${root}/`);
|
||||
}
|
||||
|
||||
export async function createGitHubAppJwt(params: {
|
||||
appId: string;
|
||||
privateKeyPem: string;
|
||||
now?: number;
|
||||
}) {
|
||||
const nowSeconds = Math.floor((params.now ?? Date.now()) / 1000);
|
||||
const header = base64UrlEncode(
|
||||
new TextEncoder().encode(JSON.stringify({ alg: "RS256", typ: "JWT" })),
|
||||
);
|
||||
const payload = base64UrlEncode(
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
iat: nowSeconds - 60,
|
||||
exp: nowSeconds + 9 * 60,
|
||||
iss: params.appId,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const signingInput = `${header}.${payload}`;
|
||||
const key = await importPrivateKey(params.privateKeyPem);
|
||||
const signature = await crypto.subtle.sign(
|
||||
"RSASSA-PKCS1-v1_5",
|
||||
key,
|
||||
new TextEncoder().encode(signingInput),
|
||||
);
|
||||
return `${signingInput}.${base64UrlEncode(new Uint8Array(signature))}`;
|
||||
}
|
||||
|
||||
export async function sha256Hex(value: string) {
|
||||
return toHex(
|
||||
new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value))),
|
||||
);
|
||||
}
|
||||
|
||||
export async function hmacSha256Base64Url(secret: string, value: string) {
|
||||
const digest = await hmacSha256(secret, new TextEncoder().encode(value));
|
||||
return base64UrlEncode(new Uint8Array(digest));
|
||||
}
|
||||
|
||||
export async function hmacSha256Hex(secret: string, value: ArrayBuffer) {
|
||||
const digest = await hmacSha256(secret, value);
|
||||
return toHex(new Uint8Array(digest));
|
||||
}
|
||||
|
||||
export function timingSafeEqual(a: string, b: string) {
|
||||
const aBytes = new TextEncoder().encode(a);
|
||||
const bBytes = new TextEncoder().encode(b);
|
||||
if (aBytes.length !== bBytes.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < aBytes.length; i += 1) {
|
||||
diff |= (aBytes[i] ?? 0) ^ (bBytes[i] ?? 0);
|
||||
}
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
export function base64UrlEncode(bytes: Uint8Array) {
|
||||
return bytesToBase64(bytes).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
export function base64UrlDecode(value: string) {
|
||||
const padded = value
|
||||
.replaceAll("-", "+")
|
||||
.replaceAll("_", "/")
|
||||
.padEnd(Math.ceil(value.length / 4) * 4, "=");
|
||||
return base64Decode(padded);
|
||||
}
|
||||
|
||||
function decodePathSegments(pathname: string) {
|
||||
return pathname
|
||||
.split("/")
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean)
|
||||
.map((segment) => {
|
||||
try {
|
||||
return decodeURIComponent(segment);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function importPrivateKey(privateKeyPem: string) {
|
||||
const normalized = privateKeyPem.replace(/\\n/g, "\n").trim();
|
||||
const pkcs8Match = /-----BEGIN PRIVATE KEY-----([\s\S]+?)-----END PRIVATE KEY-----/.exec(
|
||||
normalized,
|
||||
);
|
||||
const pkcs1Match = /-----BEGIN RSA PRIVATE KEY-----([\s\S]+?)-----END RSA PRIVATE KEY-----/.exec(
|
||||
normalized,
|
||||
);
|
||||
const der = pkcs8Match
|
||||
? base64Decode(pkcs8Match[1]?.replace(/\s+/g, "") ?? "")
|
||||
: pkcs1Match
|
||||
? wrapPkcs1RsaPrivateKeyAsPkcs8(base64Decode(pkcs1Match[1]?.replace(/\s+/g, "") ?? ""))
|
||||
: base64Decode(normalized.replace(/\s+/g, ""));
|
||||
return await crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
toArrayBuffer(der),
|
||||
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
}
|
||||
|
||||
function wrapPkcs1RsaPrivateKeyAsPkcs8(pkcs1Der: Uint8Array) {
|
||||
const rsaEncryptionOid = new Uint8Array([
|
||||
0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00,
|
||||
]);
|
||||
const version = new Uint8Array([0x02, 0x01, 0x00]);
|
||||
const privateKey = derEncode(0x04, pkcs1Der);
|
||||
return derEncode(0x30, concatBytes([version, rsaEncryptionOid, privateKey]));
|
||||
}
|
||||
|
||||
function derEncode(tag: number, value: Uint8Array) {
|
||||
return concatBytes([new Uint8Array([tag]), derLength(value.byteLength), value]);
|
||||
}
|
||||
|
||||
function derLength(length: number) {
|
||||
if (length < 0x80) return new Uint8Array([length]);
|
||||
const bytes: number[] = [];
|
||||
let remaining = length;
|
||||
while (remaining > 0) {
|
||||
bytes.unshift(remaining & 0xff);
|
||||
remaining >>= 8;
|
||||
}
|
||||
return new Uint8Array([0x80 | bytes.length, ...bytes]);
|
||||
}
|
||||
|
||||
function concatBytes(parts: Uint8Array[]) {
|
||||
const total = parts.reduce((sum, part) => sum + part.byteLength, 0);
|
||||
const out = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
out.set(part, offset);
|
||||
offset += part.byteLength;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function hmacSha256(secret: string, value: ArrayBuffer | Uint8Array) {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
new TextEncoder().encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
return await crypto.subtle.sign("HMAC", key, toArrayBuffer(value));
|
||||
}
|
||||
|
||||
function base64Decode(value: string) {
|
||||
const binary = atob(value);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array) {
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function toHex(bytes: Uint8Array) {
|
||||
let out = "";
|
||||
for (const byte of bytes) out += byte.toString(16).padStart(2, "0");
|
||||
return out;
|
||||
}
|
||||
|
||||
function toArrayBuffer(value: ArrayBuffer | Uint8Array) {
|
||||
if (value instanceof ArrayBuffer) return value;
|
||||
return new Uint8Array(value).buffer as ArrayBuffer;
|
||||
}
|
||||
@@ -6,14 +6,25 @@ export type SkillFileModerationInfo = {
|
||||
isHiddenByMod?: boolean | null;
|
||||
isRemoved?: boolean | null;
|
||||
sourceVersionId?: Id<"skillVersions"> | string | null;
|
||||
overrideActive?: boolean | null;
|
||||
verdict?: string | null;
|
||||
};
|
||||
|
||||
type SkillVersionSecuritySource = {
|
||||
_id: Id<"skillVersions"> | string;
|
||||
type SkillVersionSecurityInfo = {
|
||||
_id?: Id<"skillVersions"> | string;
|
||||
vtAnalysis?: {
|
||||
status?: string | null;
|
||||
verdict?: string | null;
|
||||
} | null;
|
||||
llmAnalysis?: {
|
||||
status?: string | null;
|
||||
verdict?: string | null;
|
||||
} | null;
|
||||
softDeletedAt?: number | null;
|
||||
};
|
||||
|
||||
type SkillVersionSecuritySource = SkillVersionSecurityInfo & {
|
||||
_id: Id<"skillVersions"> | string;
|
||||
};
|
||||
|
||||
type SkillModerationSource = {
|
||||
@@ -22,6 +33,7 @@ type SkillModerationSource = {
|
||||
moderationFlags?: string[] | null;
|
||||
moderationVerdict?: string | null;
|
||||
moderationSourceVersionId?: Id<"skillVersions"> | string | null;
|
||||
manualOverride?: boolean | null;
|
||||
};
|
||||
|
||||
type SkillFileAccessBlock = {
|
||||
@@ -39,15 +51,6 @@ function isPendingSkillModerationReason(reason: string | null | undefined) {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeSkillScanStatus(status: string | null | undefined) {
|
||||
const normalized = status?.trim().toLowerCase();
|
||||
if (normalized === "benign") return "clean";
|
||||
if (normalized === "clean" || normalized === "suspicious" || normalized === "malicious") {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getSkillFileModerationInfoFromSkill(
|
||||
skill: SkillModerationSource,
|
||||
): SkillFileModerationInfo {
|
||||
@@ -62,6 +65,8 @@ export function getSkillFileModerationInfoFromSkill(
|
||||
isHiddenByMod: skill.moderationStatus === "hidden" && !isPendingScan && !isMalwareBlocked,
|
||||
isRemoved: skill.moderationStatus === "removed",
|
||||
sourceVersionId: skill.moderationSourceVersionId ?? null,
|
||||
overrideActive: Boolean(skill.manualOverride),
|
||||
verdict: skill.moderationVerdict ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,6 +109,23 @@ export function getPublicSkillVersionAccessBlock(
|
||||
return moderatedVersionId === versionId ? block : null;
|
||||
}
|
||||
|
||||
export function getPublicSkillVersionFileAccessBlock(
|
||||
version: SkillVersionSecurityInfo | null | undefined,
|
||||
moderationInfo?: SkillFileModerationInfo | null,
|
||||
fallbackModeratedVersionId?: Id<"skillVersions"> | string | null,
|
||||
): SkillFileAccessBlock | null {
|
||||
if (version?._id) {
|
||||
const moderationBlock = getPublicSkillVersionAccessBlock(
|
||||
moderationInfo,
|
||||
version._id,
|
||||
fallbackModeratedVersionId,
|
||||
);
|
||||
if (moderationBlock) return moderationBlock;
|
||||
}
|
||||
|
||||
return getVersionSecurityAccessBlock(version, "served");
|
||||
}
|
||||
|
||||
export function getPublicSkillVersionDownloadBlock(
|
||||
moderationInfo: SkillFileModerationInfo | null | undefined,
|
||||
version: SkillVersionSecuritySource,
|
||||
@@ -116,17 +138,30 @@ export function getPublicSkillVersionDownloadBlock(
|
||||
);
|
||||
if (moderationBlock) return moderationBlock;
|
||||
|
||||
const scanStatus = normalizeSkillScanStatus(
|
||||
version.llmAnalysis?.verdict ?? version.llmAnalysis?.status,
|
||||
);
|
||||
if (scanStatus === "malicious") {
|
||||
return getVersionSecurityAccessBlock(version, "downloaded");
|
||||
}
|
||||
|
||||
function getVersionSecurityAccessBlock(
|
||||
version: SkillVersionSecurityInfo | null | undefined,
|
||||
action: "downloaded" | "served" = "served",
|
||||
): SkillFileAccessBlock | null {
|
||||
if (version?.softDeletedAt) {
|
||||
return { status: 410, message: "Version not available" };
|
||||
}
|
||||
if (hasVersionSecurityStatus(version, "malicious")) {
|
||||
return {
|
||||
status: 403,
|
||||
message:
|
||||
"Blocked: this skill version has been flagged as malicious by ClawScan and cannot be downloaded.",
|
||||
`Blocked: this skill version has been flagged as malicious by ClawScan and cannot be ${action}.`,
|
||||
};
|
||||
}
|
||||
if (hasVersionSecurityStatus(version, "pending")) {
|
||||
return {
|
||||
status: 423,
|
||||
message:
|
||||
"This skill version is pending a ClawScan security review. Please try again in a few minutes.",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -149,3 +184,28 @@ export function isPublicSkillVersionAvailableForSkill(
|
||||
) {
|
||||
return Boolean(version && !version.softDeletedAt && isSkillVersionForSkill(version, skillId));
|
||||
}
|
||||
|
||||
function hasVersionSecurityStatus(
|
||||
version: SkillVersionSecurityInfo | null | undefined,
|
||||
status: "malicious" | "pending",
|
||||
) {
|
||||
if (!version) return false;
|
||||
return [
|
||||
version.vtAnalysis?.verdict,
|
||||
version.vtAnalysis?.status,
|
||||
version.llmAnalysis?.verdict,
|
||||
version.llmAnalysis?.status,
|
||||
].some((value) => normalizeVersionSecurityStatus(value) === status);
|
||||
}
|
||||
|
||||
function normalizeVersionSecurityStatus(value: string | null | undefined) {
|
||||
switch (value?.trim().toLowerCase()) {
|
||||
case "malicious":
|
||||
return "malicious";
|
||||
case "pending":
|
||||
case "loading":
|
||||
return "pending";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,11 @@ export type PublishOptions = {
|
||||
skipWebhook?: boolean;
|
||||
ownerPublisherId?: Id<"publishers">;
|
||||
sourceProvenance?: PublishVersionArgs["source"];
|
||||
sourceSync?: {
|
||||
sourceLinkId: Id<"skillSourceLinks">;
|
||||
repositoryId: Id<"publisherGitHubRepositories">;
|
||||
syncJobId?: Id<"githubSkillSyncJobs">;
|
||||
};
|
||||
// Explicit opt-in to owner migration. The `insertVersion` mutation refuses
|
||||
// to rewrite a skill's `ownerPublisherId` unless this is `true`, so default
|
||||
// publishes (including older CLIs that never pass this flag) can never
|
||||
@@ -321,6 +326,7 @@ export async function publishVersionForUser(
|
||||
changelog: changelogText,
|
||||
changelogSource,
|
||||
sourceProvenance: options.sourceProvenance,
|
||||
sourceSync: options.sourceSync,
|
||||
tags: args.tags?.map((tag) => tag.trim()).filter(Boolean),
|
||||
fingerprint,
|
||||
forkOf: args.forkOf
|
||||
|
||||
@@ -2415,6 +2415,170 @@ const githubBackupSyncState = defineTable({
|
||||
updatedAt: v.number(),
|
||||
}).index("by_key", ["key"]);
|
||||
|
||||
const githubAppInstallations = defineTable({
|
||||
installationId: v.string(),
|
||||
accountLogin: v.string(),
|
||||
accountId: v.string(),
|
||||
accountType: v.union(v.literal("User"), v.literal("Organization")),
|
||||
createdByUserId: v.id("users"),
|
||||
suspendedAt: v.optional(v.number()),
|
||||
deletedAt: v.optional(v.number()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_installation_id", ["installationId"])
|
||||
.index("by_account_id", ["accountId"]);
|
||||
|
||||
const publisherGitHubLinks = defineTable({
|
||||
publisherId: v.id("publishers"),
|
||||
installationId: v.string(),
|
||||
githubAppInstallationId: v.id("githubAppInstallations"),
|
||||
linkedByUserId: v.id("users"),
|
||||
deletedAt: v.optional(v.number()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_publisher", ["publisherId"])
|
||||
.index("by_installation_id", ["installationId"])
|
||||
.index("by_publisher_installation_id", ["publisherId", "installationId"]);
|
||||
|
||||
const publisherGitHubRepositories = defineTable({
|
||||
publisherId: v.id("publishers"),
|
||||
githubLinkId: v.id("publisherGitHubLinks"),
|
||||
installationId: v.string(),
|
||||
repoFullName: v.string(),
|
||||
repoId: v.string(),
|
||||
defaultBranch: v.string(),
|
||||
syncRef: v.string(),
|
||||
syncRoots: v.array(v.string()),
|
||||
mode: v.union(v.literal("discover"), v.literal("mapped")),
|
||||
enabled: v.boolean(),
|
||||
lastSyncedCommit: v.optional(v.string()),
|
||||
lastSyncStatus: v.optional(
|
||||
v.union(
|
||||
v.literal("idle"),
|
||||
v.literal("queued"),
|
||||
v.literal("running"),
|
||||
v.literal("succeeded"),
|
||||
v.literal("failed"),
|
||||
),
|
||||
),
|
||||
lastSyncError: v.optional(v.string()),
|
||||
lastSyncedAt: v.optional(v.number()),
|
||||
deletedAt: v.optional(v.number()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_publisher", ["publisherId"])
|
||||
.index("by_installation_id", ["installationId"])
|
||||
.index("by_installation_repo_id", ["installationId", "repoId"])
|
||||
.index("by_repo_full_name", ["repoFullName"])
|
||||
.index("by_enabled_status", ["enabled", "lastSyncStatus"]);
|
||||
|
||||
const skillSourceLinks = defineTable({
|
||||
publisherId: v.id("publishers"),
|
||||
skillId: v.optional(v.id("skills")),
|
||||
repositoryId: v.id("publisherGitHubRepositories"),
|
||||
repoFullName: v.string(),
|
||||
repoId: v.string(),
|
||||
path: v.string(),
|
||||
slug: v.string(),
|
||||
readmePath: v.string(),
|
||||
status: v.union(
|
||||
v.literal("active"),
|
||||
v.literal("conflict"),
|
||||
v.literal("missing"),
|
||||
v.literal("disabled"),
|
||||
),
|
||||
conflictReason: v.optional(v.string()),
|
||||
lastSyncedCommit: v.optional(v.string()),
|
||||
lastSyncedVersionId: v.optional(v.id("skillVersions")),
|
||||
lastFingerprint: v.optional(v.string()),
|
||||
createdByUserId: v.id("users"),
|
||||
disabledAt: v.optional(v.number()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_publisher", ["publisherId"])
|
||||
.index("by_skill", ["skillId"])
|
||||
.index("by_repository", ["repositoryId"])
|
||||
.index("by_repository_path", ["repositoryId", "path"])
|
||||
.index("by_publisher_slug", ["publisherId", "slug"])
|
||||
.index("by_status_updated", ["status", "updatedAt"]);
|
||||
|
||||
const githubSkillSyncJobs = defineTable({
|
||||
publisherId: v.id("publishers"),
|
||||
repositoryId: v.id("publisherGitHubRepositories"),
|
||||
repoFullName: v.string(),
|
||||
ref: v.string(),
|
||||
commit: v.string(),
|
||||
status: v.union(
|
||||
v.literal("queued"),
|
||||
v.literal("running"),
|
||||
v.literal("succeeded"),
|
||||
v.literal("failed"),
|
||||
v.literal("cancelled"),
|
||||
),
|
||||
reason: v.union(
|
||||
v.literal("push"),
|
||||
v.literal("manual"),
|
||||
v.literal("repository_linked"),
|
||||
v.literal("backfill"),
|
||||
),
|
||||
candidateOffset: v.optional(v.number()),
|
||||
requestedByUserId: v.optional(v.id("users")),
|
||||
startedAt: v.optional(v.number()),
|
||||
finishedAt: v.optional(v.number()),
|
||||
error: v.optional(v.string()),
|
||||
counts: v.object({
|
||||
discovered: v.number(),
|
||||
published: v.number(),
|
||||
skipped: v.number(),
|
||||
conflicted: v.number(),
|
||||
missing: v.number(),
|
||||
}),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_repository_status", ["repositoryId", "status"])
|
||||
.index("by_repository_commit", ["repositoryId", "commit"])
|
||||
.index("by_status_created", ["status", "createdAt"]);
|
||||
|
||||
const githubAppSetupStates = defineTable({
|
||||
stateHash: v.string(),
|
||||
publisherId: v.id("publishers"),
|
||||
requestedByUserId: v.id("users"),
|
||||
nonce: v.string(),
|
||||
expiresAt: v.number(),
|
||||
consumedAt: v.optional(v.number()),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_state_hash", ["stateHash"])
|
||||
.index("by_publisher", ["publisherId"])
|
||||
.index("by_expires_at", ["expiresAt"]);
|
||||
|
||||
const githubWebhookDeliveries = defineTable({
|
||||
deliveryId: v.string(),
|
||||
event: v.string(),
|
||||
status: v.union(v.literal("processing"), v.literal("processed"), v.literal("failed")),
|
||||
installationId: v.optional(v.string()),
|
||||
repoId: v.optional(v.string()),
|
||||
error: v.optional(v.string()),
|
||||
receivedAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_delivery_id", ["deliveryId"])
|
||||
.index("by_received_at", ["receivedAt"]);
|
||||
|
||||
const githubAppInstallationClaims = defineTable({
|
||||
installationId: v.string(),
|
||||
accountId: v.string(),
|
||||
senderAccountId: v.string(),
|
||||
event: v.string(),
|
||||
receivedAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
}).index("by_installation_id", ["installationId"]);
|
||||
|
||||
const userSyncRoots = defineTable({
|
||||
userId: v.id("users"),
|
||||
rootId: v.string(),
|
||||
@@ -2540,6 +2704,14 @@ export default defineSchema({
|
||||
reservedSlugs,
|
||||
reservedHandles,
|
||||
githubBackupSyncState,
|
||||
githubAppInstallations,
|
||||
publisherGitHubLinks,
|
||||
publisherGitHubRepositories,
|
||||
skillSourceLinks,
|
||||
githubSkillSyncJobs,
|
||||
githubAppSetupStates,
|
||||
githubWebhookDeliveries,
|
||||
githubAppInstallationClaims,
|
||||
userSyncRoots,
|
||||
userSkillInstalls,
|
||||
userSkillRootInstalls,
|
||||
|
||||
@@ -35,6 +35,7 @@ import { getSkillBadgeMap, getSkillBadgeMaps, isSkillHighlighted } from "./lib/b
|
||||
import { scheduleNextBatchIfNeeded } from "./lib/batching";
|
||||
import { generateChangelogPreview as buildChangelogPreview } from "./lib/changelog";
|
||||
import { embeddingVisibilityFor } from "./lib/embeddingVisibility";
|
||||
import { sourceLinkMatchesProvenance } from "./lib/githubAppSync";
|
||||
import {
|
||||
canHealSkillOwnershipByGitHubProviderAccountId,
|
||||
getGitHubProviderAccountId,
|
||||
@@ -10467,6 +10468,75 @@ export const hardDeleteInternal = internalMutation({
|
||||
},
|
||||
});
|
||||
|
||||
async function enforceSourceManagedPublishBoundary(
|
||||
ctx: MutationCtx,
|
||||
params: {
|
||||
skill: Doc<"skills">;
|
||||
sourceProvenance?: Doc<"skillVersions">["sourceProvenance"];
|
||||
sourceSync?: {
|
||||
sourceLinkId: Id<"skillSourceLinks">;
|
||||
repositoryId: Id<"publisherGitHubRepositories">;
|
||||
syncJobId?: Id<"githubSkillSyncJobs">;
|
||||
};
|
||||
},
|
||||
) {
|
||||
let links: Doc<"skillSourceLinks">[];
|
||||
try {
|
||||
links = await ctx.db
|
||||
.query("skillSourceLinks")
|
||||
.withIndex("by_skill", (q) => q.eq("skillId", params.skill._id))
|
||||
.collect();
|
||||
} catch (error) {
|
||||
if (isMissingSourceLinkTableError(error)) return;
|
||||
throw error;
|
||||
}
|
||||
const managedLinks = links.filter((link) => link.status !== "disabled");
|
||||
if (managedLinks.length === 0) return;
|
||||
|
||||
const matchingLink = managedLinks.find((link) =>
|
||||
sourceLinkMatchesProvenance({
|
||||
link,
|
||||
sourceProvenance: params.sourceProvenance,
|
||||
sourceSync: params.sourceSync,
|
||||
expectedSourceLinkId: link._id,
|
||||
}),
|
||||
);
|
||||
if (matchingLink) return;
|
||||
|
||||
throw new ConvexError(
|
||||
"This skill is managed by GitHub sync. Unlink source management before publishing manually.",
|
||||
);
|
||||
}
|
||||
|
||||
async function assertSourceSyncCanPublish(
|
||||
ctx: MutationCtx,
|
||||
sourceSync:
|
||||
| {
|
||||
sourceLinkId: Id<"skillSourceLinks">;
|
||||
repositoryId: Id<"publisherGitHubRepositories">;
|
||||
syncJobId?: Id<"githubSkillSyncJobs">;
|
||||
}
|
||||
| undefined,
|
||||
) {
|
||||
if (!sourceSync) return;
|
||||
const link = await ctx.db.get(sourceSync.sourceLinkId);
|
||||
if (!link || link.status === "disabled" || link.repositoryId !== sourceSync.repositoryId) {
|
||||
throw new ConvexError("Source link is disabled");
|
||||
}
|
||||
const repo = await ctx.db.get(sourceSync.repositoryId);
|
||||
if (!repo || repo.deletedAt || !repo.enabled) {
|
||||
throw new ConvexError("GitHub repository link is disabled");
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingSourceLinkTableError(error: unknown) {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
(/unexpected (query )?table:? skillSourceLinks/i.test(error.message) ||
|
||||
error.message === "__owner_migration_sentinel_stop__")
|
||||
);
|
||||
}
|
||||
|
||||
export const insertVersion = internalMutation({
|
||||
args: {
|
||||
userId: v.id("users"),
|
||||
@@ -10497,6 +10567,13 @@ export const insertVersion = internalMutation({
|
||||
importedAt: v.number(),
|
||||
}),
|
||||
),
|
||||
sourceSync: v.optional(
|
||||
v.object({
|
||||
sourceLinkId: v.id("skillSourceLinks"),
|
||||
repositoryId: v.id("publisherGitHubRepositories"),
|
||||
syncJobId: v.optional(v.id("githubSkillSyncJobs")),
|
||||
}),
|
||||
),
|
||||
tags: v.optional(v.array(v.string())),
|
||||
fingerprint: v.string(),
|
||||
bypassNewSkillRateLimit: v.optional(v.boolean()),
|
||||
@@ -10599,12 +10676,21 @@ export const insertVersion = internalMutation({
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
await assertSourceSyncCanPublish(ctx, args.sourceSync);
|
||||
|
||||
let skill = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_slug", (q) => q.eq("slug", normalizedSlug))
|
||||
.unique();
|
||||
|
||||
if (skill) {
|
||||
await enforceSourceManagedPublishBoundary(ctx, {
|
||||
skill,
|
||||
sourceProvenance: args.sourceProvenance,
|
||||
sourceSync: args.sourceSync,
|
||||
});
|
||||
}
|
||||
|
||||
if (skill && skill.softDeletedAt && !(await canUserManageSkillOwner(ctx, skill, userId))) {
|
||||
const unpublishedReservationExpiresAt = await getUnpublishedSlugReservationExpiresAt(
|
||||
ctx,
|
||||
|
||||
@@ -21,6 +21,7 @@ into `docs/` and leave only the design record here.
|
||||
- `orgs.md`: org, publisher membership, and scoped identity plan.
|
||||
- `github-import.md`: GitHub import feature spec.
|
||||
- `github-backed-skills.md`: source-backed GitHub skills catalog and install invariants.
|
||||
- `github-app-skill-sync.md`: GitHub App org repository sync plan for source-managed skills.
|
||||
- `diffing.md`: skill version diffing UI/API design.
|
||||
- `slug-routing.md`: internal web route precedence and plugin alias contract.
|
||||
- `ci.md`: PR check and production deploy audit-tag policy.
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
---
|
||||
summary: "Durable invariants for GitHub App repository sync of source-managed skills."
|
||||
read_when:
|
||||
- Changing GitHub App install, webhook, or repository sync behavior
|
||||
- Changing source-managed skill publish boundaries
|
||||
- Reviewing org/manual skill ownership interactions
|
||||
---
|
||||
|
||||
# GitHub App Skill Sync
|
||||
|
||||
This note captures the behavior that must survive refactors. Public setup docs
|
||||
belong in `docs/`; this file is for maintainer-facing invariants and trust
|
||||
boundaries.
|
||||
|
||||
## Core Invariant
|
||||
|
||||
Linking a ClawHub org to GitHub does not make every org skill source-managed.
|
||||
Only skills with a non-disabled `skillSourceLinks` row for a linked repository
|
||||
path are controlled by GitHub sync.
|
||||
|
||||
Manual, CLI-managed, imported, or otherwise non-source-linked skills can remain
|
||||
in the same org. Repository sync must not create, update, delete, rename,
|
||||
restore, or retag those skills unless an org admin explicitly adopts the skill
|
||||
into a source link first.
|
||||
|
||||
## Source Ownership
|
||||
|
||||
- A repository sync job may publish only through an exact source link for that
|
||||
repository and path.
|
||||
- A manual publish to a source-managed skill is rejected unless the source link
|
||||
has been disabled first.
|
||||
- Disabled source links are no longer authoritative. After unlinking, normal
|
||||
manual publish behavior applies.
|
||||
- A discovered repository candidate that collides with an existing manual org
|
||||
skill slug becomes a source-link conflict. It must not overwrite the manual
|
||||
skill.
|
||||
- Adopting an existing manual skill into sync requires admin rights on the
|
||||
publisher, a matching skill owner publisher, a matching slug, and no other
|
||||
non-disabled source link for that skill.
|
||||
- GitHub sync still uses the normal publish pipeline, including parser,
|
||||
scanner, quality, moderation, ownership, and version rules. Sync must not lift
|
||||
moderation or bypass bans.
|
||||
|
||||
## Install Handshake
|
||||
|
||||
The GitHub App setup handshake is intentionally multi-step:
|
||||
|
||||
1. A publisher admin starts setup from ClawHub.
|
||||
2. ClawHub creates a short-lived HMAC-signed state and stores only a hash plus
|
||||
nonce, publisher id, requesting user id, optional target GitHub account id,
|
||||
and expiry.
|
||||
3. GitHub redirects back with an installation id and the signed state.
|
||||
4. ClawHub verifies the state signature, stored hash, nonce, expiry,
|
||||
single-consumption status, requesting user, and current publisher admin role.
|
||||
5. ClawHub fetches the installation with a GitHub App JWT.
|
||||
6. User-account installs are accepted only when the installation account id
|
||||
matches the current ClawHub user's GitHub provider account id.
|
||||
7. Organization installs require a target GitHub account id in the setup state
|
||||
and a signed GitHub installation webhook claim whose installation account id
|
||||
matches that target and whose sender account id matches the current ClawHub
|
||||
user's GitHub provider account id.
|
||||
|
||||
The webhook claim is what ties an org installation redirect to the GitHub user
|
||||
who performed the install. If the redirect outruns the webhook, completion
|
||||
should fail cleanly and can be retried after the webhook arrives.
|
||||
|
||||
Repository links created by an installation are disabled by default. A publisher
|
||||
admin must explicitly enable sync for each repository.
|
||||
|
||||
## UI Rollout Gate
|
||||
|
||||
During internal rollout, the ClawHub UI must expose GitHub Sync controls only to
|
||||
users who are both:
|
||||
|
||||
- owners or admins of the target ClawHub org publisher; and
|
||||
- members of the `openclaw` ClawHub publisher.
|
||||
|
||||
This gate is intentionally a rollout/discoverability gate, not the durable
|
||||
authorization boundary. Backend GitHub sync functions must continue to enforce
|
||||
the target publisher admin role themselves.
|
||||
|
||||
## Runtime Proof Before Wide Release
|
||||
|
||||
Before removing the internal UI rollout gate or treating the feature as broadly
|
||||
available, capture redacted proof from a real configured GitHub App install:
|
||||
|
||||
1. Start setup from an eligible ClawHub org publisher and record the generated
|
||||
GitHub App install URL with the signed `state` value redacted.
|
||||
2. Complete an org installation and confirm the callback succeeds only after the
|
||||
matching signed installation webhook claim is received.
|
||||
3. Confirm newly discovered repositories are present but disabled by default.
|
||||
4. Enable one repository, configure branch/roots, and queue a manual sync.
|
||||
5. Push a commit to the configured ref and confirm GitHub webhook delivery
|
||||
queues or coalesces the repository sync job.
|
||||
6. Confirm the sync creates or updates the expected source-managed skill version
|
||||
through the normal publish/scanner pipeline.
|
||||
7. Attempt a manual publish to the active source-managed skill and confirm it is
|
||||
rejected until the source link is disabled.
|
||||
8. Remove or suspend repository access and confirm affected repository/source
|
||||
links are disabled while unrelated manual org skills remain untouched.
|
||||
|
||||
Acceptable PR proof is redacted terminal output, Convex logs, GitHub webhook
|
||||
delivery screenshots, ClawHub UI screenshots, or a short recording that covers
|
||||
the install, webhook claim, repository enablement, sync publish, and manual
|
||||
publish blocking path.
|
||||
|
||||
## Webhook Trust Boundary
|
||||
|
||||
- Verify `X-Hub-Signature-256` for every GitHub webhook.
|
||||
- Deduplicate by `X-GitHub-Delivery`, but mark a delivery processed only after
|
||||
event work succeeds. Failed deliveries stay retryable.
|
||||
- Treat GitHub installation id and repository id as stable identities. Repo
|
||||
names may change.
|
||||
- Repository rename events must update both repository rows and source-link
|
||||
`repoFullName` values so source provenance checks continue to match.
|
||||
- Repository removal, installation deletion, and installation suspension disable
|
||||
affected repository links and their non-disabled source links. Manual org
|
||||
skills remain untouched.
|
||||
|
||||
## Sync Semantics
|
||||
|
||||
- Push webhooks queue sync only for enabled repository links whose configured
|
||||
ref matches the pushed branch/ref.
|
||||
- Deletion pushes with GitHub's all-zero `after` SHA are ignored.
|
||||
- Older queued push jobs for the same repository/ref are cancelled when a newer
|
||||
push is queued.
|
||||
- Sync jobs are serialized per repository so concurrent pushes cannot derive
|
||||
and publish the same next version.
|
||||
- Sync downloads the GitHub archive for the exact commit being processed.
|
||||
- Candidate discovery can scan all configured roots to mark missing source
|
||||
links correctly, but publishing work may be capped per job.
|
||||
- If candidate file fingerprints are unchanged, sync validates that the source
|
||||
link is still publishable and then records the latest commit without storing
|
||||
duplicate blobs or creating a new version.
|
||||
- On failure, repository metadata must preserve the last successful sync commit
|
||||
and timestamp.
|
||||
|
||||
## Version Security Boundary
|
||||
|
||||
Each GitHub sync publish creates an ordinary `skillVersions` row and queues the
|
||||
normal security scanners for that exact version. Scanner completion must only
|
||||
promote scan outcomes onto the skill-level moderation row when the scanned
|
||||
version is still the skill's latest version, so an older async scan result
|
||||
cannot overwrite the current latest verdict.
|
||||
|
||||
Public artifact access must still enforce the requested version's own scan
|
||||
state. Download, card, raw file, and version-detail endpoints must block a
|
||||
historical version when that version's stored ClawScan or VirusTotal fields are
|
||||
malicious or explicitly pending, even if a newer latest version currently
|
||||
leaves the skill active. Static-scan-only findings remain advisory when
|
||||
ClawScan/VT do not block the version. Scan and verification endpoints may
|
||||
remain readable for blocked versions so users and automation can inspect the
|
||||
reason.
|
||||
|
||||
## Required GitHub App Shape
|
||||
|
||||
Minimum GitHub App permissions:
|
||||
|
||||
- Contents: read-only
|
||||
- Metadata: read-only
|
||||
|
||||
Webhook events:
|
||||
|
||||
- `push`
|
||||
- `installation`
|
||||
- `installation_repositories`
|
||||
- `repository`
|
||||
|
||||
Required Convex environment:
|
||||
|
||||
- `GITHUB_APP_ID`
|
||||
- `GITHUB_APP_SLUG`
|
||||
- `GITHUB_APP_PRIVATE_KEY`
|
||||
- `GITHUB_APP_WEBHOOK_SECRET`
|
||||
- optional `GITHUB_APP_STATE_SECRET`
|
||||
@@ -0,0 +1,155 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Doc, Id } from "../../convex/_generated/dataModel";
|
||||
import type { PublicPublisherListItem } from "../lib/publicUser";
|
||||
|
||||
const { actionMock, mutationMock } = vi.hoisted(() => ({
|
||||
actionMock: vi.fn(),
|
||||
mutationMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
useAction: () => actionMock,
|
||||
useMutation: () => mutationMock,
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute: () => (config: unknown) => ({ __config: config }),
|
||||
Link: ({ children, to }: { children: React.ReactNode; to?: string }) => (
|
||||
<a href={to}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("GitHubSyncPanel", () => {
|
||||
beforeEach(() => {
|
||||
actionMock.mockReset();
|
||||
mutationMock.mockReset();
|
||||
actionMock.mockResolvedValue({ url: "https://github.com/apps/clawhub/installations/new" });
|
||||
mutationMock.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it("renders repository controls and submits sync settings", async () => {
|
||||
const { GitHubSyncPanel } = await import("../routes/user/$handle");
|
||||
|
||||
render(<GitHubSyncPanel publisher={publisher} repositories={[repository]} />);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "GitHub Sync" })).toBeTruthy();
|
||||
expect(screen.getByText("openclaw/skills")).toBeTruthy();
|
||||
expect(screen.getByLabelText("GitHub account ID")).toBeTruthy();
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue("main"), { target: { value: "stable" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /save/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mutationMock).toHaveBeenCalledWith({
|
||||
repositoryId: "publisherGitHubRepositories:repo",
|
||||
syncRef: "stable",
|
||||
syncRoots: ["skills"],
|
||||
mode: "discover",
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("queues a repository sync", async () => {
|
||||
const { GitHubSyncPanel } = await import("../routes/user/$handle");
|
||||
|
||||
render(<GitHubSyncPanel publisher={publisher} repositories={[repository]} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /sync/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mutationMock).toHaveBeenCalledWith({
|
||||
repositoryId: "publisherGitHubRepositories:repo",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("canManagePublisherGitHubSync", () => {
|
||||
it("allows target org admins who are openclaw members", async () => {
|
||||
const { canManagePublisherGitHubSync } = await import("../routes/user/$handle");
|
||||
|
||||
expect(
|
||||
canManagePublisherGitHubSync(targetOrgPublisher, [
|
||||
{ publisher: targetOrgPublisher, role: "admin" },
|
||||
{ publisher: openclawPublisher, role: "publisher" },
|
||||
]),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("hides sync from target org admins outside the openclaw rollout", async () => {
|
||||
const { canManagePublisherGitHubSync } = await import("../routes/user/$handle");
|
||||
|
||||
expect(
|
||||
canManagePublisherGitHubSync(targetOrgPublisher, [
|
||||
{ publisher: targetOrgPublisher, role: "admin" },
|
||||
]),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("hides sync from openclaw members without target org admin rights", async () => {
|
||||
const { canManagePublisherGitHubSync } = await import("../routes/user/$handle");
|
||||
|
||||
expect(
|
||||
canManagePublisherGitHubSync(targetOrgPublisher, [
|
||||
{ publisher: targetOrgPublisher, role: "publisher" },
|
||||
{ publisher: openclawPublisher, role: "owner" },
|
||||
]),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
const publisher: PublicPublisherListItem = {
|
||||
_id: "publishers:org" as Id<"publishers">,
|
||||
_creationTime: 1,
|
||||
kind: "org",
|
||||
handle: "openclaw",
|
||||
displayName: "OpenClaw",
|
||||
image: undefined,
|
||||
bio: undefined,
|
||||
linkedUserId: undefined,
|
||||
stats: { skills: 0, packages: 0, installs: 0, downloads: 0, stars: 0 },
|
||||
publishedItems: [],
|
||||
};
|
||||
|
||||
const repository: Doc<"publisherGitHubRepositories"> & { sourceLinkCount: number } = {
|
||||
_id: "publisherGitHubRepositories:repo" as Id<"publisherGitHubRepositories">,
|
||||
_creationTime: 1,
|
||||
publisherId: "publishers:org" as Id<"publishers">,
|
||||
githubLinkId: "publisherGitHubLinks:link" as Id<"publisherGitHubLinks">,
|
||||
installationId: "123",
|
||||
repoFullName: "openclaw/skills",
|
||||
repoId: "456",
|
||||
defaultBranch: "main",
|
||||
syncRef: "main",
|
||||
syncRoots: ["skills"],
|
||||
mode: "discover",
|
||||
enabled: true,
|
||||
lastSyncStatus: "idle",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
sourceLinkCount: 2,
|
||||
};
|
||||
|
||||
const targetOrgPublisher: PublicPublisherListItem = {
|
||||
...publisher,
|
||||
_id: "publishers:target" as Id<"publishers">,
|
||||
handle: "target-org",
|
||||
displayName: "Target Org",
|
||||
};
|
||||
|
||||
const openclawPublisher: PublicPublisherListItem = {
|
||||
...publisher,
|
||||
_id: "publishers:openclaw" as Id<"publishers">,
|
||||
handle: "openclaw",
|
||||
displayName: "OpenClaw",
|
||||
};
|
||||
@@ -26,6 +26,26 @@ describe("SkillCard", () => {
|
||||
expect(screen.queryByText("Official")).toBeNull();
|
||||
expect(container.querySelector(".official-badge")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps badges after the title and summary", () => {
|
||||
const { container } = render(
|
||||
<SkillCard
|
||||
skill={makeSkill()}
|
||||
badge="Official"
|
||||
summaryFallback="Fallback summary"
|
||||
meta={<span>meta</span>}
|
||||
/>,
|
||||
);
|
||||
|
||||
const title = container.querySelector(".skill-card-title");
|
||||
const summary = container.querySelector(".skill-card-summary");
|
||||
const tags = container.querySelector(".skill-card-tags");
|
||||
expect(title).toBeTruthy();
|
||||
expect(summary).toBeTruthy();
|
||||
expect(tags).toBeTruthy();
|
||||
expect(title!.compareDocumentPosition(tags!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(summary!.compareDocumentPosition(tags!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
function makeSkill(): PublicSkill {
|
||||
|
||||
+281
-2
@@ -1,16 +1,22 @@
|
||||
import { createFileRoute, Link, notFound } from "@tanstack/react-router";
|
||||
import { usePaginatedQuery, useQuery } from "convex/react";
|
||||
import { useAction, useMutation, usePaginatedQuery, useQuery } from "convex/react";
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
Building2,
|
||||
GitBranch,
|
||||
Package,
|
||||
PlugZap,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Star,
|
||||
Users,
|
||||
Wrench,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { type FormEvent, type ReactNode, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import type { Doc } from "../../../convex/_generated/dataModel";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { Container } from "../../components/layout/Container";
|
||||
import { MarketplaceIcon } from "../../components/MarketplaceIcon";
|
||||
@@ -18,7 +24,10 @@ import { OfficialBadge, OfficialTag } from "../../components/OfficialBadge";
|
||||
import { BrowseResultsSkeleton } from "../../components/skeletons/BrowseResultsSkeleton";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Card, CardContent } from "../../components/ui/card";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Skeleton } from "../../components/ui/skeleton";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
import { getUserFacingConvexError } from "../../lib/convexError";
|
||||
import { formatCompactStat } from "../../lib/numberFormat";
|
||||
import { buildPublisherMeta } from "../../lib/og";
|
||||
import type {
|
||||
@@ -77,6 +86,16 @@ type PublisherMemberResult = {
|
||||
|
||||
type PublishedView = "list" | "grid";
|
||||
type ProfileCatalogTab = "skills" | "plugins" | "stars";
|
||||
type PublisherMembership = {
|
||||
publisher: PublicPublisher;
|
||||
role: "owner" | "admin" | "publisher";
|
||||
};
|
||||
type GitHubRepositoryLink = Doc<"publisherGitHubRepositories"> & {
|
||||
sourceLinkCount: number;
|
||||
};
|
||||
const INTERNAL_GITHUB_SYNC_PUBLISHER_HANDLE = "openclaw";
|
||||
const GITHUB_INSTALL_RETRY_DELAY_MS = 3_000;
|
||||
const GITHUB_INSTALL_MAX_RETRIES = 20;
|
||||
|
||||
const roleColor: Record<string, "accent" | "default" | "compact"> = {
|
||||
owner: "accent",
|
||||
@@ -84,6 +103,25 @@ const roleColor: Record<string, "accent" | "default" | "compact"> = {
|
||||
publisher: "compact",
|
||||
};
|
||||
|
||||
export function canManagePublisherGitHubSync(
|
||||
publisher: PublicPublisherListItem | null | undefined,
|
||||
memberships: PublisherMembership[] | undefined,
|
||||
) {
|
||||
const canManageViewedOrg =
|
||||
publisher?.kind === "org" &&
|
||||
Boolean(
|
||||
memberships?.some(
|
||||
(entry) =>
|
||||
entry.publisher._id === publisher._id &&
|
||||
(entry.role === "owner" || entry.role === "admin"),
|
||||
),
|
||||
);
|
||||
const isInternalRolloutUser = Boolean(
|
||||
memberships?.some((entry) => entry.publisher.handle === INTERNAL_GITHUB_SYNC_PUBLISHER_HANDLE),
|
||||
);
|
||||
return canManageViewedOrg && isInternalRolloutUser;
|
||||
}
|
||||
|
||||
function GitHubIcon({ size = 14 }: { size?: number }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width={size} height={size} aria-hidden="true">
|
||||
@@ -113,6 +151,7 @@ function PublisherProfile() {
|
||||
api.publishers.getPublishedDisplayManifest,
|
||||
publishedQueryArgs,
|
||||
) as PublicPublisherCatalogDisplay | null | undefined;
|
||||
const myPublishers = useQuery(api.publishers.listMine, {}) as PublisherMembership[] | undefined;
|
||||
const members = useQuery(api.publishers.listMembers, { publisherHandle: handle }) as
|
||||
| PublisherMemberResult
|
||||
| null
|
||||
@@ -137,6 +176,60 @@ function PublisherProfile() {
|
||||
);
|
||||
const publishedItems = (publishedResults ?? []) as PublicPublisherCatalogItem[];
|
||||
const starredItems = (starredResults ?? []) as PublicPublisherCatalogItem[];
|
||||
const canManageGitHubSync = canManagePublisherGitHubSync(publisher, myPublishers);
|
||||
const githubRepositories = useQuery(
|
||||
api.githubApp.listPublisherRepositories,
|
||||
canManageGitHubSync && publisher ? { publisherId: publisher._id } : "skip",
|
||||
) as GitHubRepositoryLink[] | undefined;
|
||||
const completePublisherInstall = useAction(api.githubApp.completePublisherInstall);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canManageGitHubSync) return undefined;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const state = params.get("state");
|
||||
const installationId = params.get("installation_id");
|
||||
if (!state || !installationId) return undefined;
|
||||
let cancelled = false;
|
||||
let attempts = 0;
|
||||
let showedPendingToast = false;
|
||||
let retryTimer: number | undefined;
|
||||
|
||||
const tryCompleteInstall = () => {
|
||||
attempts += 1;
|
||||
completePublisherInstall({ state, installationId })
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
toast.success(`Connected ${result.repositories.length} GitHub repositories`);
|
||||
const next = new URL(window.location.href);
|
||||
next.searchParams.delete("state");
|
||||
next.searchParams.delete("installation_id");
|
||||
next.searchParams.delete("setup_action");
|
||||
window.history.replaceState(null, "", `${next.pathname}${next.search}${next.hash}`);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
const message = getUserFacingConvexError(error, "GitHub App connection failed");
|
||||
const shouldRetry =
|
||||
message.includes("GitHub installation confirmation is still pending") &&
|
||||
attempts < GITHUB_INSTALL_MAX_RETRIES;
|
||||
if (shouldRetry) {
|
||||
if (!showedPendingToast) {
|
||||
toast.error(`${message} Retrying...`);
|
||||
showedPendingToast = true;
|
||||
}
|
||||
retryTimer = window.setTimeout(tryCompleteInstall, GITHUB_INSTALL_RETRY_DELAY_MS);
|
||||
return;
|
||||
}
|
||||
toast.error(message);
|
||||
});
|
||||
};
|
||||
|
||||
tryCompleteInstall();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (retryTimer) window.clearTimeout(retryTimer);
|
||||
};
|
||||
}, [canManageGitHubSync, completePublisherInstall]);
|
||||
|
||||
if (publisher === undefined) {
|
||||
return (
|
||||
@@ -353,6 +446,10 @@ function PublisherProfile() {
|
||||
</aside>
|
||||
|
||||
<section className="publisher-profile-main" aria-labelledby="publisher-published-title">
|
||||
{canManageGitHubSync ? (
|
||||
<GitHubSyncPanel publisher={publisher} repositories={githubRepositories} />
|
||||
) : null}
|
||||
|
||||
<div className="publisher-profile-section-header">
|
||||
<div>
|
||||
<h2 id="publisher-published-title" className="sr-only">
|
||||
@@ -430,6 +527,188 @@ function PublisherProfile() {
|
||||
);
|
||||
}
|
||||
|
||||
export function GitHubSyncPanel({
|
||||
publisher,
|
||||
repositories,
|
||||
}: {
|
||||
publisher: PublicPublisherListItem;
|
||||
repositories: GitHubRepositoryLink[] | undefined;
|
||||
}) {
|
||||
const beginPublisherInstall = useAction(api.githubApp.beginPublisherInstall);
|
||||
const [targetId, setTargetId] = useState("");
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
|
||||
const connect = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const trimmedTargetId = targetId.trim();
|
||||
if (!trimmedTargetId) {
|
||||
toast.error("GitHub account ID is required");
|
||||
return;
|
||||
}
|
||||
setConnecting(true);
|
||||
try {
|
||||
const result = await beginPublisherInstall({
|
||||
publisherId: publisher._id,
|
||||
targetId: trimmedTargetId,
|
||||
});
|
||||
window.location.assign(result.url);
|
||||
} catch (error) {
|
||||
setConnecting(false);
|
||||
toast.error(getUserFacingConvexError(error, "GitHub App setup failed"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="publisher-profile-github-panel" aria-labelledby="publisher-github-title">
|
||||
<div className="publisher-profile-github-header">
|
||||
<div>
|
||||
<h2 id="publisher-github-title">GitHub Sync</h2>
|
||||
<span>{repositories === undefined ? "Loading" : `${repositories.length} repos`}</span>
|
||||
</div>
|
||||
<form className="publisher-profile-github-connect" onSubmit={connect}>
|
||||
<Input
|
||||
value={targetId}
|
||||
onChange={(event) => setTargetId(event.target.value)}
|
||||
inputMode="numeric"
|
||||
placeholder="GitHub account ID"
|
||||
aria-label="GitHub account ID"
|
||||
/>
|
||||
<Button type="submit" size="sm" loading={connecting}>
|
||||
<PlugZap size={14} aria-hidden="true" />
|
||||
Connect
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{repositories === undefined ? (
|
||||
<div className="publisher-profile-github-loading" role="status">
|
||||
Loading GitHub repositories...
|
||||
</div>
|
||||
) : repositories.length > 0 ? (
|
||||
<div className="publisher-profile-github-repos">
|
||||
{repositories.map((repo) => (
|
||||
<GitHubRepositoryRow key={repo._id} repo={repo} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="publisher-profile-empty-copy">No GitHub repositories connected.</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function GitHubRepositoryRow({ repo }: { repo: GitHubRepositoryLink }) {
|
||||
const updateRepositorySyncSettings = useMutation(api.githubApp.updateRepositorySyncSettings);
|
||||
const queueRepositorySync = useMutation(api.githubApp.queueRepositorySync);
|
||||
const [syncRef, setSyncRef] = useState(repo.syncRef);
|
||||
const [syncRoots, setSyncRoots] = useState(repo.syncRoots.join("\n"));
|
||||
const [mode, setMode] = useState<Doc<"publisherGitHubRepositories">["mode"]>(repo.mode);
|
||||
const [enabled, setEnabled] = useState(repo.enabled);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSyncRef(repo.syncRef);
|
||||
setSyncRoots(repo.syncRoots.join("\n"));
|
||||
setMode(repo.mode);
|
||||
setEnabled(repo.enabled);
|
||||
}, [repo._id, repo.enabled, repo.mode, repo.syncRef, repo.syncRoots]);
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateRepositorySyncSettings({
|
||||
repositoryId: repo._id,
|
||||
syncRef,
|
||||
syncRoots: syncRoots
|
||||
.split("\n")
|
||||
.map((root) => root.trim())
|
||||
.filter(Boolean),
|
||||
mode,
|
||||
enabled,
|
||||
});
|
||||
toast.success("GitHub repository settings saved");
|
||||
} catch (error) {
|
||||
toast.error(getUserFacingConvexError(error, "Repository settings failed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const syncNow = async () => {
|
||||
setSyncing(true);
|
||||
try {
|
||||
await queueRepositorySync({ repositoryId: repo._id });
|
||||
toast.success("GitHub sync queued");
|
||||
} catch (error) {
|
||||
toast.error(getUserFacingConvexError(error, "GitHub sync failed"));
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<article className="publisher-profile-github-repo">
|
||||
<div className="publisher-profile-github-repo-heading">
|
||||
<div>
|
||||
<h3>{repo.repoFullName}</h3>
|
||||
<span>
|
||||
{repo.sourceLinkCount} linked · {repo.lastSyncStatus}
|
||||
</span>
|
||||
</div>
|
||||
<label className="publisher-profile-github-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(event) => setEnabled(event.target.checked)}
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="publisher-profile-github-grid">
|
||||
<label>
|
||||
<span>Branch</span>
|
||||
<Input value={syncRef} onChange={(event) => setSyncRef(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Mode</span>
|
||||
<select
|
||||
className="publisher-profile-github-select"
|
||||
value={mode}
|
||||
onChange={(event) => setMode(event.target.value === "mapped" ? "mapped" : "discover")}
|
||||
>
|
||||
<option value="discover">Discover</option>
|
||||
<option value="mapped">Mapped</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="publisher-profile-github-roots">
|
||||
<span>Roots</span>
|
||||
<Textarea value={syncRoots} onChange={(event) => setSyncRoots(event.target.value)} />
|
||||
</label>
|
||||
|
||||
<div className="publisher-profile-github-repo-footer">
|
||||
<span>
|
||||
<GitBranch size={14} aria-hidden="true" />
|
||||
{repo.lastSyncedCommit ? repo.lastSyncedCommit.slice(0, 7) : repo.defaultBranch}
|
||||
</span>
|
||||
<div>
|
||||
<Button type="button" size="sm" variant="outline" onClick={syncNow} loading={syncing}>
|
||||
<RefreshCw size={14} aria-hidden="true" />
|
||||
Sync
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={save} loading={saving}>
|
||||
<Save size={14} aria-hidden="true" />
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function PublisherStat({
|
||||
icon: Icon,
|
||||
value,
|
||||
|
||||
+191
@@ -12727,6 +12727,156 @@ a.publisher-profile-detail:hover {
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.publisher-profile-github-panel {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding-bottom: 22px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.publisher-profile-github-header {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.publisher-profile-github-header > div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.publisher-profile-github-header h2 {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-size: var(--fs-md);
|
||||
font-weight: 750;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.publisher-profile-github-header span,
|
||||
.publisher-profile-github-loading,
|
||||
.publisher-profile-github-repo-heading span,
|
||||
.publisher-profile-github-repo-footer span {
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.publisher-profile-github-connect {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(150px, 210px) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.publisher-profile-github-connect input {
|
||||
min-height: 34px;
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.publisher-profile-github-repos {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.publisher-profile-github-repo {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.publisher-profile-github-repo-heading,
|
||||
.publisher-profile-github-repo-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.publisher-profile-github-repo-heading > div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.publisher-profile-github-repo h3 {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: var(--ink);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 750;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.publisher-profile-github-toggle {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--ink);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.publisher-profile-github-toggle input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.publisher-profile-github-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 150px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.publisher-profile-github-grid label,
|
||||
.publisher-profile-github-roots {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.publisher-profile-github-grid label span,
|
||||
.publisher-profile-github-roots span {
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.publisher-profile-github-grid input,
|
||||
.publisher-profile-github-select {
|
||||
min-height: 36px;
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.publisher-profile-github-select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--input-bg);
|
||||
color: var(--ink);
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.publisher-profile-github-roots textarea {
|
||||
min-height: 76px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.publisher-profile-github-repo-footer span,
|
||||
.publisher-profile-github-repo-footer div {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.publisher-profile-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -13337,6 +13487,25 @@ a.publisher-profile-detail:hover {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.publisher-profile-section-controls {
|
||||
width: 100%;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.publisher-profile-github-header,
|
||||
.publisher-profile-github-repo-heading,
|
||||
.publisher-profile-github-repo-footer {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.publisher-profile-github-connect {
|
||||
width: 100%;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
@@ -13382,6 +13551,28 @@ a.publisher-profile-detail:hover {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.publisher-profile-section-controls {
|
||||
width: 100%;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.publisher-profile-github-connect,
|
||||
.publisher-profile-github-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.publisher-profile-github-connect button,
|
||||
.publisher-profile-github-repo-footer div,
|
||||
.publisher-profile-github-repo-footer button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.publisher-profile-github-repo-footer div {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.publisher-published-row {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user