Compare commits

...
21 changed files with 4001 additions and 56 deletions
+4
View File
@@ -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;
+3 -1
View File
@@ -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",
},
};
}
-7
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -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,
+49
View File
@@ -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");
+15 -12
View File
@@ -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)
+17 -17
View File
@@ -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();
+180
View File
@@ -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("")}`;
}
+183
View File
@@ -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();
}
+204
View File
@@ -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;
}
+77 -17
View File
@@ -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;
}
}
+6
View File
@@ -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
+172
View File
@@ -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,
+86
View File
@@ -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,
+1
View File
@@ -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.
+175
View File
@@ -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",
};
+20
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}