aboutsummaryrefslogtreecommitdiff
path: root/workers/plugin-marketplace/src/index.ts
diff options
context:
space:
mode:
Diffstat (limited to 'workers/plugin-marketplace/src/index.ts')
-rw-r--r--workers/plugin-marketplace/src/index.ts855
1 files changed, 769 insertions, 86 deletions
diff --git a/workers/plugin-marketplace/src/index.ts b/workers/plugin-marketplace/src/index.ts
index e4523340..a504e1af 100644
--- a/workers/plugin-marketplace/src/index.ts
+++ b/workers/plugin-marketplace/src/index.ts
@@ -1,12 +1,32 @@
+import { parse } from "smol-toml";
+
const SNAPSHOT_KEY = "plugins/index.json";
+const SCAN_CACHE_KEY = "plugins/scan-cache.json";
const SNAPSHOT_CACHE_CONTROL = "public, max-age=300, s-maxage=1800, stale-while-revalidate=3600";
const GITHUB_QUERY = "topic:herdr-plugin is:public";
const GITHUB_API_VERSION = "2022-11-28";
-const GITHUB_SEARCH_URL = "https://api.github.com/search/repositories";
+const GITHUB_API_URL = "https://api.github.com";
+const GITHUB_SEARCH_URL = `${GITHUB_API_URL}/search/repositories`;
+const GITHUB_GRAPHQL_URL = `${GITHUB_API_URL}/graphql`;
const BLACKLIST_REPO_KEY_PREFIX = "repo:";
+const PLUGIN_MANIFEST_FILE = "herdr-plugin.toml";
const PER_PAGE = 100;
const MAX_REPOS = 1000;
+const GRAPHQL_BATCH_SIZE = 50;
+const NETWORK_CONCURRENCY = 5;
+const MANIFEST_MAX_BYTES = 32 * 1024;
+const MAX_TREE_ENTRIES = 20_000;
+const MAX_MANIFESTS_PER_REPOSITORY = 100;
+const MAX_TOTAL_MANIFESTS = 5000;
+const MAX_TOTAL_MANIFEST_BYTES = 16 * 1024 * 1024;
+const PLUGIN_NAME_MAX_CHARS = 120;
+const PLUGIN_VERSION_MAX_CHARS = 64;
+const PLUGIN_DESCRIPTION_MAX_CHARS = 500;
const REQUEST_TIMEOUT_MS = 10_000;
+const SCAN_CACHE_SCHEMA_VERSION = 1;
+
+const PLUGIN_PLATFORMS = new Set(["linux", "macos", "windows"]);
+const REGULAR_BLOB_MODES = new Set(["100644", "100755"]);
type R2Bucket = {
put(
@@ -19,6 +39,7 @@ type R2Bucket = {
};
},
): Promise<unknown>;
+ get(key: string): Promise<{ text(): Promise<string> } | null>;
};
type KVNamespace = {
@@ -50,7 +71,7 @@ type RefreshOptions = {
type GitHubRepository = Record<string, unknown>;
-export type PluginListing = {
+type RepositoryListing = {
id: number;
fullName: string;
owner: string;
@@ -65,22 +86,94 @@ export type PluginListing = {
createdAt: string | null;
updatedAt: string | null;
pushedAt: string | null;
+ defaultBranch: string;
+};
+
+export type PluginManifestListing = {
+ path: string;
+ id: string;
+ name: string;
+ version: string;
+ minHerdrVersion: string;
+ description: string | null;
+ platforms: string[] | null;
+};
+
+export type PluginListing = Omit<RepositoryListing, "defaultBranch"> & {
+ headCommit: string;
+ manifests: PluginManifestListing[];
};
export type PluginSnapshot = {
schemaVersion: 1;
generatedAt: string;
+ pluginCount: number;
+ repositoryCount: number;
source: {
provider: "github";
query: string;
totalCount: number;
collectedCount: number;
+ blacklistedCount: number;
+ missingManifestCount: number;
+ invalidManifestCount: number;
+ duplicateManifestCount: number;
+ skippedRepositoryCount: number;
+ unavailableRepositoryCount: number;
truncated: boolean;
warnings?: string[];
};
plugins: PluginListing[];
};
+type ScanCacheEntry = {
+ repositoryId: number;
+ fullName: string;
+ defaultBranch: string;
+ pushedAt: string | null;
+ headCommit: string;
+ manifestFileCount: number;
+ invalidManifestCount: number;
+ duplicateManifestCount: number;
+ warning: string | null;
+ retryable: boolean;
+ manifests: PluginManifestListing[];
+};
+
+type ScanCache = {
+ schemaVersion: 1;
+ entries: ScanCacheEntry[];
+};
+
+type ResolvedRepository = {
+ repository: RepositoryListing;
+ headCommit: string;
+ cached: ScanCacheEntry | null;
+};
+
+type TreeManifestCandidate = {
+ repository: RepositoryListing;
+ headCommit: string;
+ path: string;
+ size: number;
+};
+
+type PendingRepositoryScan = {
+ resolved: ResolvedRepository;
+ manifestFileCount: number;
+ invalidManifestCount: number;
+ warning: string | null;
+ retryable: boolean;
+ candidates: TreeManifestCandidate[];
+};
+
+type GitHubTreeEntry = {
+ path: string;
+ mode: string;
+ type: string;
+ size: number | null;
+};
+
export type RefreshResult =
| { ok: true; snapshot: PluginSnapshot }
| { ok: false; error: string };
@@ -107,39 +200,112 @@ export async function refreshPlugins(
}
const fetchFn = options.fetch ?? fetch;
- const result = await fetchGitHubRepositories(fetchFn, token);
- const normalizedPlugins = normalizeRepositories(result.repositories);
- if (normalizedPlugins.length === 0) {
- throw new Error("GitHub returned no listable plugin repositories");
+ const search = await fetchGitHubRepositories(fetchFn, token);
+ const repositories = normalizeRepositories(search.repositories);
+ const blockedRepositories = await readBlacklistedRepositories(env);
+ const candidates = repositories.filter(
+ (repository) => !blockedRepositories.has(repository.fullName.toLowerCase()),
+ );
+ const blacklistedCount = repositories.length - candidates.length;
+ const cache = await readScanCache(env.PLUGIN_MARKETPLACE_BUCKET, logger);
+ if (cache.entries.length > 0 && repositories.length * 2 < cache.entries.length) {
+ throw new Error(
+ `GitHub repository inventory collapsed from ${cache.entries.length} to ${repositories.length}; remove ${SCAN_CACHE_KEY} to accept an intentional reset`,
+ );
}
+ const cachedById = new Map(cache.entries.map((entry) => [entry.repositoryId, entry]));
- const blockedRepositories = await readBlacklistedRepositories(env);
- const plugins =
- blockedRepositories.size === 0
- ? normalizedPlugins
- : normalizedPlugins.filter(
- (plugin) => !blockedRepositories.has(plugin.fullName.toLowerCase()),
- );
+ const { resolved, unavailableRepositoryCount } = await resolveRepositoryHeads(
+ fetchFn,
+ token,
+ candidates,
+ cachedById,
+ );
+ const resolvedIds = new Set(resolved.map(({ repository }) => repository.id));
+ const missingCachedRepository = candidates.find(
+ (repository) => cachedById.has(repository.id) && !resolvedIds.has(repository.id),
+ );
+ if (
+ missingCachedRepository ||
+ (candidates.length > 0 && resolved.length * 2 < candidates.length)
+ ) {
+ throw new Error(
+ `GitHub head resolution omitted marketplace repositories${missingCachedRepository ? ` including ${missingCachedRepository.fullName}` : ""}`,
+ );
+ }
+ const changed = resolved.filter(
+ ({ headCommit, cached }) => !cached || cached.headCommit !== headCommit || cached.retryable,
+ );
+ const scannedById = await scanChangedRepositories(fetchFn, token, changed);
+
+ const nextEntries = resolved.map(({ repository, headCommit, cached }) => {
+ const scanned = scannedById.get(repository.id);
+ if (scanned) return scanned;
+ if (!cached || cached.headCommit !== headCommit) {
+ throw new Error(`missing marketplace scan result for ${repository.fullName}`);
+ }
+ return {
+ ...cached,
+ fullName: repository.fullName,
+ defaultBranch: repository.defaultBranch,
+ pushedAt: repository.pushedAt,
+ };
+ });
+ const entriesById = new Map(nextEntries.map((entry) => [entry.repositoryId, entry]));
+ const plugins = resolved
+ .map(({ repository }) => {
+ const entry = entriesById.get(repository.id);
+ if (!entry || entry.manifests.length === 0) return null;
+ const { defaultBranch: _defaultBranch, ...card } = repository;
+ return { ...card, headCommit: entry.headCommit, manifests: entry.manifests };
+ })
+ .filter((plugin): plugin is PluginListing => plugin !== null);
+ const pluginCount = plugins.reduce((total, plugin) => total + plugin.manifests.length, 0);
+ const warnings = nextEntries.flatMap((entry) => (entry.warning ? [entry.warning] : []));
+ if (search.truncated) {
+ warnings.unshift(
+ `GitHub returned ${search.totalCount} results; only the first ${search.repositories.length} were collected.`,
+ );
+ }
+ const generatedAt = (options.now ?? new Date()).toISOString();
const snapshot: PluginSnapshot = {
schemaVersion: 1,
- generatedAt: (options.now ?? new Date()).toISOString(),
+ generatedAt,
+ pluginCount,
+ repositoryCount: plugins.length,
source: {
provider: "github",
query: GITHUB_QUERY,
- totalCount: result.totalCount,
- collectedCount: result.repositories.length,
- truncated: result.truncated,
+ totalCount: search.totalCount,
+ collectedCount: search.repositories.length,
+ blacklistedCount,
+ missingManifestCount: nextEntries.filter(
+ (entry) => entry.manifestFileCount === 0 && !entry.warning,
+ ).length,
+ invalidManifestCount: nextEntries.reduce(
+ (total, entry) => total + entry.invalidManifestCount,
+ 0,
+ ),
+ duplicateManifestCount: nextEntries.reduce(
+ (total, entry) => total + entry.duplicateManifestCount,
+ 0,
+ ),
+ skippedRepositoryCount: nextEntries.filter((entry) => entry.warning).length,
+ unavailableRepositoryCount,
+ truncated: search.truncated,
+ ...(warnings.length > 0 ? { warnings } : {}),
},
plugins,
};
- if (result.truncated) {
- snapshot.source.warnings = [
- `GitHub returned ${result.totalCount} results; only the first ${result.repositories.length} were collected.`,
- ];
- }
-
+ const nextCache: ScanCache = { schemaVersion: SCAN_CACHE_SCHEMA_VERSION, entries: nextEntries };
+ await env.PLUGIN_MARKETPLACE_BUCKET.put(SCAN_CACHE_KEY, JSON.stringify(nextCache), {
+ httpMetadata: {
+ contentType: "application/json; charset=utf-8",
+ cacheControl: "no-store",
+ },
+ });
await env.PLUGIN_MARKETPLACE_BUCKET.put(SNAPSHOT_KEY, JSON.stringify(snapshot), {
httpMetadata: {
contentType: "application/json; charset=utf-8",
@@ -170,24 +336,10 @@ async function fetchGitHubRepositories(
url.searchParams.set("sort", "stars");
url.searchParams.set("order", "desc");
- const response = await fetchWithTimeout(
- fetchFn,
- url,
- {
- headers: {
- Accept: "application/vnd.github+json",
- Authorization: `Bearer ${token}`,
- "User-Agent": "herdr-plugin-marketplace",
- "X-GitHub-Api-Version": GITHUB_API_VERSION,
- },
- },
- timeoutMs,
- );
-
+ const response = await fetchWithTimeout(fetchFn, url, githubRequestInit(token), timeoutMs);
if (!response.ok) {
throw new Error(`GitHub search failed with status ${response.status}`);
}
-
const body = await response.json();
if (!isObject(body) || typeof body.total_count !== "number" || !Array.isArray(body.items)) {
throw new Error("GitHub search returned malformed JSON");
@@ -198,10 +350,7 @@ async function fetchGitHubRepositories(
totalCount = body.total_count;
repositories.push(...body.items.slice(0, MAX_REPOS - repositories.length));
-
- if (repositories.length >= totalCount || body.items.length === 0) {
- break;
- }
+ if (repositories.length >= totalCount || body.items.length === 0) break;
}
return {
@@ -211,51 +360,463 @@ async function fetchGitHubRepositories(
};
}
-async function fetchWithTimeout(
+async function resolveRepositoryHeads(
fetchFn: FetchLike,
- url: URL,
- init: RequestInit,
- timeoutMs: number,
-): Promise<Response> {
- const controller = new AbortController();
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
+ token: string,
+ repositories: RepositoryListing[],
+ cachedById: Map<number, ScanCacheEntry>,
+): Promise<{ resolved: ResolvedRepository[]; unavailableRepositoryCount: number }> {
+ const resolvedById = new Map<number, ResolvedRepository>();
+ const needResolution: RepositoryListing[] = [];
+
+ for (const repository of repositories) {
+ const cached = cachedById.get(repository.id) ?? null;
+ if (
+ cached &&
+ cached.fullName.toLowerCase() === repository.fullName.toLowerCase() &&
+ cached.defaultBranch === repository.defaultBranch &&
+ !cached.retryable &&
+ cached.pushedAt !== null &&
+ cached.pushedAt === repository.pushedAt
+ ) {
+ resolvedById.set(repository.id, { repository, headCommit: cached.headCommit, cached });
+ } else {
+ needResolution.push(repository);
+ }
+ }
+
+ const batches = chunk(needResolution, GRAPHQL_BATCH_SIZE);
+ const results = await mapConcurrent(batches, NETWORK_CONCURRENCY, async (batch) => {
+ const data = await fetchGraphqlData(fetchFn, token, repositoryHeadQuery(batch));
+ return batch.flatMap((repository, index) => {
+ const raw = data[`repo${index}`];
+ if (!isObject(raw)) return [];
+ const branch = isObject(raw.defaultBranchRef) ? readString(raw.defaultBranchRef.name) : null;
+ const target = isObject(raw.defaultBranchRef?.target) ? raw.defaultBranchRef.target : null;
+ const headCommit = isObject(target) ? readString(target.oid) : null;
+ if (
+ branch !== repository.defaultBranch ||
+ !headCommit ||
+ !/^[0-9a-f]{40,64}$/i.test(headCommit)
+ ) {
+ return [];
+ }
+ return [{ repository, headCommit, cached: cachedById.get(repository.id) ?? null }];
+ });
+ });
+ for (const resolved of results.flat()) {
+ resolvedById.set(resolved.repository.id, resolved);
+ }
+
+ return {
+ resolved: repositories.flatMap((repository) => {
+ const value = resolvedById.get(repository.id);
+ return value ? [value] : [];
+ }),
+ unavailableRepositoryCount: repositories.length - resolvedById.size,
+ };
+}
+
+async function scanChangedRepositories(
+ fetchFn: FetchLike,
+ token: string,
+ repositories: ResolvedRepository[],
+): Promise<Map<number, ScanCacheEntry>> {
+ const pending = await mapConcurrent(repositories, NETWORK_CONCURRENCY, (resolved) =>
+ fetchRepositoryTree(fetchFn, token, resolved),
+ );
+ const candidates: TreeManifestCandidate[] = [];
+ let candidateBytes = 0;
+ for (const scan of pending) {
+ const scanBytes = scan.candidates.reduce((total, candidate) => total + candidate.size, 0);
+ if (
+ candidates.length + scan.candidates.length > MAX_TOTAL_MANIFESTS ||
+ candidateBytes + scanBytes > MAX_TOTAL_MANIFEST_BYTES
+ ) {
+ scan.warning = `${scan.resolved.repository.fullName} was skipped because the marketplace scan budget was exhausted.`;
+ scan.retryable = true;
+ scan.candidates = [];
+ continue;
+ }
+ candidates.push(...scan.candidates);
+ candidateBytes += scanBytes;
+ }
+ const contents = await fetchManifestContents(fetchFn, token, candidates);
+ const entries = new Map<number, ScanCacheEntry>();
+
+ for (const scan of pending) {
+ const manifests: PluginManifestListing[] = [];
+ let invalidManifestCount = scan.invalidManifestCount;
+ for (const candidate of scan.candidates) {
+ const content = contents.get(manifestCandidateKey(candidate));
+ const metadata = typeof content === "string" ? parseManifestSummary(content) : null;
+ if (!metadata) {
+ invalidManifestCount += 1;
+ continue;
+ }
+ manifests.push({ path: candidate.path, ...metadata });
+ }
+ const { manifests: uniqueManifests, duplicateCount } = deduplicateManifests(manifests);
+ const { repository, headCommit } = scan.resolved;
+ entries.set(repository.id, {
+ repositoryId: repository.id,
+ fullName: repository.fullName,
+ defaultBranch: repository.defaultBranch,
+ pushedAt: repository.pushedAt,
+ headCommit,
+ manifestFileCount: scan.manifestFileCount,
+ invalidManifestCount,
+ duplicateManifestCount: duplicateCount,
+ warning: scan.warning,
+ retryable: scan.retryable,
+ manifests: uniqueManifests,
+ });
+ }
+ return entries;
+}
+
+async function fetchRepositoryTree(
+ fetchFn: FetchLike,
+ token: string,
+ resolved: ResolvedRepository,
+): Promise<PendingRepositoryScan> {
+ const { repository, headCommit } = resolved;
+ const url = new URL(
+ `${GITHUB_API_URL}/repos/${encodeURIComponent(repository.owner)}/${encodeURIComponent(repository.name)}/git/trees/${headCommit}`,
+ );
+ url.searchParams.set("recursive", "1");
+ const response = await fetchWithTimeout(fetchFn, url, githubRequestInit(token), REQUEST_TIMEOUT_MS);
+ if (!response.ok) {
+ throw new Error(`GitHub tree fetch failed for ${repository.fullName} with status ${response.status}`);
+ }
+ const body = await response.json();
+ if (!isObject(body) || !Array.isArray(body.tree)) {
+ throw new Error(`GitHub tree fetch returned malformed JSON for ${repository.fullName}`);
+ }
+ if (body.truncated === true || body.tree.length > MAX_TREE_ENTRIES) {
+ return skippedRepositoryScan(resolved, `${repository.fullName} was skipped because its Git tree is too large.`);
+ }
+
+ const manifestEntries = body.tree
+ .map(normalizeTreeEntry)
+ .filter((entry): entry is GitHubTreeEntry => entry !== null)
+ .filter(
+ (entry) =>
+ isDiscoverableManifestPath(entry.path) &&
+ entry.type === "blob" &&
+ REGULAR_BLOB_MODES.has(entry.mode),
+ );
+ if (manifestEntries.length > MAX_MANIFESTS_PER_REPOSITORY) {
+ return skippedRepositoryScan(
+ resolved,
+ `${repository.fullName} was skipped because it contains more than ${MAX_MANIFESTS_PER_REPOSITORY} plugin manifests.`,
+ );
+ }
+
+ let invalidManifestCount = 0;
+ const candidates: TreeManifestCandidate[] = [];
+ for (const entry of manifestEntries) {
+ if (
+ entry.size === null ||
+ entry.size < 0 ||
+ entry.size > MANIFEST_MAX_BYTES
+ ) {
+ invalidManifestCount += 1;
+ continue;
+ }
+ candidates.push({ repository, headCommit, path: entry.path, size: entry.size });
+ }
+
+ return {
+ resolved,
+ manifestFileCount: manifestEntries.length,
+ invalidManifestCount,
+ warning: null,
+ retryable: false,
+ candidates,
+ };
+}
+
+function skippedRepositoryScan(
+ resolved: ResolvedRepository,
+ warning: string,
+): PendingRepositoryScan {
+ return {
+ resolved,
+ manifestFileCount: 0,
+ invalidManifestCount: 0,
+ warning,
+ retryable: false,
+ candidates: [],
+ };
+}
+
+async function fetchManifestContents(
+ fetchFn: FetchLike,
+ token: string,
+ candidates: TreeManifestCandidate[],
+): Promise<Map<string, string | null>> {
+ const batches = chunk(candidates, GRAPHQL_BATCH_SIZE);
+ const results = await mapConcurrent(batches, NETWORK_CONCURRENCY, async (batch) => {
+ const data = await fetchGraphqlData(fetchFn, token, manifestContentQuery(batch));
+ return batch.flatMap((candidate, index) => {
+ const raw = data[`item${index}`];
+ const manifest = isObject(raw) && isObject(raw.manifest) ? raw.manifest : null;
+ if (!manifest) {
+ throw new Error(
+ `GitHub manifest fetch omitted ${candidate.repository.fullName}:${candidate.path}`,
+ );
+ }
+ if (manifest.isBinary === true) {
+ return [[manifestCandidateKey(candidate), null] as const];
+ }
+ if (typeof manifest.text !== "string") {
+ throw new Error(
+ `GitHub manifest fetch returned malformed data for ${candidate.repository.fullName}:${candidate.path}`,
+ );
+ }
+ return [[manifestCandidateKey(candidate), manifest.text] as const];
+ });
+ });
+ return new Map(results.flat());
+}
+
+async function fetchGraphqlData(
+ fetchFn: FetchLike,
+ token: string,
+ query: string,
+): Promise<Record<string, unknown>> {
+ const response = await fetchWithTimeout(
+ fetchFn,
+ new URL(GITHUB_GRAPHQL_URL),
+ {
+ ...githubRequestInit(token),
+ method: "POST",
+ body: JSON.stringify({ query }),
+ },
+ REQUEST_TIMEOUT_MS,
+ );
+ if (!response.ok) {
+ throw new Error(`GitHub marketplace query failed with status ${response.status}`);
+ }
+ const body = await response.json();
+ if (!isObject(body) || !isObject(body.data)) {
+ throw new Error("GitHub marketplace query returned malformed JSON");
+ }
+ if (
+ Array.isArray(body.errors) &&
+ body.errors.some((error) => !isMissingAliasGraphqlError(error))
+ ) {
+ throw new Error("GitHub marketplace query returned GraphQL errors");
+ }
+ return body.data;
+}
+
+function isMissingAliasGraphqlError(error: unknown): boolean {
+ if (!isObject(error) || error.type !== "NOT_FOUND" || !Array.isArray(error.path)) {
+ return false;
+ }
+ const alias = error.path[0];
+ return typeof alias === "string" && /^(repo|item)\d+$/.test(alias);
+}
+
+function repositoryHeadQuery(repositories: RepositoryListing[]): string {
+ const selections = repositories.map((repository, index) => {
+ const owner = JSON.stringify(repository.owner);
+ const name = JSON.stringify(repository.name);
+ return `repo${index}: repository(owner: ${owner}, name: ${name}) {
+ defaultBranchRef { name target { ... on Commit { oid } } }
+ }`;
+ });
+ return `query PluginMarketplaceHeads {\n${selections.join("\n")}\n}`;
+}
+
+function manifestContentQuery(candidates: TreeManifestCandidate[]): string {
+ const selections = candidates.map((candidate, index) => {
+ const owner = JSON.stringify(candidate.repository.owner);
+ const name = JSON.stringify(candidate.repository.name);
+ const expression = JSON.stringify(`${candidate.headCommit}:${candidate.path}`);
+ return `item${index}: repository(owner: ${owner}, name: ${name}) {
+ manifest: object(expression: ${expression}) { ... on Blob { text isBinary } }
+ }`;
+ });
+ return `query PluginMarketplaceManifests {\n${selections.join("\n")}\n}`;
+}
+
+export function parseManifestSummary(manifestText: string): Omit<PluginManifestListing, "path"> | null {
try {
- return await fetchFn(url, { ...init, signal: controller.signal });
- } finally {
- clearTimeout(timeout);
+ const manifest = parse(manifestText.replace(/^\uFEFF/, ""));
+ if (!isObject(manifest)) return null;
+ const id = readTrimmedString(manifest.id);
+ const name = readTrimmedString(manifest.name);
+ const version = readTrimmedString(manifest.version);
+ const minHerdrVersion = readTrimmedString(manifest.min_herdr_version);
+ if (
+ !id ||
+ id.length > 120 ||
+ !/^[A-Za-z0-9:._-]+$/.test(id) ||
+ !name ||
+ name.length > PLUGIN_NAME_MAX_CHARS ||
+ !version ||
+ version.length > PLUGIN_VERSION_MAX_CHARS ||
+ !minHerdrVersion ||
+ minHerdrVersion.length > PLUGIN_VERSION_MAX_CHARS ||
+ !isHerdrVersion(minHerdrVersion)
+ ) {
+ return null;
+ }
+
+ let description: string | null = null;
+ if (manifest.description !== undefined) {
+ if (typeof manifest.description !== "string") return null;
+ description = manifest.description.trim() || null;
+ if (description && description.length > PLUGIN_DESCRIPTION_MAX_CHARS) return null;
+ }
+
+ let platforms: string[] | null = null;
+ if (manifest.platforms !== undefined) {
+ if (
+ !Array.isArray(manifest.platforms) ||
+ manifest.platforms.length === 0 ||
+ manifest.platforms.length > PLUGIN_PLATFORMS.size ||
+ !manifest.platforms.every(
+ (platform) => typeof platform === "string" && PLUGIN_PLATFORMS.has(platform),
+ )
+ ) {
+ return null;
+ }
+ platforms = [...manifest.platforms];
+ if (new Set(platforms).size !== platforms.length) return null;
+ }
+
+ return { id, name, version, minHerdrVersion, description, platforms };
+ } catch {
+ return null;
}
}
-export function normalizeRepositories(repositories: GitHubRepository[]): PluginListing[] {
- return repositories
- .map(normalizeRepository)
- .filter((plugin): plugin is PluginListing => plugin !== null)
- .sort(comparePlugins);
+async function readScanCache(
+ bucket: R2Bucket,
+ logger: Pick<Console, "error">,
+): Promise<ScanCache> {
+ const empty = (): ScanCache => ({ schemaVersion: SCAN_CACHE_SCHEMA_VERSION, entries: [] });
+ const object = await bucket.get(SCAN_CACHE_KEY);
+ if (!object) return empty();
+ try {
+ const value: unknown = JSON.parse(await object.text());
+ if (
+ !isObject(value) ||
+ value.schemaVersion !== SCAN_CACHE_SCHEMA_VERSION ||
+ !Array.isArray(value.entries)
+ ) {
+ throw new Error("unsupported cache shape");
+ }
+ const entries = value.entries.map(readScanCacheEntry);
+ if (entries.some((entry) => entry === null)) {
+ throw new Error("invalid cache entry");
+ }
+ return { schemaVersion: SCAN_CACHE_SCHEMA_VERSION, entries: entries as ScanCacheEntry[] };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "unknown cache error";
+ logger.error(`discarding invalid plugin marketplace scan cache: ${message}`);
+ return empty();
+ }
+}
+
+function readScanCacheEntry(value: unknown): ScanCacheEntry | null {
+ if (!isObject(value) || !Array.isArray(value.manifests)) return null;
+ const repositoryId = readInteger(value.repositoryId);
+ const fullName = readString(value.fullName);
+ const defaultBranch = readString(value.defaultBranch);
+ const pushedAt = value.pushedAt === null ? null : readIsoString(value.pushedAt);
+ const headCommit = readString(value.headCommit);
+ const manifestFileCount = readNonNegativeIntegerOrNull(value.manifestFileCount);
+ const invalidManifestCount = readNonNegativeIntegerOrNull(value.invalidManifestCount);
+ const duplicateManifestCount = readNonNegativeIntegerOrNull(value.duplicateManifestCount);
+ const warning = value.warning === null ? null : readString(value.warning);
+ const retryable = typeof value.retryable === "boolean" ? value.retryable : null;
+ const manifests = value.manifests.map(readCachedManifest);
+ if (
+ repositoryId === null ||
+ !fullName ||
+ !defaultBranch ||
+ (value.pushedAt !== null && pushedAt === null) ||
+ !headCommit ||
+ !/^[0-9a-f]{40,64}$/i.test(headCommit) ||
+ manifestFileCount === null ||
+ invalidManifestCount === null ||
+ duplicateManifestCount === null ||
+ retryable === null ||
+ (value.warning !== null && !warning) ||
+ manifests.some((manifest) => manifest === null)
+ ) {
+ return null;
+ }
+ return {
+ repositoryId,
+ fullName,
+ defaultBranch,
+ pushedAt,
+ headCommit,
+ manifestFileCount,
+ invalidManifestCount,
+ duplicateManifestCount,
+ warning,
+ retryable,
+ manifests: manifests as PluginManifestListing[],
+ };
+}
+
+function readCachedManifest(value: unknown): PluginManifestListing | null {
+ if (!isObject(value)) return null;
+ const path = readString(value.path);
+ const id = readString(value.id);
+ const name = readString(value.name);
+ const version = readString(value.version);
+ const minHerdrVersion = readString(value.minHerdrVersion);
+ const description = value.description === null ? null : readString(value.description);
+ const platforms = value.platforms === null ? null : readStringArrayOrNull(value.platforms);
+ if (
+ !path ||
+ !isManifestPath(path) ||
+ !id ||
+ !name ||
+ !version ||
+ !minHerdrVersion ||
+ (value.description !== null && !description) ||
+ (value.platforms !== null && platforms === null)
+ ) {
+ return null;
+ }
+ return { path, id, name, version, minHerdrVersion, description, platforms };
+}
+
+export function normalizeRepositories(repositories: GitHubRepository[]): RepositoryListing[] {
+ const byId = new Map<number, RepositoryListing>();
+ for (const repository of repositories.map(normalizeRepository)) {
+ if (repository && !byId.has(repository.id)) byId.set(repository.id, repository);
+ }
+ return [...byId.values()].sort(compareRepositories);
}
async function readBlacklistedRepositories(env: Env): Promise<Set<string>> {
const kv = env.PLUGIN_MARKETPLACE_BLACKLIST;
const blockedRepositories = new Set<string>();
- if (!kv) {
- return blockedRepositories;
- }
+ if (!kv) return blockedRepositories;
let cursor: string | undefined;
do {
const page = await kv.list({ prefix: BLACKLIST_REPO_KEY_PREFIX, cursor });
for (const key of page.keys) {
const repository = key.name.slice(BLACKLIST_REPO_KEY_PREFIX.length).trim().toLowerCase();
- if (repository.includes("/")) {
- blockedRepositories.add(repository);
- }
+ if (repository.includes("/")) blockedRepositories.add(repository);
}
cursor = page.cursor;
} while (cursor);
-
return blockedRepositories;
}
-function normalizeRepository(repo: GitHubRepository): PluginListing | null {
+function normalizeRepository(repo: GitHubRepository): RepositoryListing | null {
if (
readBoolean(repo.disabled) ||
readBoolean(repo.archived) ||
@@ -270,18 +831,16 @@ function normalizeRepository(repo: GitHubRepository): PluginListing | null {
const ownerObject = isObject(repo.owner) ? repo.owner : {};
const owner = firstString(readString(ownerObject.login), fullNameParts.owner);
const name = firstString(readString(repo.name), fullNameParts.name);
- if (!owner || !name) {
- return null;
- }
+ const defaultBranch = readString(repo.default_branch);
+ if (!owner || !name || !defaultBranch) return null;
const fullName = readString(repo.full_name) ?? `${owner}/${name}`;
const url = readString(repo.html_url);
- if (!url || !isValidGitHubRepoUrl(url, owner, name)) {
- return null;
- }
+ const id = readInteger(repo.id);
+ if (!url || !isValidGitHubRepoUrl(url, owner, name) || id === null || id <= 0) return null;
return {
- id: readInteger(repo.id) ?? 0,
+ id,
fullName,
owner,
name,
@@ -295,10 +854,67 @@ function normalizeRepository(repo: GitHubRepository): PluginListing | null {
createdAt: readIsoString(repo.created_at),
updatedAt: readIsoString(repo.updated_at),
pushedAt: readIsoString(repo.pushed_at),
+ defaultBranch,
};
}
-function comparePlugins(a: PluginListing, b: PluginListing): number {
+function normalizeTreeEntry(value: unknown): GitHubTreeEntry | null {
+ if (!isObject(value)) return null;
+ const path = readString(value.path);
+ const mode = readString(value.mode);
+ const type = readString(value.type);
+ if (!path || !mode || !type) return null;
+ return { path, mode, type, size: readNonNegativeIntegerOrNull(value.size) };
+}
+
+function isManifestPath(path: string): boolean {
+ return path === PLUGIN_MANIFEST_FILE || path.endsWith(`/${PLUGIN_MANIFEST_FILE}`);
+}
+
+function isDiscoverableManifestPath(path: string): boolean {
+ if (!isManifestPath(path)) return false;
+ const ignoredSegments = new Set([
+ "test",
+ "tests",
+ "fixture",
+ "fixtures",
+ "testdata",
+ "__fixtures__",
+ ]);
+ return !path
+ .split("/")
+ .slice(0, -1)
+ .some((segment) => ignoredSegments.has(segment.toLowerCase()));
+}
+
+function deduplicateManifests(manifests: PluginManifestListing[]): {
+ manifests: PluginManifestListing[];
+ duplicateCount: number;
+} {
+ const preferred = [...manifests].sort((a, b) => {
+ const depth = (path: string) => path.split("/").length;
+ return depth(a.path) - depth(b.path) || a.path.localeCompare(b.path);
+ });
+ const byId = new Map<string, PluginManifestListing>();
+ let duplicateCount = 0;
+ for (const manifest of preferred) {
+ if (byId.has(manifest.id)) {
+ duplicateCount += 1;
+ } else {
+ byId.set(manifest.id, manifest);
+ }
+ }
+ return {
+ manifests: [...byId.values()].sort((a, b) => a.path.localeCompare(b.path)),
+ duplicateCount,
+ };
+}
+
+function manifestCandidateKey(candidate: TreeManifestCandidate): string {
+ return `${candidate.repository.id}:${candidate.path}`;
+}
+
+function compareRepositories(a: RepositoryListing, b: RepositoryListing): number {
return (
b.stars - a.stars ||
dateMs(b.pushedAt) - dateMs(a.pushedAt) ||
@@ -306,11 +922,63 @@ function comparePlugins(a: PluginListing, b: PluginListing): number {
);
}
+function githubRequestInit(token: string): RequestInit {
+ return {
+ headers: {
+ Accept: "application/vnd.github+json",
+ Authorization: `Bearer ${token}`,
+ "User-Agent": "herdr-plugin-marketplace",
+ "X-GitHub-Api-Version": GITHUB_API_VERSION,
+ },
+ };
+}
+
+async function fetchWithTimeout(
+ fetchFn: FetchLike,
+ url: URL,
+ init: RequestInit,
+ timeoutMs: number,
+): Promise<Response> {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
+ try {
+ return await fetchFn(url, { ...init, signal: controller.signal });
+ } finally {
+ clearTimeout(timeout);
+ }
+}
+
+function chunk<T>(values: T[], size: number): T[][] {
+ const chunks: T[][] = [];
+ for (let index = 0; index < values.length; index += size) {
+ chunks.push(values.slice(index, index + size));
+ }
+ return chunks;
+}
+
+async function mapConcurrent<T, R>(
+ values: T[],
+ concurrency: number,
+ operation: (value: T) => Promise<R>,
+): Promise<R[]> {
+ const results = new Array<R>(values.length);
+ let nextIndex = 0;
+ const workers = Array.from({ length: Math.min(concurrency, values.length) }, async () => {
+ while (nextIndex < values.length) {
+ const index = nextIndex;
+ nextIndex += 1;
+ results[index] = await operation(values[index]);
+ }
+ });
+ await Promise.all(workers);
+ return results;
+}
+
function jsonResponse(body: unknown, status: number, cacheControl: string): Response {
return new Response(JSON.stringify(body), {
status,
headers: {
- "Content-Type": "application/json; charset=utf-8",
+ "Content-Type": "application/json",
"Cache-Control": cacheControl,
},
});
@@ -333,14 +1001,9 @@ function isValidGitHubRepoUrl(url: string, owner: string, name: string): boolean
}
function splitFullName(fullName: string | null): { owner: string | null; name: string | null } {
- if (!fullName) {
- return { owner: null, name: null };
- }
+ if (!fullName) return { owner: null, name: null };
const [owner, name, extra] = fullName.split("/");
- if (!owner || !name || extra) {
- return { owner: null, name: null };
- }
- return { owner, name };
+ return owner && name && !extra ? { owner, name } : { owner: null, name: null };
}
function firstString(...values: Array<string | null>): string | null {
@@ -351,6 +1014,10 @@ function readString(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
+function readTrimmedString(value: unknown): string | null {
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
+}
+
function readNullableString(value: unknown): string | null {
return typeof value === "string" ? value : null;
}
@@ -359,13 +1026,23 @@ function readStringArray(value: unknown): string[] {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
}
+function readStringArrayOrNull(value: unknown): string[] | null {
+ return Array.isArray(value) && value.every((item) => typeof item === "string")
+ ? value
+ : null;
+}
+
function readInteger(value: unknown): number | null {
return typeof value === "number" && Number.isInteger(value) ? value : null;
}
function readNonNegativeInteger(value: unknown): number {
+ return readNonNegativeIntegerOrNull(value) ?? 0;
+}
+
+function readNonNegativeIntegerOrNull(value: unknown): number | null {
const integer = readInteger(value);
- return integer !== null && integer >= 0 ? integer : 0;
+ return integer !== null && integer >= 0 ? integer : null;
}
function readBoolean(value: unknown): boolean {
@@ -373,10 +1050,16 @@ function readBoolean(value: unknown): boolean {
}
function readIsoString(value: unknown): string | null {
- if (typeof value !== "string") {
- return null;
- }
- return Number.isNaN(Date.parse(value)) ? null : value;
+ return typeof value === "string" && !Number.isNaN(Date.parse(value)) ? value : null;
+}
+
+function isHerdrVersion(value: string): boolean {
+ const normalized = value.startsWith("v") ? value.slice(1) : value;
+ const parts = normalized.split(".");
+ return (
+ parts.length === 3 &&
+ parts.every((part) => /^\d+$/.test(part) && Number(part) <= 0xffff_ffff)
+ );
}
function dateMs(value: string | null): number {