Files
Jesse Merhi 303075c82b Remove retired moderation surfaces (#2658)
* chore: remove retired moderation surfaces

* test: cover retired moderation action wrappers

* chore: retain moderation security gates

* test: cover retired moderation compatibility paths

* fix: stop settings queries during account deletion

* docs: add retired comment purge command

* chore: delete retired scanner tombstones
2026-06-16 08:01:11 -07:00

149 lines
5.0 KiB
TypeScript

import { ConvexError } from "convex/values";
import { internal } from "../_generated/api";
import type { Id } from "../_generated/dataModel";
import type { ActionCtx } from "../_generated/server";
import { buildGitHubApiHeaders } from "./githubAuth";
import { GITHUB_PROFILE_SYNC_WINDOW_MS } from "./githubProfileSync";
const GITHUB_API = "https://api.github.com";
const MIN_ACCOUNT_AGE_MS = 14 * 24 * 60 * 60 * 1000;
type GitHubAccountGateCtx = Pick<ActionCtx, "runQuery" | "runMutation">;
type GitHubUser = {
login?: string;
name?: string;
avatar_url?: string;
created_at?: string;
};
function assertGitHubNumericId(providerAccountId: string) {
if (!/^[0-9]+$/.test(providerAccountId)) {
throw new ConvexError("GitHub account lookup failed");
}
}
async function fetchGitHubUserByNumericId(providerAccountId: string) {
assertGitHubNumericId(providerAccountId);
const url = `${GITHUB_API}/user/${providerAccountId}`;
const headers = await buildGitHubApiHeaders({ userAgent: "clawhub" });
const response = await fetch(url, {
headers,
});
if (response.status !== 401 || !headers.Authorization) return response;
console.warn("[githubAccount] GitHub API auth was rejected; retrying lookup without auth");
return await fetch(url, {
headers: { "User-Agent": "clawhub" },
});
}
export async function fetchGitHubCreatedAtByProviderAccountId(providerAccountId: string) {
const response = await fetchGitHubUserByNumericId(providerAccountId);
if (!response.ok) {
if (response.status === 403 || response.status === 429) {
throw new ConvexError("GitHub API rate limit exceeded — please try again in a few minutes");
}
throw new ConvexError("GitHub account lookup failed");
}
const payload = (await response.json()) as GitHubUser;
const parsed = payload.created_at ? Date.parse(payload.created_at) : Number.NaN;
if (!Number.isFinite(parsed)) throw new ConvexError("GitHub account lookup failed");
return parsed;
}
export async function requireGitHubAccountAge(ctx: GitHubAccountGateCtx, userId: Id<"users">) {
const user = await ctx.runQuery(internal.users.getByIdInternal, { userId });
if (!user || user.deletedAt || user.deactivatedAt) throw new ConvexError("User not found");
if (user.role === "admin") return;
const now = Date.now();
let createdAt = user.githubCreatedAt ?? null;
if (!createdAt) {
const providerAccountId = await ctx.runQuery(
internal.githubIdentity.getGitHubProviderAccountIdInternal,
{ userId },
);
if (!providerAccountId) {
// Invariant: GitHub is our only auth provider, so this should never happen.
throw new ConvexError("GitHub account required");
}
createdAt = await fetchGitHubCreatedAtByProviderAccountId(providerAccountId);
await ctx.runMutation(internal.users.setGitHubCreatedAtInternal, {
userId,
githubCreatedAt: createdAt,
});
}
if (!createdAt) throw new ConvexError("GitHub account lookup failed");
const ageMs = now - createdAt;
if (ageMs < MIN_ACCOUNT_AGE_MS) {
const remainingMs = MIN_ACCOUNT_AGE_MS - ageMs;
const remainingDays = Math.max(1, Math.ceil(remainingMs / (24 * 60 * 60 * 1000)));
throw new ConvexError(
`GitHub account must be at least 14 days old to publish skills. Try again in ${remainingDays} day${
remainingDays === 1 ? "" : "s"
}.`,
);
}
}
/**
* Sync the user's GitHub profile (username, avatar) from the GitHub API.
* This handles the case where a user renames their GitHub account.
* Uses the immutable GitHub numeric ID to fetch the current profile.
*/
export async function syncGitHubProfile(ctx: ActionCtx, userId: Id<"users">) {
const user = await ctx.runQuery(internal.users.getByIdInternal, { userId });
if (!user || user.deletedAt || user.deactivatedAt) return;
const now = Date.now();
const lastSyncedAt = user.githubProfileSyncedAt ?? null;
if (lastSyncedAt && now - lastSyncedAt < GITHUB_PROFILE_SYNC_WINDOW_MS) return;
const providerAccountId = await ctx.runQuery(
internal.githubIdentity.getGitHubProviderAccountIdInternal,
{ userId },
);
if (!providerAccountId) return;
assertGitHubNumericId(providerAccountId);
const response = await fetchGitHubUserByNumericId(providerAccountId);
if (!response.ok) {
// Silently fail - this is a best-effort sync, not critical path
console.warn(`[syncGitHubProfile] GitHub API error for user ${userId}: ${response.status}`);
return;
}
const payload = (await response.json()) as GitHubUser;
const newLogin = payload.login?.trim();
const newImage = payload.avatar_url?.trim();
const profileName = payload.name?.trim();
if (!newLogin) return;
const args: {
userId: Id<"users">;
name: string;
image?: string;
syncedAt: number;
profileName?: string;
} = {
userId,
name: newLogin,
image: newImage,
syncedAt: now,
};
if (profileName && profileName !== newLogin) {
args.profileName = profileName;
}
await ctx.runMutation(internal.users.syncGitHubProfileInternal, args);
}