import { ok } from "@atcute/client";
import { mainSchema as listRecordsSchema } from "@atcute/atproto/types/repo/listRecords";
import { isDid, type Did, type Nsid } from "@atcute/lexicons/syntax";
import type { OAuthUserAgent } from "@atcute/oauth-browser-client";
import { createClient, serviceDidForHost } from "#lib/auth/agent.ts";
import { validateRepoName } from "#lib/api/repoCreate.ts";
import { createMigrationTask, type MigrationTask } from "#lib/api/migrator.ts";
import { uploadProfileAvatar } from "#lib/api/profile.ts";
import { createRecord, putRecord, readRecord } from "#lib/api/write.ts";
import { createPubKey } from "#lib/api/settings.ts";
import { addEmail, listEmails } from "#lib/api/emails.ts";
import { createDeliberiClient } from "#lib/api/deliberi.ts";
import { nsidOf, recordKeyOf } from "#lib/api/syntax.ts";
import type { GitHubAccount, GitHubRepo } from "#lib/github.ts";
import type { ProfileRecord } from "#lib/api/records.ts";
import type { Fetch } from "#lib/fetch.ts";
const ACCOUNT_CACHE_MS = 30_000;
const accountCache = new WeakMap<Fetch, { account: GitHubAccount; at: number }>();
export const fetchGitHubAccount = async (fetcher: Fetch = fetch): Promise<GitHubAccount | null> => {
const cached = accountCache.get(fetcher);
if (cached && Date.now() - cached.at < ACCOUNT_CACHE_MS) return cached.account;
const response = await fetcher("/_internal/github", { cache: "no-store" });
if (response.status === 401) {
accountCache.delete(fetcher);
return null;
}
if (!response.ok) throw new Error("Could not load your GitHub account. Try connecting again.");
const account = (await response.json()) as GitHubAccount;
accountCache.set(fetcher, { account, at: Date.now() });
return account;
};
export const invalidateGitHubAccount = (fetcher: Fetch = fetch) => accountCache.delete(fetcher);
const PROFILE_COLLECTION = nsidOf("org.tangled.actor.profile");
const PUBLIC_KEY_COLLECTION = "org.tangled.key.sshKey" as Nsid;
const SELF_RKEY = recordKeyOf("self");
const GITHUB_REPO_GRAMMAR = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/;
const messageOf = (cause: unknown): string =>
cause instanceof Error ? cause.message : String(cause);
// localinfra serves a github stub on its own origin; github.com cannot be hardcoded
const hostOf = (origin: string): string => {
let parsed: URL;
try {
parsed = new URL(origin);
} catch {
throw new Error(`Invalid GitHub origin: "${origin}" is not a URL.`);
}
if (parsed.protocol !== "https:") {
throw new Error(`Invalid GitHub origin: protocol must be https.`);
}
return parsed.hostname.toLowerCase();
};
export const validateGitHubRepoUrl = (
urlString: string,
expectedFullName: string,
kind: "htmlUrl" | "cloneUrl",
origin: string
): string => {
if (!GITHUB_REPO_GRAMMAR.test(expectedFullName)) {
throw new Error(`Invalid GitHub fullName: "${expectedFullName}".`);
}
let parsed: URL;
try {
parsed = new URL(urlString);
} catch {
throw new Error(`Invalid GitHub ${kind}: malformed URL.`);
}
if (parsed.protocol !== "https:") {
throw new Error(`Invalid GitHub ${kind}: protocol must be https.`);
}
const allowedHost = hostOf(origin);
if (parsed.hostname.toLowerCase() !== allowedHost) {
throw new Error(`Invalid GitHub ${kind}: host must be ${allowedHost}.`);
}
if (parsed.username || parsed.password) {
throw new Error(`Invalid GitHub ${kind}: credentials are not allowed.`);
}
if (parsed.port !== "") {
throw new Error(`Invalid GitHub ${kind}: non-default port is not allowed.`);
}
if (parsed.search !== "") {
throw new Error(`Invalid GitHub ${kind}: query parameters are not allowed.`);
}
if (parsed.hash !== "") {
throw new Error(`Invalid GitHub ${kind}: hash fragments are not allowed.`);
}
const allowedPaths =
kind === "cloneUrl"
? [`/${expectedFullName}.git`, `/${expectedFullName}`]
: [`/${expectedFullName}`];
if (!allowedPaths.includes(parsed.pathname)) {
throw new Error(
`Invalid GitHub ${kind}: pathname must match repository "${expectedFullName}".`
);
}
return parsed.toString();
};
export const canonicalRepoName = (rawName: string): string =>
validateRepoName(rawName).toLowerCase();
interface RepoCollision<T = string> {
canonical: string;
candidates: T[];
conflictsWithExisting: boolean;
hasCollision: boolean;
}
export const findRepoCollisions = <T extends string | { name: string }>(
candidates: Iterable<T>,
existing: Iterable<string> = []
): Map<string, RepoCollision<T>> => {
const existingSet = new Set(
Array.from(existing).flatMap((item) => {
try {
return [canonicalRepoName(item)];
} catch {
return [];
}
})
);
const groups = new Map<string, T[]>();
for (const candidate of candidates) {
const rawName = typeof candidate === "string" ? candidate : candidate.name;
const canonical = canonicalRepoName(rawName);
const bucket = groups.get(canonical);
if (bucket) {
bucket.push(candidate);
} else {
groups.set(canonical, [candidate]);
}
}
return new Map(
Array.from(groups, ([canonical, items]) => {
const conflictsWithExisting = existingSet.has(canonical);
return [
canonical,
{
canonical,
candidates: items,
conflictsWithExisting,
hasCollision: items.length > 1 || conflictsWithExisting
}
];
})
);
};
interface ImportGitHubRepoInput {
ownerDid: Did;
ownerHandle: string;
knot: string;
spindle?: string;
repo: GitHubRepo;
name?: string;
description?: string;
}
interface ImportUrlInput {
sourceUrl: string;
name: string;
description?: string;
knot: string;
ownerDid?: Did;
ownerHandle?: string;
spindle?: string;
}
const knotDidOf = (knot: string): Did => {
const did = knot.startsWith("did:") ? knot : serviceDidForHost(knot);
if (!isDid(did)) throw new Error(`${knot} is not a did`);
return did;
};
type Uri = `${string}:${string}`;
const validateUrlSource = (raw: string): Uri => {
let parsed: URL;
try {
parsed = new URL(raw.trim());
} catch {
throw new Error("That is not a URL.");
}
if (parsed.protocol !== "https:") throw new Error("The source URL must be https.");
if (parsed.username || parsed.password)
throw new Error("The source URL must not carry credentials.");
if (parsed.port !== "" && parsed.port !== "443")
throw new Error("The source URL must not use a custom port.");
if (parsed.pathname.replace(/\/+$/, "").split("/").filter(Boolean).length === 0)
throw new Error("The source URL must name a repository.");
return parsed.toString() as Uri;
};
export interface StartedMigration {
taskId: string;
name: string;
jobId?: string;
}
export const importFromUrl = async (
agent: OAuthUserAgent,
migratorUrl: string,
input: ImportUrlInput,
options?: { fetch?: Fetch; signal?: AbortSignal; bobbinUrl?: string }
): Promise<StartedMigration> => {
const sourceUrl = validateUrlSource(input.sourceUrl);
const name = validateRepoName(input.name);
const description = input.description?.trim() || undefined;
const task = await createMigrationTask(
agent,
migratorUrl,
crypto.randomUUID(),
[
{
name,
knotDid: knotDidOf(input.knot),
sourceUrl,
...(description !== undefined ? { description } : {})
}
],
options
);
const first = task.jobs[0];
return { taskId: task.id, name: first?.name ?? name, jobId: first?.id };
};
export const importGitHubRepos = async (
agent: OAuthUserAgent,
migratorUrl: string,
origin: string,
inputs: ImportGitHubRepoInput[],
options?: { fetch?: Fetch; signal?: AbortSignal; bobbinUrl?: string }
): Promise<MigrationTask> => {
if (inputs.length === 0) throw new Error("Select at least one repository to import.");
const jobs = [];
for (const input of inputs) {
const cloneUrl = validateGitHubRepoUrl(
input.repo.cloneUrl,
input.repo.fullName,
"cloneUrl",
origin
);
const name = validateRepoName(input.name ?? input.repo.name);
const description = input.description?.trim() || undefined;
jobs.push({
name,
knotDid: knotDidOf(input.knot),
sourceUrl: validateUrlSource(cloneUrl),
...(input.repo.private === true ? { private: true } : {}),
...(description !== undefined ? { description } : {})
});
}
return createMigrationTask(agent, migratorUrl, crypto.randomUUID(), jobs, options);
};
const normalizeSshKey = (rawKey: string): string | null => {
const parts = rawKey.trim().split(/\s+/);
if (parts.length < 2) return null;
const [algorithm, base64Blob] = parts;
if (!/^[a-zA-Z0-9@._-]+$/.test(algorithm)) return null;
if (!/^[A-Za-z0-9+/=]+$/.test(base64Blob)) return null;
return `${algorithm} ${base64Blob}`;
};
interface ImportGitHubProfileEndpoints {
bobbinUrl?: string;
deliberiUrl: string;
fetch?: Fetch;
}
interface ImportGitHubProfileOptions {
avatar: boolean;
keys: boolean;
email: boolean;
}
interface ImportedGitHubKey {
rkey: string;
name: string;
key: string;
}
interface ImportGitHubProfileResult {
imported: string[];
errors: string[];
keys: ImportedGitHubKey[];
}
export const importGitHubProfile = async (
agent: OAuthUserAgent,
endpoints: ImportGitHubProfileEndpoints,
account: GitHubAccount,
options: ImportGitHubProfileOptions
): Promise<ImportGitHubProfileResult> => {
const fetchFn = endpoints.fetch ?? globalThis.fetch;
const imported: string[] = [];
const errors: string[] = [];
if (options.avatar) {
try {
const res = await fetchFn("/_internal/github/avatar");
if (!res.ok) {
throw new Error(`failed to fetch avatar (${res.status})`);
}
const rawBlob = await res.blob();
const contentType = res.headers.get("content-type")?.split(";")[0]?.trim();
const image =
contentType && !rawBlob.type ? new Blob([rawBlob], { type: contentType }) : rawBlob;
const avatarBlob = await uploadProfileAvatar(agent, image);
const existing = await readRecord<ProfileRecord>(agent, PROFILE_COLLECTION, SELF_RKEY);
const existingProfile = existing?.value;
const existingCid = existing?.written?.cid;
const record: ProfileRecord = {
$type: "org.tangled.actor.profile",
...(existingProfile ?? {}),
avatar: avatarBlob
};
if (existingProfile) {
await putRecord(agent, PROFILE_COLLECTION, SELF_RKEY, record, existingCid);
} else {
await createRecord(agent, PROFILE_COLLECTION, record, SELF_RKEY);
}
imported.push("avatar");
} catch (cause) {
errors.push(`Failed to import avatar: ${messageOf(cause)}`);
}
}
const writtenKeys: ImportedGitHubKey[] = [];
if (options.keys) {
try {
const rpc = createClient(agent);
const existingTokens = new Set<string>();
let cursor: string | undefined;
do {
const page = await ok(
rpc.call(listRecordsSchema, {
params: {
repo: agent.sub,
collection: PUBLIC_KEY_COLLECTION,
limit: 100,
...(cursor ? { cursor } : {})
}
})
);
for (const rec of page.records) {
const val = rec.value as { publicKey?: unknown };
if (typeof val?.publicKey === "string") {
const token = normalizeSshKey(val.publicKey);
if (token) existingTokens.add(token);
}
}
cursor = page.cursor;
} while (cursor);
const seenInBatch = new Set<string>();
for (const key of account.keys) {
const rawKey = key.key?.trim() ?? "";
const token = normalizeSshKey(rawKey);
if (!token) {
errors.push(`Invalid SSH key format for key ${key.title || key.id}.`);
continue;
}
if (existingTokens.has(token) || seenInBatch.has(token)) {
continue;
}
seenInBatch.add(token);
try {
const title = key.title?.trim() || `github-${key.id}`;
const written = await createPubKey(agent, title, rawKey);
writtenKeys.push({ rkey: written.rkey, name: title, key: rawKey });
existingTokens.add(token);
imported.push(`key:${key.id}`);
} catch (cause) {
errors.push(`Failed to import key ${key.title || key.id}: ${messageOf(cause)}`);
}
}
} catch (cause) {
errors.push(`Failed to load existing keys: ${messageOf(cause)}`);
}
}
if (options.email) {
if (!account.email || !account.email.trim()) {
errors.push("GitHub account does not have an email address.");
} else {
const targetEmail = account.email.trim();
try {
const deliberi = createDeliberiClient({
deliberiUrl: endpoints.deliberiUrl,
agent,
fetch: fetchFn
});
const { emails } = await listEmails(deliberi);
const normalizedTarget = targetEmail.toLowerCase();
const exists = emails.some((e) => e.address.toLowerCase() === normalizedTarget);
if (!exists) {
await addEmail(deliberi, targetEmail);
imported.push(`email:${targetEmail} (verification required)`);
}
} catch (cause) {
errors.push(`Failed to import email ${targetEmail}: ${messageOf(cause)}`);
}
}
}
return { imported, errors, keys: writtenKeys };
};