// GitHub channel: scans the user's public gists, falls back to `/` // profile README. No auth needed. import type { Identity } from "@kez/core"; import { ChannelError, type Channel, type ChannelHit, parseAndVerifyFor } from "./index.js"; const DEFAULT_API_BASE = "https://api.github.com"; const DEFAULT_RAW_BASE = "https://raw.githubusercontent.com"; const USER_AGENT = "kez-channels-node/0.1 (+https://example.invalid/kez)"; export interface GithubChannelOptions { apiBase?: string; rawBase?: string; fetch?: typeof fetch; } export class GithubChannel implements Channel { readonly system = "github"; private readonly apiBase: string; private readonly rawBase: string; private readonly fetch: typeof fetch; constructor(opts: GithubChannelOptions = {}) { this.apiBase = opts.apiBase ?? DEFAULT_API_BASE; this.rawBase = opts.rawBase ?? DEFAULT_RAW_BASE; this.fetch = opts.fetch ?? globalThis.fetch; } async fetchAndVerify(identity: Identity): Promise { const user = identity.id; if (!user) throw ChannelError.other("github identity has empty user"); let lastError: ChannelError | undefined; // 1. gists try { const candidates = await this.fetchGistCandidates(user); for (const url of candidates) { try { const body = await this.fetchText(url); return parseAndVerifyFor(body, identity); } catch (e) { lastError = e instanceof ChannelError ? e : ChannelError.other((e as Error).message, e); } } } catch (e) { lastError = e instanceof ChannelError ? e : ChannelError.other((e as Error).message, e); } // 2. profile README fallback (main, then master) for (const branch of ["main", "master"]) { const url = `${this.rawBase}/${user}/${user}/${branch}/README.md`; try { const body = await this.fetchText(url); return parseAndVerifyFor(body, identity); } catch (e) { if (e instanceof ChannelError && e.kind === "Unreachable") continue; lastError = e instanceof ChannelError ? e : ChannelError.other((e as Error).message, e); } } throw lastError ?? ChannelError.notFound(identity); } private async fetchText(url: string): Promise { let resp: Response; try { resp = await this.fetch(url, { headers: { "User-Agent": USER_AGENT } }); } catch (e) { throw ChannelError.unreachable(`GET ${url}: ${(e as Error).message}`, e); } if (!resp.ok) throw ChannelError.unreachable(`GET ${url}: ${resp.status}`); return resp.text(); } private async fetchGistCandidates(user: string): Promise { const url = gistsUrl(this.apiBase, user); let resp: Response; try { resp = await this.fetch(url, { headers: { "User-Agent": USER_AGENT, Accept: "application/vnd.github+json", }, }); } catch (e) { throw ChannelError.unreachable(`GET ${url}: ${(e as Error).message}`, e); } if (!resp.ok) throw ChannelError.unreachable(`GET ${url}: ${resp.status}`); const body = (await resp.json()) as unknown; return parseGistCandidates(body); } } export function looksLikeKezFilename(name: string): boolean { const lower = name.toLowerCase(); return ( lower.endsWith(".kez") || lower.endsWith(".kez.md") || lower.endsWith(".kez.json") || lower.includes("kez") ); } export function gistsUrl(apiBase: string, user: string): string { return `${apiBase}/users/${user}/gists?per_page=100`; } export function profileReadmeUrls(rawBase: string, user: string): string[] { return [ `${rawBase}/${user}/${user}/main/README.md`, `${rawBase}/${user}/${user}/master/README.md`, ]; } export function parseGistCandidates(body: unknown): string[] { if (!Array.isArray(body)) return []; const out: string[] = []; for (const gist of body) { if (typeof gist !== "object" || gist === null) continue; const files = (gist as Record).files; if (typeof files !== "object" || files === null) continue; for (const [name, file] of Object.entries(files)) { if (!looksLikeKezFilename(name)) continue; const rawUrl = (file as Record)?.raw_url; if (typeof rawUrl === "string") out.push(rawUrl); } } return out; }