mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
2814 lines
88 KiB
TypeScript
2814 lines
88 KiB
TypeScript
import { ConvexError, v } from "convex/values";
|
|
import { internal } from "./_generated/api";
|
|
import type { Doc, Id } from "./_generated/dataModel";
|
|
import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server";
|
|
import { internalAction, internalMutation, internalQuery, mutation, query } from "./functions";
|
|
import {
|
|
assertAdmin,
|
|
assertModerator,
|
|
getOptionalActiveAuthUserId,
|
|
requireUser,
|
|
} from "./lib/access";
|
|
import { isLocalDevAuthEnabled } from "./lib/devAuth";
|
|
import { syncGitHubProfile } from "./lib/githubAccount";
|
|
import { hasOfficialPublisherRow } from "./lib/officialPublishers";
|
|
import { toPublicUser } from "./lib/public";
|
|
import {
|
|
formatReservedPublicOwnerHandleMessage,
|
|
isReservedOpenClawExtensionHandle,
|
|
isReservedPublicOwnerHandle,
|
|
} from "./lib/publicRouteReservations";
|
|
import {
|
|
ensurePersonalPublisherForUser,
|
|
getActiveUserByHandleOrPersonalPublisher,
|
|
getPublisherByHandle,
|
|
getPublisherMembership,
|
|
getPersonalPublisherForUser,
|
|
getPersonalPublisherForUserOrFallback,
|
|
getUserByHandleOrPersonalPublisher,
|
|
isReservedOpenClawPublisherHandle,
|
|
} from "./lib/publishers";
|
|
import {
|
|
getPackagePublisherContribution,
|
|
getSkillPublisherContribution,
|
|
} from "./lib/publisherStats";
|
|
import {
|
|
getLatestActiveReservedHandle,
|
|
isHandleReservedForAnotherUser,
|
|
normalizeReservedHandle,
|
|
upsertReservedHandleForRightfulOwner,
|
|
} from "./lib/reservedHandles";
|
|
import { buildUserSearchResults } from "./lib/userSearch";
|
|
|
|
const DEFAULT_ROLE = "user";
|
|
const ADMIN_HANDLE = "steipete";
|
|
const MAX_USER_LIST_LIMIT = 200;
|
|
const MAX_USER_SEARCH_SCAN = 5_000;
|
|
const MIN_USER_SEARCH_SCAN = 500;
|
|
const DEV_PERSONA_GITHUB_CREATED_AT = Date.UTC(2020, 0, 1);
|
|
const AUTOBAN_AUDIT_MATCH_WINDOW_MS = 5_000;
|
|
const BAN_AUDIT_ACTIONS = new Set([
|
|
"user.ban",
|
|
"user.autoban.malware",
|
|
"user.autoban.publisher_abuse",
|
|
]);
|
|
const BAN_APPEAL_AUTH_ACCOUNT_MATCH_LIMIT = 20;
|
|
const MALICIOUS_ARTIFACT_FINDING_ACTION = "user.malicious_artifact.finding";
|
|
const MALICIOUS_ARTIFACT_DISTINCT_BAN_THRESHOLD = 2;
|
|
const MALICIOUS_ARTIFACT_ATTEMPT_BAN_THRESHOLD = 3;
|
|
const MALICIOUS_ARTIFACT_AUDIT_LOOKBACK = 100;
|
|
const DEV_PERSONA_BANNED_REAUTH_MESSAGE =
|
|
"This account has been banned and cannot sign in. If you believe this is a mistake, appeal this decision: https://appeals.openclaw.ai/.";
|
|
const ACCOUNT_RECOVERY_PURGE_LIMIT_DEFAULT = 25;
|
|
const ACCOUNT_RECOVERY_PURGE_LIMIT_MAX = 100;
|
|
const HOVER_STATS_COMPATIBILITY_ROW_LIMIT = 200;
|
|
const MAX_STAFF_PUBLISHER_MANAGER_EXCLUSION_SCAN = 100;
|
|
const MAX_STAFF_PUBLISHER_MANAGER_EXCLUSION_READS = 2_000;
|
|
const STAFF_PUBLISHER_MANAGER_ROLES = ["owner", "admin"] as const;
|
|
const accountRecoveryPurgeModeValidator = v.optional(
|
|
v.union(v.literal("deactivated"), v.literal("legacyDeleted")),
|
|
);
|
|
type DeletedAccountCleanupResult = {
|
|
authAccounts: number;
|
|
authVerificationCodes: number;
|
|
authSessions: number;
|
|
authRefreshTokens: number;
|
|
githubOrgMemberships: number;
|
|
apiTokens: number;
|
|
personalPublisherDeleted: boolean;
|
|
};
|
|
type AccountRecoveryPurgeEligibilityReason =
|
|
| "self_delete_audit"
|
|
| "auth_locked_purged_user"
|
|
| "auth_locked_legacy_deleted_user";
|
|
type AccountRecoveryPurgeEligibility =
|
|
| {
|
|
eligible: true;
|
|
reason: AccountRecoveryPurgeEligibilityReason;
|
|
selfDeleteAuditLog: Doc<"auditLogs"> | null;
|
|
authAccountCount: number | null;
|
|
}
|
|
| {
|
|
eligible: false;
|
|
selfDeleteAuditLog: null;
|
|
};
|
|
type AccountRecoveryPurgeCandidate = {
|
|
userId: Id<"users">;
|
|
eligibilityReason: AccountRecoveryPurgeEligibilityReason;
|
|
handle: string | null;
|
|
displayName: string | null;
|
|
emailPresent: boolean;
|
|
personalPublisherId: Id<"publishers"> | null;
|
|
authAccountCount: number | null;
|
|
deletedAt: number | null;
|
|
deactivatedAt: number | null;
|
|
purgedAt: number | null;
|
|
selfDeleteAuditLogId: Id<"auditLogs"> | null;
|
|
selfDeleteAuditCreatedAt: number | null;
|
|
};
|
|
|
|
type BanEmailTarget = Pick<Doc<"users">, "_id" | "email" | "handle">;
|
|
type MaliciousArtifactKind = "skill" | "plugin";
|
|
type MaliciousArtifactFinding = {
|
|
artifactKind: MaliciousArtifactKind;
|
|
artifactName: string;
|
|
};
|
|
type PublisherAbuseAutobanNomination = Doc<"publisherAbuseReviewNominations">;
|
|
type PublisherAbuseAutobanNominationCheck =
|
|
| { ok: true; nomination: PublisherAbuseAutobanNomination }
|
|
| {
|
|
ok: false;
|
|
reason: "nomination_not_actionable";
|
|
};
|
|
type ApplyUserBanArgs = {
|
|
target: Doc<"users">;
|
|
targetUserId: Id<"users">;
|
|
actorUserId: Id<"users">;
|
|
deletedByRole: "admin" | "moderator" | "user";
|
|
auditAction: "user.ban" | "user.autoban.publisher_abuse";
|
|
emailSource: "manual" | "autoban";
|
|
reasonRaw?: string;
|
|
hiddenBy?: Id<"users">;
|
|
emailTrigger?: string;
|
|
auditMetadata?: Record<string, unknown>;
|
|
};
|
|
|
|
async function scheduleBanNotificationEmail(
|
|
ctx: Pick<MutationCtx, "scheduler">,
|
|
args: {
|
|
target: BanEmailTarget;
|
|
bannedAt: number;
|
|
source: "manual" | "autoban";
|
|
reason?: string;
|
|
trigger?: string;
|
|
artifact?: { kind: "skill" | "plugin"; name: string };
|
|
hiddenArtifacts?: number;
|
|
},
|
|
) {
|
|
const to = args.target.email?.trim();
|
|
if (!to) return;
|
|
|
|
await ctx.scheduler.runAfter(0, internal.emailsNode.sendBanNotificationInternal, {
|
|
userId: args.target._id,
|
|
bannedAt: args.bannedAt,
|
|
to,
|
|
handle: args.target.handle,
|
|
source: args.source,
|
|
reason: args.reason,
|
|
trigger: args.trigger,
|
|
artifact: args.artifact,
|
|
hiddenArtifacts: args.hiddenArtifacts,
|
|
});
|
|
}
|
|
|
|
async function scheduleRestoredAccountNotificationEmail(
|
|
ctx: Pick<MutationCtx, "scheduler">,
|
|
args: {
|
|
target: BanEmailTarget;
|
|
restoredAt: number;
|
|
restoredListings?: Array<{ kind: "skill" | "plugin"; name: string }>;
|
|
skillsRestored?: number;
|
|
packagesRestored?: number;
|
|
},
|
|
) {
|
|
const to = args.target.email?.trim();
|
|
if (!to) return;
|
|
|
|
await ctx.scheduler.runAfter(0, internal.emailsNode.sendRestoredAccountNotificationInternal, {
|
|
userId: args.target._id,
|
|
restoredAt: args.restoredAt,
|
|
to,
|
|
handle: args.target.handle,
|
|
restoredListings: args.restoredListings,
|
|
skillsRestored: args.skillsRestored,
|
|
packagesRestored: args.packagesRestored,
|
|
});
|
|
}
|
|
|
|
async function scheduleMaliciousArtifactNotificationEmail(
|
|
ctx: Pick<MutationCtx, "scheduler">,
|
|
args: {
|
|
target: BanEmailTarget;
|
|
findingAt: number;
|
|
artifact: { kind: MaliciousArtifactKind; name: string };
|
|
version?: string;
|
|
trigger?: string;
|
|
findingSummary?: string;
|
|
},
|
|
) {
|
|
const to = args.target.email?.trim();
|
|
if (!to) return;
|
|
|
|
await ctx.scheduler.runAfter(0, internal.emailsNode.sendMaliciousArtifactNotificationInternal, {
|
|
userId: args.target._id,
|
|
findingAt: args.findingAt,
|
|
to,
|
|
handle: args.target.handle,
|
|
artifact: args.artifact,
|
|
version: args.version,
|
|
trigger: args.trigger,
|
|
findingSummary: args.findingSummary,
|
|
});
|
|
}
|
|
|
|
async function getActionablePublisherAbuseAutobanNomination(
|
|
ctx: Pick<MutationCtx, "db">,
|
|
args: {
|
|
nominationId: Id<"publisherAbuseReviewNominations">;
|
|
scoreId: Id<"publisherAbuseScores">;
|
|
ownerUserId: Id<"users">;
|
|
},
|
|
): Promise<PublisherAbuseAutobanNominationCheck> {
|
|
const nomination = await ctx.db.get(args.nominationId);
|
|
if (!nomination) return { ok: false, reason: "nomination_not_actionable" };
|
|
if (nomination.status !== "pending") return { ok: false, reason: "nomination_not_actionable" };
|
|
if (nomination.label !== "potential_ban_candidate") {
|
|
return { ok: false, reason: "nomination_not_actionable" };
|
|
}
|
|
if (nomination.latestScoreId !== args.scoreId) {
|
|
return { ok: false, reason: "nomination_not_actionable" };
|
|
}
|
|
if (nomination.ownerUserId !== args.ownerUserId) {
|
|
return { ok: false, reason: "nomination_not_actionable" };
|
|
}
|
|
if (!nomination.ownerPublisherId) return { ok: false, reason: "nomination_not_actionable" };
|
|
const publisher = await ctx.db.get(nomination.ownerPublisherId);
|
|
if (
|
|
!publisher ||
|
|
publisher.deletedAt ||
|
|
publisher.deactivatedAt ||
|
|
publisher.linkedUserId !== args.ownerUserId
|
|
) {
|
|
return { ok: false, reason: "nomination_not_actionable" };
|
|
}
|
|
if (await hasOfficialPublisherRow(ctx, publisher._id)) {
|
|
return { ok: false, reason: "nomination_not_actionable" };
|
|
}
|
|
if (publisher.kind === "org" && (await hasStaffPublisherManager(ctx, publisher._id))) {
|
|
return { ok: false, reason: "nomination_not_actionable" };
|
|
}
|
|
return { ok: true, nomination };
|
|
}
|
|
|
|
async function hasStaffPublisherManager(
|
|
ctx: Pick<MutationCtx, "db">,
|
|
publisherId: Id<"publishers">,
|
|
) {
|
|
let remainingDocReads = MAX_STAFF_PUBLISHER_MANAGER_EXCLUSION_READS;
|
|
for (const role of STAFF_PUBLISHER_MANAGER_ROLES) {
|
|
let cursor: string | null = null;
|
|
do {
|
|
if (remainingDocReads <= 1) return true;
|
|
const page = await ctx.db
|
|
.query("publisherMembers")
|
|
.withIndex("by_publisher_and_role", (q) =>
|
|
q.eq("publisherId", publisherId).eq("role", role),
|
|
)
|
|
.paginate({
|
|
cursor,
|
|
numItems: Math.min(
|
|
MAX_STAFF_PUBLISHER_MANAGER_EXCLUSION_SCAN,
|
|
Math.floor(remainingDocReads / 2),
|
|
),
|
|
});
|
|
remainingDocReads -= page.page.length;
|
|
|
|
for (const member of page.page) {
|
|
if (remainingDocReads <= 0) return true;
|
|
remainingDocReads -= 1;
|
|
const user = await ctx.db.get(member.userId);
|
|
if (user?.role === "admin" || user?.role === "moderator") return true;
|
|
}
|
|
cursor = page.isDone ? null : page.continueCursor;
|
|
} while (cursor !== null);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function markPublisherAbuseAutobanNominationBanned(
|
|
ctx: Pick<MutationCtx, "db">,
|
|
args: {
|
|
nomination: PublisherAbuseAutobanNomination;
|
|
reason: string;
|
|
now: number;
|
|
},
|
|
) {
|
|
await ctx.db.patch(args.nomination._id, {
|
|
status: "banned",
|
|
reviewedByUserId: undefined,
|
|
reviewedAt: args.now,
|
|
notes: args.reason,
|
|
updatedAt: args.now,
|
|
});
|
|
await ctx.db.insert("publisherAbuseReviewEvents", {
|
|
nominationId: args.nomination._id,
|
|
ownerKey: args.nomination.ownerKey,
|
|
scoreId: args.nomination.latestScoreId,
|
|
eventType: "triage_status_changed",
|
|
previousStatus: args.nomination.status,
|
|
nextStatus: "banned",
|
|
notes: args.reason,
|
|
createdAt: args.now,
|
|
});
|
|
}
|
|
|
|
async function purgeAuthStateForUser(ctx: MutationCtx, userId: Id<"users">) {
|
|
const accounts = await ctx.db
|
|
.query("authAccounts")
|
|
.withIndex("userIdAndProvider", (q) => q.eq("userId", userId))
|
|
.collect();
|
|
let authVerificationCodes = 0;
|
|
for (const account of accounts) {
|
|
const codes = await ctx.db
|
|
.query("authVerificationCodes")
|
|
.withIndex("accountId", (q) => q.eq("accountId", account._id))
|
|
.collect();
|
|
authVerificationCodes += codes.length;
|
|
for (const code of codes) await ctx.db.delete(code._id);
|
|
await ctx.db.delete(account._id);
|
|
}
|
|
|
|
const sessions = await ctx.db
|
|
.query("authSessions")
|
|
.withIndex("userId", (q) => q.eq("userId", userId))
|
|
.collect();
|
|
let authRefreshTokens = 0;
|
|
for (const session of sessions) {
|
|
const refreshTokens = await ctx.db
|
|
.query("authRefreshTokens")
|
|
.withIndex("sessionId", (q) => q.eq("sessionId", session._id))
|
|
.collect();
|
|
authRefreshTokens += refreshTokens.length;
|
|
for (const refreshToken of refreshTokens) await ctx.db.delete(refreshToken._id);
|
|
await ctx.db.delete(session._id);
|
|
}
|
|
|
|
return {
|
|
authAccounts: accounts.length,
|
|
authVerificationCodes,
|
|
authSessions: sessions.length,
|
|
authRefreshTokens,
|
|
};
|
|
}
|
|
|
|
async function hardDeleteSelfDeletedAccountState(
|
|
ctx: MutationCtx,
|
|
user: Doc<"users">,
|
|
deletedAt: number,
|
|
): Promise<DeletedAccountCleanupResult> {
|
|
const tokens = await ctx.db
|
|
.query("apiTokens")
|
|
.withIndex("by_user", (q) => q.eq("userId", user._id))
|
|
.collect();
|
|
for (const token of tokens) await ctx.db.delete(token._id);
|
|
const githubOrgMemberships = await ctx.db
|
|
.query("githubOrgMemberships")
|
|
.withIndex("by_user", (q) => q.eq("userId", user._id))
|
|
.collect();
|
|
for (const membership of githubOrgMemberships) await ctx.db.delete(membership._id);
|
|
|
|
const personalPublisher = user.personalPublisherId
|
|
? await ctx.db.get(user.personalPublisherId)
|
|
: await getPersonalPublisherForUser(ctx, user._id);
|
|
let personalPublisherDeleted = false;
|
|
if (personalPublisher) {
|
|
const publisherDeletedAt = personalPublisher.deletedAt ?? deletedAt;
|
|
if (!personalPublisher.deletedAt || !personalPublisher.deactivatedAt) {
|
|
await ctx.db.patch(personalPublisher._id, {
|
|
deletedAt: publisherDeletedAt,
|
|
deactivatedAt: publisherDeletedAt,
|
|
updatedAt: deletedAt,
|
|
});
|
|
}
|
|
await ctx.runMutation(internal.skills.applyPublisherDeletionToOwnedSkillsBatchInternal, {
|
|
ownerPublisherId: personalPublisher._id,
|
|
actorUserId: user._id,
|
|
deletedAt: publisherDeletedAt,
|
|
cursor: undefined,
|
|
});
|
|
await ctx.runMutation(internal.packages.applyPublisherDeletionToOwnedPackagesBatchInternal, {
|
|
ownerPublisherId: personalPublisher._id,
|
|
actorUserId: user._id,
|
|
deletedAt: publisherDeletedAt,
|
|
cursor: undefined,
|
|
});
|
|
await ctx.runMutation(internal.publishers.hardDeletePublisherRowsInternal, {
|
|
publisherId: personalPublisher._id,
|
|
});
|
|
personalPublisherDeleted = true;
|
|
}
|
|
|
|
await ctx.runMutation(internal.packages.applyAccountDeletionToOwnedPackagesBatchInternal, {
|
|
ownerUserId: user._id,
|
|
deletedAt,
|
|
cursor: undefined,
|
|
});
|
|
await ctx.runMutation(internal.skills.applyAccountDeletionToOwnedSkillsBatchInternal, {
|
|
ownerUserId: user._id,
|
|
hiddenBy: user._id,
|
|
deletedAt,
|
|
cursor: undefined,
|
|
});
|
|
await ctx.runMutation(internal.telemetry.clearUserTelemetryInternal, { userId: user._id });
|
|
const authState = await purgeAuthStateForUser(ctx, user._id);
|
|
return {
|
|
...authState,
|
|
githubOrgMemberships: githubOrgMemberships.length,
|
|
apiTokens: tokens.length,
|
|
personalPublisherDeleted,
|
|
};
|
|
}
|
|
|
|
async function scrubDeletedUserTombstone(ctx: MutationCtx, user: Doc<"users">, deletedAt: number) {
|
|
await ctx.db.patch(user._id, {
|
|
deactivatedAt: user.deactivatedAt ?? deletedAt,
|
|
purgedAt: user.purgedAt ?? deletedAt,
|
|
deletedAt: undefined,
|
|
banReason: undefined,
|
|
role: "user",
|
|
handle: undefined,
|
|
displayName: undefined,
|
|
name: undefined,
|
|
image: undefined,
|
|
email: undefined,
|
|
emailVerificationTime: undefined,
|
|
phone: undefined,
|
|
phoneVerificationTime: undefined,
|
|
isAnonymous: undefined,
|
|
bio: undefined,
|
|
githubCreatedAt: undefined,
|
|
githubOrgMembershipsSyncedAt: undefined,
|
|
githubOrgMembershipsTruncated: undefined,
|
|
updatedAt: deletedAt,
|
|
});
|
|
}
|
|
const DEV_PERSONAS = {
|
|
owner: {
|
|
handle: "local",
|
|
displayName: "Local Owner",
|
|
role: "user",
|
|
},
|
|
user: {
|
|
handle: "local-user",
|
|
displayName: "Local User",
|
|
role: "user",
|
|
},
|
|
admin: {
|
|
handle: "local-admin",
|
|
displayName: "Local Admin",
|
|
role: "admin",
|
|
},
|
|
officialOrgMember: {
|
|
handle: "local-official-member",
|
|
displayName: "Local Official Org Member",
|
|
role: "user",
|
|
},
|
|
abusePublisher: {
|
|
handle: "local-abuse",
|
|
displayName: "Local Abuse Test Publisher",
|
|
email: "local-abuse@example.test",
|
|
role: "user",
|
|
},
|
|
} as const;
|
|
|
|
const DEV_OFFICIAL_ORG = {
|
|
handle: "local-official-org",
|
|
displayName: "Local Official Org",
|
|
reason: "dev-persona.official-org-member",
|
|
} as const;
|
|
const DEV_GITHUB_ORGS = [
|
|
{
|
|
githubOrgId: "100000001",
|
|
login: "openclaw",
|
|
avatarUrl: "https://avatars.githubusercontent.com/u/188567264?v=4",
|
|
role: "admin" as const,
|
|
},
|
|
{
|
|
githubOrgId: "100000002",
|
|
login: "trycua",
|
|
avatarUrl: "https://avatars.githubusercontent.com/u/175698028?v=4",
|
|
role: "member" as const,
|
|
},
|
|
];
|
|
|
|
type DevPersona = keyof typeof DEV_PERSONAS;
|
|
|
|
async function hasBlockingBanAudit(ctx: Pick<MutationCtx, "db">, userId: Id<"users">) {
|
|
const banRecords = await ctx.db
|
|
.query("auditLogs")
|
|
.withIndex("by_target", (q) => q.eq("targetType", "user").eq("targetId", userId.toString()))
|
|
.collect();
|
|
return banRecords.some((record) => BAN_AUDIT_ACTIONS.has(record.action));
|
|
}
|
|
|
|
export const getById = query({
|
|
args: { userId: v.id("users") },
|
|
handler: async (ctx, args) => toPublicUser(await ctx.db.get(args.userId)),
|
|
});
|
|
|
|
export const getByIdInternal = internalQuery({
|
|
args: { userId: v.id("users") },
|
|
handler: async (ctx, args) => ctx.db.get(args.userId),
|
|
});
|
|
|
|
export const upsertDevPersonaInternal = internalMutation({
|
|
args: {
|
|
persona: v.union(
|
|
v.literal("owner"),
|
|
v.literal("user"),
|
|
v.literal("admin"),
|
|
v.literal("officialOrgMember"),
|
|
v.literal("abusePublisher"),
|
|
),
|
|
devAuthSecret: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args): Promise<Id<"users">> => {
|
|
if (!isLocalDevAuthEnabled(process.env, args.devAuthSecret)) {
|
|
throw new Error("Dev auth is disabled");
|
|
}
|
|
|
|
const persona = DEV_PERSONAS[args.persona as DevPersona];
|
|
const now = Date.now();
|
|
const existing = await getUserByHandleOrPersonalPublisher(ctx, persona.handle);
|
|
const patch = {
|
|
handle: persona.handle,
|
|
displayName: persona.displayName,
|
|
name: persona.handle,
|
|
email: "email" in persona ? persona.email : undefined,
|
|
role: persona.role,
|
|
githubCreatedAt: DEV_PERSONA_GITHUB_CREATED_AT,
|
|
deletedAt: undefined,
|
|
deactivatedAt: undefined,
|
|
purgedAt: undefined,
|
|
banReason: undefined,
|
|
updatedAt: now,
|
|
};
|
|
if (
|
|
existing &&
|
|
(existing.deletedAt || existing.deactivatedAt) &&
|
|
(await hasBlockingBanAudit(ctx, existing._id))
|
|
) {
|
|
throw new ConvexError(DEV_PERSONA_BANNED_REAUTH_MESSAGE);
|
|
}
|
|
const userId =
|
|
existing?._id ??
|
|
(await ctx.db.insert("users", {
|
|
...patch,
|
|
createdAt: now,
|
|
}));
|
|
if (existing) {
|
|
await ctx.db.patch(existing._id, patch);
|
|
}
|
|
const user = await ctx.db.get(userId);
|
|
if (!user) throw new Error("Dev persona was not created");
|
|
await ensurePersonalPublisherForUser(ctx, user, {
|
|
actorUserId: user._id,
|
|
source: "dev_persona.upsert",
|
|
});
|
|
if (args.persona === "officialOrgMember") {
|
|
await ensureDevOfficialOrgMembership(ctx, user, now);
|
|
await replaceDevGitHubOrgMemberships(ctx, user._id, now);
|
|
}
|
|
return userId;
|
|
},
|
|
});
|
|
|
|
async function replaceDevGitHubOrgMemberships(
|
|
ctx: MutationCtx,
|
|
userId: Id<"users">,
|
|
syncedAt: number,
|
|
) {
|
|
const existing = await ctx.db
|
|
.query("githubOrgMemberships")
|
|
.withIndex("by_user", (q) => q.eq("userId", userId))
|
|
.collect();
|
|
for (const membership of existing) await ctx.db.delete(membership._id);
|
|
for (const membership of DEV_GITHUB_ORGS) {
|
|
await ctx.db.insert("githubOrgMemberships", {
|
|
userId,
|
|
...membership,
|
|
syncedAt,
|
|
});
|
|
}
|
|
await ctx.db.patch(userId, {
|
|
githubOrgMembershipsSyncedAt: syncedAt,
|
|
githubOrgMembershipsTruncated: undefined,
|
|
});
|
|
}
|
|
|
|
async function ensureDevOfficialOrgMembership(ctx: MutationCtx, user: Doc<"users">, now: number) {
|
|
let publisher = await getPublisherByHandle(ctx, DEV_OFFICIAL_ORG.handle);
|
|
let publisherId = publisher?._id;
|
|
|
|
if (!publisherId) {
|
|
publisherId = await ctx.db.insert("publishers", {
|
|
kind: "org",
|
|
handle: DEV_OFFICIAL_ORG.handle,
|
|
displayName: DEV_OFFICIAL_ORG.displayName,
|
|
bio: undefined,
|
|
image: undefined,
|
|
linkedUserId: undefined,
|
|
trustedPublisher: undefined,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
} else if (publisher?.deletedAt || publisher?.deactivatedAt) {
|
|
await ctx.db.patch(publisherId, {
|
|
displayName: DEV_OFFICIAL_ORG.displayName,
|
|
deletedAt: undefined,
|
|
deactivatedAt: undefined,
|
|
updatedAt: now,
|
|
});
|
|
}
|
|
|
|
const existingOfficial = await ctx.db
|
|
.query("officialPublishers")
|
|
.withIndex("by_publisher", (q) => q.eq("publisherId", publisherId))
|
|
.unique();
|
|
if (!existingOfficial) {
|
|
await ctx.db.insert("officialPublishers", {
|
|
publisherId,
|
|
reason: DEV_OFFICIAL_ORG.reason,
|
|
createdByUserId: user._id,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
}
|
|
|
|
const membership = await getPublisherMembership(ctx, publisherId, user._id);
|
|
if (!membership) {
|
|
await ctx.db.insert("publisherMembers", {
|
|
publisherId,
|
|
userId: user._id,
|
|
role: "admin",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
} else if (membership.role === "publisher") {
|
|
await ctx.db.patch(membership._id, { role: "admin", updatedAt: now });
|
|
}
|
|
}
|
|
|
|
export const getByHandleInternal = internalQuery({
|
|
args: { handle: v.string() },
|
|
handler: async (ctx, args) => {
|
|
return await getUserByHandleOrPersonalPublisher(ctx, args.handle);
|
|
},
|
|
});
|
|
|
|
export const getBanAppealContextByGitHubProviderAccountIdInternal = internalQuery({
|
|
args: { providerAccountId: v.string() },
|
|
handler: async (ctx, args) => {
|
|
const providerAccountId = args.providerAccountId.trim();
|
|
if (!/^\d+$/.test(providerAccountId)) {
|
|
return { ok: true as const, action: "moderated" as const, userId: null };
|
|
}
|
|
|
|
const accounts = await ctx.db
|
|
.query("authAccounts")
|
|
.withIndex("providerAndAccountId", (q) =>
|
|
q.eq("provider", "github").eq("providerAccountId", providerAccountId),
|
|
)
|
|
.take(BAN_APPEAL_AUTH_ACCOUNT_MATCH_LIMIT);
|
|
if (accounts.length === 0) {
|
|
return { ok: true as const, action: "moderated" as const, userId: null };
|
|
}
|
|
|
|
let fallbackUser: Doc<"users"> | null = null;
|
|
for (const account of accounts) {
|
|
const user = await ctx.db.get(account.userId);
|
|
if (!user) continue;
|
|
fallbackUser ??= user;
|
|
if (!user.deletedAt || user.deactivatedAt) continue;
|
|
|
|
const banLog = await getCurrentBanAuditLog(ctx, user._id, user.deletedAt);
|
|
if (banLog) return toBanAppealContextResult(user, banLog);
|
|
}
|
|
|
|
if (!fallbackUser) return { ok: true as const, action: "moderated" as const, userId: null };
|
|
return toBanAppealContextResult(fallbackUser, null);
|
|
},
|
|
});
|
|
|
|
function toBanAppealContextResult(user: Doc<"users">, banLog: Doc<"auditLogs"> | null) {
|
|
const banned = Boolean(user.deletedAt && !user.deactivatedAt && banLog);
|
|
const metadata = banLog?.metadata as { reason?: string } | undefined;
|
|
|
|
return {
|
|
ok: true as const,
|
|
action: banned ? ("banned" as const) : ("moderated" as const),
|
|
userId: user._id,
|
|
handle: user.handle ?? null,
|
|
displayName: user.displayName ?? user.name ?? null,
|
|
banReason: banned ? (user.banReason ?? metadata?.reason ?? null) : null,
|
|
bannedAt: banned ? (user.deletedAt ?? null) : null,
|
|
auditAction: banLog?.action ?? null,
|
|
auditActorUserId: banLog?.actorUserId ?? null,
|
|
};
|
|
}
|
|
|
|
async function getCurrentBanAuditLog(
|
|
ctx: Pick<QueryCtx | MutationCtx, "db">,
|
|
userId: Id<"users">,
|
|
bannedAt: number,
|
|
) {
|
|
const logs = await ctx.db
|
|
.query("auditLogs")
|
|
.withIndex("by_target_createdAt", (q) =>
|
|
q
|
|
.eq("targetType", "user")
|
|
.eq("targetId", userId.toString())
|
|
.gte("createdAt", bannedAt - AUTOBAN_AUDIT_MATCH_WINDOW_MS)
|
|
.lte("createdAt", bannedAt + AUTOBAN_AUDIT_MATCH_WINDOW_MS),
|
|
)
|
|
.order("desc")
|
|
.take(20);
|
|
return logs.find((log) => BAN_AUDIT_ACTIONS.has(log.action)) ?? null;
|
|
}
|
|
|
|
export const searchInternal = internalQuery({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
query: v.optional(v.string()),
|
|
limit: v.optional(v.number()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const actor = await ctx.db.get(args.actorUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new Error("Unauthorized");
|
|
assertAdmin(actor);
|
|
|
|
const limit = clampInt(args.limit ?? 20, 1, MAX_USER_LIST_LIMIT);
|
|
const exactHandleUser = args.query
|
|
? await getUserByHandleOrPersonalPublisher(ctx, args.query)
|
|
: null;
|
|
const result = await queryUsersForAdminList(ctx, {
|
|
limit,
|
|
search: args.query,
|
|
exactUserId: exactHandleUser?._id,
|
|
});
|
|
const dedupedUsers = exactHandleUser
|
|
? [exactHandleUser, ...result.items.filter((user) => user._id !== exactHandleUser._id)]
|
|
: result.items;
|
|
const total = exactHandleUser
|
|
? result.total + (result.containsExactUser ? 0 : 1)
|
|
: result.total;
|
|
const items = dedupedUsers.slice(0, limit).map((user) => ({
|
|
userId: user._id,
|
|
handle: user.handle ?? null,
|
|
displayName: user.displayName ?? null,
|
|
name: user.name ?? null,
|
|
role: user.role ?? null,
|
|
}));
|
|
return { items, total };
|
|
},
|
|
});
|
|
|
|
export const setGitHubCreatedAtInternal = internalMutation({
|
|
args: {
|
|
userId: v.id("users"),
|
|
githubCreatedAt: v.number(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
await ctx.db.patch(args.userId, {
|
|
githubCreatedAt: args.githubCreatedAt,
|
|
updatedAt: Date.now(),
|
|
});
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Sync the user's GitHub profile (username, avatar) when it changes.
|
|
* This handles the case where a user renames their GitHub account.
|
|
*/
|
|
export const syncGitHubProfileInternal = internalMutation({
|
|
args: {
|
|
userId: v.id("users"),
|
|
name: v.string(),
|
|
image: v.optional(v.string()),
|
|
profileName: v.optional(v.string()),
|
|
syncedAt: v.number(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const user = await ctx.db.get(args.userId);
|
|
if (!user || user.deletedAt || user.deactivatedAt) return;
|
|
const canClaimNewHandle = await canUserClaimHandle(ctx, args.name, args.userId);
|
|
|
|
const updates: Partial<Doc<"users">> = { githubProfileSyncedAt: args.syncedAt };
|
|
let didChangeProfile = false;
|
|
|
|
if (user.name !== args.name) {
|
|
updates.name = args.name;
|
|
didChangeProfile = true;
|
|
}
|
|
|
|
// Update handle if it was derived from the old username
|
|
if (user.handle === user.name && user.name !== args.name && canClaimNewHandle) {
|
|
updates.handle = args.name;
|
|
didChangeProfile = true;
|
|
}
|
|
|
|
// Update displayName if it was derived from the old username
|
|
if (
|
|
(user.displayName === user.name || user.displayName === user.handle) &&
|
|
user.name !== args.name &&
|
|
canClaimNewHandle
|
|
) {
|
|
updates.displayName = args.name;
|
|
didChangeProfile = true;
|
|
}
|
|
|
|
// If displayName is derived/missing, prefer the GitHub profile "name" (full name).
|
|
const profileName = args.profileName?.trim();
|
|
if (profileName && profileName !== args.name) {
|
|
const currentDisplay = user.displayName?.trim();
|
|
const currentHandle = user.handle?.trim();
|
|
const currentLogin = user.name?.trim();
|
|
const isDerivedOrMissing =
|
|
!currentDisplay || currentDisplay === currentHandle || currentDisplay === currentLogin;
|
|
if (isDerivedOrMissing && currentDisplay !== profileName) {
|
|
updates.displayName = profileName;
|
|
didChangeProfile = true;
|
|
}
|
|
}
|
|
|
|
// Update avatar if provided
|
|
if (args.image && args.image !== user.image) {
|
|
updates.image = args.image;
|
|
didChangeProfile = true;
|
|
}
|
|
|
|
if (didChangeProfile) {
|
|
updates.updatedAt = Date.now();
|
|
}
|
|
await ctx.db.patch(args.userId, updates);
|
|
if (didChangeProfile) {
|
|
await ctx.db.insert("auditLogs", {
|
|
actorUserId: args.userId,
|
|
action: "user.profile.sync",
|
|
targetType: "user",
|
|
targetId: args.userId,
|
|
metadata: {
|
|
source: "github",
|
|
previous: {
|
|
name: user.name ?? null,
|
|
handle: user.handle ?? null,
|
|
displayName: user.displayName ?? null,
|
|
image: user.image ?? null,
|
|
},
|
|
next: {
|
|
name: updates.name ?? user.name ?? null,
|
|
handle: updates.handle ?? user.handle ?? null,
|
|
displayName: updates.displayName ?? user.displayName ?? null,
|
|
image: updates.image ?? user.image ?? null,
|
|
},
|
|
},
|
|
createdAt: updates.updatedAt ?? args.syncedAt,
|
|
});
|
|
}
|
|
const nextUser = didChangeProfile ? ({ ...user, ...updates } as Doc<"users">) : user;
|
|
await ensurePersonalPublisherForUser(ctx, nextUser, {
|
|
actorUserId: args.userId,
|
|
source: "user.profile.sync",
|
|
});
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Internal action to sync GitHub profile from the GitHub API.
|
|
* This is called after login to ensure the username is up-to-date.
|
|
*/
|
|
export const syncGitHubProfileAction = internalAction({
|
|
args: { userId: v.id("users") },
|
|
handler: async (ctx: ActionCtx, args) => {
|
|
await syncGitHubProfile(ctx, args.userId);
|
|
},
|
|
});
|
|
|
|
export const me = query({
|
|
args: {},
|
|
handler: async (ctx) => {
|
|
const userId = await getOptionalActiveAuthUserId(ctx);
|
|
if (!userId) return null;
|
|
return await ctx.db.get(userId);
|
|
},
|
|
});
|
|
|
|
export const ensure = mutation({
|
|
args: {},
|
|
handler: ensureHandler,
|
|
});
|
|
|
|
function normalizeHandle(handle: string | undefined) {
|
|
const normalized = handle?.trim();
|
|
return normalized ? normalized : undefined;
|
|
}
|
|
|
|
function deriveHandle(args: { existingHandle?: string; githubLogin?: string; email?: string }) {
|
|
// Prefer the GitHub login; only fall back to email-derived handle when we don't already have one.
|
|
if (args.githubLogin) return args.githubLogin;
|
|
if (!args.existingHandle && args.email) return args.email.split("@")[0]?.trim() || undefined;
|
|
return undefined;
|
|
}
|
|
|
|
function appendHandleSuffix(base: string, suffix: number) {
|
|
const suffixText = suffix <= 1 ? "" : `-${suffix}`;
|
|
const maxBaseLength = Math.max(2, 40 - suffixText.length);
|
|
return `${base.slice(0, maxBaseLength)}${suffixText}`;
|
|
}
|
|
|
|
function getSafePersonalHandleFallbackBase(handle: string | undefined) {
|
|
return isReservedOpenClawPublisherHandle(handle) ? "user" : handle;
|
|
}
|
|
|
|
async function resolveAvailableHandle(
|
|
ctx: MutationCtx,
|
|
preferredHandle: string | undefined,
|
|
userId: Id<"users">,
|
|
) {
|
|
const normalizedHandle = normalizeReservedHandle(preferredHandle);
|
|
if (!normalizedHandle) return undefined;
|
|
for (let suffix = 1; suffix <= 50; suffix += 1) {
|
|
const candidate = appendHandleSuffix(normalizedHandle, suffix);
|
|
if (await canUserClaimHandle(ctx, candidate, userId)) return candidate;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
async function canUserClaimHandle(
|
|
ctx: MutationCtx,
|
|
handle: string | undefined,
|
|
userId: Id<"users">,
|
|
) {
|
|
const normalizedHandle = normalizeReservedHandle(handle);
|
|
if (!normalizedHandle) return false;
|
|
if (isReservedPublicOwnerHandle(normalizedHandle)) return false;
|
|
if (isReservedOpenClawPublisherHandle(normalizedHandle)) return false;
|
|
if (await isHandleReservedForAnotherUser(ctx, normalizedHandle, userId)) return false;
|
|
|
|
const publisher = await getPublisherByHandle(ctx, normalizedHandle);
|
|
if (isReservedOpenClawExtensionHandle(normalizedHandle)) {
|
|
const existingUser = await getUserByHandleOrPersonalPublisher(ctx, normalizedHandle);
|
|
return existingUser?._id === userId;
|
|
}
|
|
if (!publisher || publisher.deletedAt || publisher.deactivatedAt) return true;
|
|
return publisher.kind === "user" && publisher.linkedUserId === userId;
|
|
}
|
|
|
|
async function computeEnsureUpdates(ctx: MutationCtx, user: Doc<"users">) {
|
|
const updates: Record<string, unknown> = {};
|
|
|
|
const existingHandle = normalizeHandle(user.handle);
|
|
const existingHandleClaimable = existingHandle
|
|
? await canUserClaimHandle(ctx, existingHandle, user._id)
|
|
: false;
|
|
const githubLogin = normalizeHandle(user.name);
|
|
const requestedHandle = deriveHandle({
|
|
existingHandle,
|
|
githubLogin,
|
|
email: user.email,
|
|
});
|
|
let derivedHandle =
|
|
requestedHandle && (await canUserClaimHandle(ctx, requestedHandle, user._id))
|
|
? requestedHandle
|
|
: undefined;
|
|
if (!derivedHandle && (!existingHandle || !existingHandleClaimable)) {
|
|
const emailFallback = normalizeHandle(user.email?.split("@")[0]);
|
|
const safeEmailFallback = getSafePersonalHandleFallbackBase(emailFallback);
|
|
const emailFallbackHandle =
|
|
emailFallback && emailFallback !== requestedHandle
|
|
? await resolveAvailableHandle(ctx, safeEmailFallback, user._id)
|
|
: undefined;
|
|
const preferredFallbackBase = requestedHandle ?? existingHandle ?? githubLogin ?? emailFallback;
|
|
const fallbackBase = isReservedOpenClawPublisherHandle(preferredFallbackBase)
|
|
? (safeEmailFallback ?? "user")
|
|
: preferredFallbackBase;
|
|
derivedHandle =
|
|
(await resolveAvailableHandle(ctx, fallbackBase, user._id)) ?? emailFallbackHandle;
|
|
}
|
|
const baseHandle = derivedHandle ?? (existingHandleClaimable ? existingHandle : undefined);
|
|
|
|
if (derivedHandle && existingHandle !== derivedHandle) {
|
|
updates.handle = derivedHandle;
|
|
}
|
|
|
|
const displayName = normalizeHandle(user.displayName);
|
|
if (!displayName && baseHandle) {
|
|
updates.displayName = baseHandle;
|
|
} else if (derivedHandle && displayName === existingHandle) {
|
|
updates.displayName = derivedHandle;
|
|
}
|
|
|
|
if (!user.role) {
|
|
updates.role = baseHandle === ADMIN_HANDLE ? "admin" : DEFAULT_ROLE;
|
|
}
|
|
|
|
if (!user.createdAt) updates.createdAt = user._creationTime;
|
|
|
|
return updates;
|
|
}
|
|
|
|
export async function ensureHandler(ctx: MutationCtx) {
|
|
const { userId, user } = await requireUser(ctx);
|
|
const updates = await computeEnsureUpdates(ctx, user);
|
|
|
|
const hasUpdates = Object.keys(updates).length > 0;
|
|
if (Object.keys(updates).length > 0) {
|
|
updates.updatedAt = Date.now();
|
|
await ctx.db.patch(userId, updates);
|
|
}
|
|
const ensuredUser = hasUpdates
|
|
? ({ ...user, ...updates } as Doc<"users">)
|
|
: ((await ctx.db.get(userId)) ?? user);
|
|
await ensurePersonalPublisherForUser(
|
|
ctx,
|
|
ensuredUser,
|
|
{
|
|
actorUserId: userId,
|
|
source: "user.ensure",
|
|
},
|
|
{ handleConflict: "skip" },
|
|
);
|
|
if (hasUpdates) {
|
|
await ctx.db.insert("auditLogs", {
|
|
actorUserId: userId,
|
|
action: "user.profile.ensure",
|
|
targetType: "user",
|
|
targetId: userId,
|
|
metadata: {
|
|
changedFields: Object.keys(updates).filter((field) => field !== "updatedAt"),
|
|
},
|
|
createdAt: updates.updatedAt as number,
|
|
});
|
|
}
|
|
return await ctx.db.get(userId);
|
|
}
|
|
|
|
export const updateProfile = mutation({
|
|
args: {
|
|
displayName: v.string(),
|
|
bio: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const { userId } = await requireUser(ctx);
|
|
const user = await ctx.db.get(userId);
|
|
const now = Date.now();
|
|
const displayName = args.displayName.trim();
|
|
const bio = args.bio?.trim();
|
|
await ctx.db.patch(userId, {
|
|
displayName,
|
|
bio,
|
|
updatedAt: now,
|
|
});
|
|
await ctx.db.insert("auditLogs", {
|
|
actorUserId: userId,
|
|
action: "user.profile.update",
|
|
targetType: "user",
|
|
targetId: userId,
|
|
metadata: {
|
|
previous: {
|
|
displayName: user?.displayName ?? null,
|
|
bio: user?.bio ?? null,
|
|
},
|
|
next: {
|
|
displayName,
|
|
bio: bio ?? null,
|
|
},
|
|
},
|
|
createdAt: now,
|
|
});
|
|
const nextUser = await ctx.db.get(userId);
|
|
if (nextUser) {
|
|
await ensurePersonalPublisherForUser(
|
|
ctx,
|
|
nextUser,
|
|
{
|
|
actorUserId: userId,
|
|
source: "user.profile.update",
|
|
},
|
|
{ handleConflict: "skip" },
|
|
);
|
|
}
|
|
},
|
|
});
|
|
|
|
export const deleteAccount = mutation({
|
|
args: {},
|
|
handler: async (ctx) => {
|
|
const { userId } = await requireUser(ctx);
|
|
const now = Date.now();
|
|
const user = await ctx.db.get(userId);
|
|
if (!user) throw new Error("User not found");
|
|
|
|
await ctx.runMutation(internal.publishers.deleteSoleOwnerOrgsForAccountDeletionInternal, {
|
|
actorUserId: userId,
|
|
deletedAt: now,
|
|
});
|
|
const cleanup = await hardDeleteSelfDeletedAccountState(ctx, user, now);
|
|
|
|
await ctx.db.patch(userId, {
|
|
deactivatedAt: now,
|
|
purgedAt: now,
|
|
deletedAt: undefined,
|
|
banReason: undefined,
|
|
role: "user",
|
|
handle: undefined,
|
|
displayName: undefined,
|
|
name: undefined,
|
|
image: undefined,
|
|
email: undefined,
|
|
emailVerificationTime: undefined,
|
|
phone: undefined,
|
|
phoneVerificationTime: undefined,
|
|
isAnonymous: undefined,
|
|
bio: undefined,
|
|
githubCreatedAt: undefined,
|
|
githubOrgMembershipsSyncedAt: undefined,
|
|
githubOrgMembershipsTruncated: undefined,
|
|
updatedAt: now,
|
|
});
|
|
await ctx.db.insert("auditLogs", {
|
|
actorUserId: userId,
|
|
action: "user.delete",
|
|
targetType: "user",
|
|
targetId: userId,
|
|
metadata: {
|
|
previous: {
|
|
handle: user?.handle ?? null,
|
|
displayName: user?.displayName ?? null,
|
|
name: user?.name ?? null,
|
|
image: user?.image ?? null,
|
|
emailPresent: Boolean(user?.email),
|
|
personalPublisherId: user?.personalPublisherId ?? null,
|
|
},
|
|
cleanup,
|
|
},
|
|
createdAt: now,
|
|
});
|
|
},
|
|
});
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: null;
|
|
}
|
|
|
|
function optionalString(value: unknown) {
|
|
return typeof value === "string" && value.trim() ? value : null;
|
|
}
|
|
|
|
function optionalPublisherId(value: unknown): Id<"publishers"> | null {
|
|
return typeof value === "string" && value.startsWith("publishers:")
|
|
? (value as Id<"publishers">)
|
|
: null;
|
|
}
|
|
|
|
function getSelfDeletePreviousMetadata(log: Doc<"auditLogs"> | null) {
|
|
return asRecord(asRecord(log?.metadata)?.previous);
|
|
}
|
|
|
|
function buildAccountRecoveryPurgeCandidate(
|
|
user: Doc<"users">,
|
|
eligibility: {
|
|
reason: AccountRecoveryPurgeEligibilityReason;
|
|
selfDeleteAuditLog: Doc<"auditLogs"> | null;
|
|
authAccountCount: number | null;
|
|
},
|
|
): AccountRecoveryPurgeCandidate {
|
|
const previous = getSelfDeletePreviousMetadata(eligibility.selfDeleteAuditLog);
|
|
return {
|
|
userId: user._id,
|
|
eligibilityReason: eligibility.reason,
|
|
handle: optionalString(user.handle) ?? optionalString(previous?.handle),
|
|
displayName:
|
|
optionalString(user.displayName) ??
|
|
optionalString(user.name) ??
|
|
optionalString(previous?.displayName) ??
|
|
optionalString(previous?.name),
|
|
emailPresent: Boolean(user.email) || previous?.emailPresent === true,
|
|
personalPublisherId:
|
|
user.personalPublisherId ?? optionalPublisherId(previous?.personalPublisherId),
|
|
authAccountCount: eligibility.authAccountCount,
|
|
deletedAt: user.deletedAt ?? null,
|
|
deactivatedAt: user.deactivatedAt ?? null,
|
|
purgedAt: user.purgedAt ?? null,
|
|
selfDeleteAuditLogId: eligibility.selfDeleteAuditLog?._id ?? null,
|
|
selfDeleteAuditCreatedAt: eligibility.selfDeleteAuditLog?.createdAt ?? null,
|
|
};
|
|
}
|
|
|
|
async function getSelfDeletedAccountEligibility(
|
|
ctx: MutationCtx,
|
|
user: Doc<"users">,
|
|
): Promise<AccountRecoveryPurgeEligibility> {
|
|
const hasModernTombstone = Boolean(user.deactivatedAt && user.purgedAt && !user.deletedAt);
|
|
const hasLegacySelfDeleteMarker = Boolean(user.deletedAt && !user.banReason);
|
|
if ((!hasModernTombstone && !hasLegacySelfDeleteMarker) || user.banReason) {
|
|
return { eligible: false, selfDeleteAuditLog: null };
|
|
}
|
|
const logs = await ctx.db
|
|
.query("auditLogs")
|
|
.withIndex("by_target", (q) => q.eq("targetType", "user").eq("targetId", user._id.toString()))
|
|
.collect();
|
|
const selfDeleteAuditLog =
|
|
logs.find((log) => log.action === "user.delete" && log.actorUserId === user._id) ?? null;
|
|
const hasBanAudit = logs.some((log) => BAN_AUDIT_ACTIONS.has(log.action));
|
|
const hasRecoveryPurgeAudit = logs.some((log) => log.action === "user.recovery_purge");
|
|
const selfDeleteAuditAlreadyCleaned = Boolean(
|
|
asRecord(selfDeleteAuditLog?.metadata)?.cleanup || hasRecoveryPurgeAudit,
|
|
);
|
|
if (selfDeleteAuditLog && !hasBanAudit && !selfDeleteAuditAlreadyCleaned) {
|
|
return {
|
|
eligible: true,
|
|
reason: "self_delete_audit" as const,
|
|
selfDeleteAuditLog,
|
|
authAccountCount: null,
|
|
};
|
|
}
|
|
if (hasBanAudit) {
|
|
return { eligible: false, selfDeleteAuditLog: null };
|
|
}
|
|
|
|
const authAccounts = await ctx.db
|
|
.query("authAccounts")
|
|
.withIndex("userIdAndProvider", (q) => q.eq("userId", user._id))
|
|
.collect();
|
|
if (authAccounts.length === 0) return { eligible: false, selfDeleteAuditLog: null };
|
|
|
|
if (hasLegacySelfDeleteMarker) {
|
|
return {
|
|
eligible: true,
|
|
reason: "auth_locked_legacy_deleted_user" as const,
|
|
selfDeleteAuditLog: null,
|
|
authAccountCount: authAccounts.length,
|
|
};
|
|
}
|
|
|
|
if (!hasModernTombstone) return { eligible: false, selfDeleteAuditLog: null };
|
|
|
|
const profileIdentityScrubbed = !user.handle && !user.email && !user.name && !user.displayName;
|
|
if (!profileIdentityScrubbed) return { eligible: false, selfDeleteAuditLog: null };
|
|
|
|
return {
|
|
eligible: true,
|
|
reason: "auth_locked_purged_user" as const,
|
|
selfDeleteAuditLog: null,
|
|
authAccountCount: authAccounts.length,
|
|
};
|
|
}
|
|
|
|
export const purgeSelfDeletedAccountRecoveryBatchInternal = internalMutation({
|
|
args: {
|
|
cursor: v.optional(v.string()),
|
|
limit: v.optional(v.number()),
|
|
dryRun: v.optional(v.boolean()),
|
|
mode: accountRecoveryPurgeModeValidator,
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const limit = clampInt(
|
|
args.limit ?? ACCOUNT_RECOVERY_PURGE_LIMIT_DEFAULT,
|
|
1,
|
|
ACCOUNT_RECOVERY_PURGE_LIMIT_MAX,
|
|
);
|
|
const dryRun = args.dryRun !== false;
|
|
const mode = args.mode ?? "deactivated";
|
|
const { page, isDone, continueCursor } =
|
|
mode === "legacyDeleted"
|
|
? await ctx.db
|
|
.query("users")
|
|
.withIndex("by_ban_reason_deleted_at", (q) =>
|
|
q.eq("banReason", undefined).gte("deletedAt", 0),
|
|
)
|
|
.paginate({ cursor: args.cursor ?? null, numItems: limit })
|
|
: await ctx.db
|
|
.query("users")
|
|
.withIndex("by_deactivated_purged_at", (q) => q.gte("deactivatedAt", 0))
|
|
.paginate({ cursor: args.cursor ?? null, numItems: limit });
|
|
|
|
let eligible = 0;
|
|
let purged = 0;
|
|
const skipped: Array<{ userId: Id<"users">; reason: string }> = [];
|
|
const candidates: AccountRecoveryPurgeCandidate[] = [];
|
|
const cleaned: Array<
|
|
DeletedAccountCleanupResult & { userId: Id<"users">; deactivatedAt: number }
|
|
> = [];
|
|
|
|
for (const user of page) {
|
|
const eligibility = await getSelfDeletedAccountEligibility(ctx, user);
|
|
if (!eligibility.eligible) {
|
|
skipped.push({ userId: user._id, reason: "not_self_deleted_or_security_blocked" });
|
|
continue;
|
|
}
|
|
eligible += 1;
|
|
candidates.push(buildAccountRecoveryPurgeCandidate(user, eligibility));
|
|
if (dryRun) continue;
|
|
const deletedAt = user.deactivatedAt ?? user.deletedAt ?? Date.now();
|
|
const cleanup = await hardDeleteSelfDeletedAccountState(ctx, user, deletedAt);
|
|
await scrubDeletedUserTombstone(ctx, user, deletedAt);
|
|
await ctx.db.insert("auditLogs", {
|
|
actorUserId: user._id,
|
|
action: "user.recovery_purge",
|
|
targetType: "user",
|
|
targetId: user._id,
|
|
metadata: {
|
|
deactivatedAt: user.deactivatedAt,
|
|
purgedAt: user.purgedAt,
|
|
deletedAt: user.deletedAt,
|
|
cleanup,
|
|
mode,
|
|
source: "backfill",
|
|
},
|
|
createdAt: Date.now(),
|
|
});
|
|
cleaned.push({ userId: user._id, deactivatedAt: deletedAt, ...cleanup });
|
|
purged += 1;
|
|
}
|
|
|
|
return {
|
|
ok: true as const,
|
|
dryRun,
|
|
mode,
|
|
scanned: page.length,
|
|
eligible,
|
|
purged,
|
|
skipped,
|
|
candidates,
|
|
cleaned,
|
|
isDone,
|
|
cursor: isDone ? null : continueCursor,
|
|
};
|
|
},
|
|
});
|
|
|
|
export const list = query({
|
|
args: { limit: v.optional(v.number()), search: v.optional(v.string()) },
|
|
handler: async (ctx, args) => {
|
|
const { user } = await requireUser(ctx);
|
|
assertAdmin(user);
|
|
const limit = clampInt(args.limit ?? 50, 1, MAX_USER_LIST_LIMIT);
|
|
const exactHandleUser = args.search
|
|
? await getUserByHandleOrPersonalPublisher(ctx, args.search)
|
|
: null;
|
|
const result = await queryUsersForAdminList(ctx, {
|
|
limit,
|
|
search: args.search,
|
|
exactUserId: exactHandleUser?._id,
|
|
});
|
|
const dedupedUsers = exactHandleUser
|
|
? [exactHandleUser, ...result.items.filter((entry) => entry._id !== exactHandleUser._id)]
|
|
: result.items;
|
|
const total = exactHandleUser
|
|
? result.total + (result.containsExactUser ? 0 : 1)
|
|
: result.total;
|
|
return {
|
|
items: dedupedUsers.slice(0, limit),
|
|
total,
|
|
};
|
|
},
|
|
});
|
|
|
|
export const listPublic = query({
|
|
args: { limit: v.optional(v.number()) },
|
|
handler: async (ctx, args) => {
|
|
const limit = clampInt(args.limit ?? 40, 1, 100);
|
|
const result = await queryUsersForPublicList(ctx, {
|
|
limit,
|
|
});
|
|
return {
|
|
items: result.items
|
|
.map((user) => toPublicUser(user))
|
|
.filter((user): user is NonNullable<ReturnType<typeof toPublicUser>> => Boolean(user)),
|
|
total: result.total,
|
|
};
|
|
},
|
|
});
|
|
|
|
function normalizeSearchQuery(search?: string) {
|
|
const trimmed = search?.trim().toLowerCase();
|
|
return trimmed ? trimmed : undefined;
|
|
}
|
|
|
|
function computeUserSearchScanLimit(limit: number) {
|
|
return clampInt(limit * 10, MIN_USER_SEARCH_SCAN, MAX_USER_SEARCH_SCAN);
|
|
}
|
|
|
|
async function queryUsersForAdminList(
|
|
ctx: Pick<QueryCtx, "db">,
|
|
args: { limit: number; search?: string; exactUserId?: Id<"users"> },
|
|
) {
|
|
const normalizedSearch = normalizeSearchQuery(args.search);
|
|
const orderedUsers = ctx.db.query("users").order("desc");
|
|
|
|
if (!normalizedSearch) {
|
|
const items = await orderedUsers.take(args.limit);
|
|
return { items, total: items.length, containsExactUser: false };
|
|
}
|
|
|
|
const scannedUsers = await orderedUsers.take(computeUserSearchScanLimit(args.limit));
|
|
const result = buildUserSearchResults(scannedUsers, normalizedSearch);
|
|
return {
|
|
items: result.items.slice(0, args.limit),
|
|
total: result.total,
|
|
containsExactUser: args.exactUserId
|
|
? result.items.some((user) => user._id === args.exactUserId)
|
|
: false,
|
|
};
|
|
}
|
|
|
|
async function queryUsersForPublicList(
|
|
ctx: Pick<QueryCtx, "db">,
|
|
args: { limit: number; search?: string },
|
|
) {
|
|
const normalizedSearch = normalizeSearchQuery(args.search);
|
|
const scanLimit = normalizedSearch
|
|
? computeUserSearchScanLimit(args.limit)
|
|
: clampInt(args.limit * 6, args.limit, MAX_USER_SEARCH_SCAN);
|
|
const scannedUsers = await ctx.db
|
|
.query("users")
|
|
.withIndex("by_active_handle", (q) =>
|
|
q.eq("deletedAt", undefined).eq("deactivatedAt", undefined),
|
|
)
|
|
.order("desc")
|
|
.take(scanLimit);
|
|
const activeUsers = scannedUsers.filter((user) => Boolean(user.handle));
|
|
const result = buildUserSearchResults(activeUsers, normalizedSearch);
|
|
return {
|
|
items: result.items.slice(0, args.limit),
|
|
total: result.total,
|
|
};
|
|
}
|
|
|
|
function clampInt(value: number, min: number, max: number) {
|
|
return Math.min(Math.max(Math.trunc(value), min), max);
|
|
}
|
|
|
|
export const getByHandle = query({
|
|
args: { handle: v.string() },
|
|
handler: async (ctx, args) => {
|
|
return toPublicUser(await getActiveUserByHandleOrPersonalPublisher(ctx, args.handle));
|
|
},
|
|
});
|
|
|
|
async function getPublisherInstallFallback(
|
|
ctx: Pick<QueryCtx, "db">,
|
|
publisherId: Id<"publishers">,
|
|
ownerUserId: Id<"users">,
|
|
) {
|
|
// Publisher aggregates are the normal path; keep legacy hover recovery bounded.
|
|
const [publisherSkills, publisherPackages, ownerSkills, ownerPackages] = await Promise.all([
|
|
ctx.db
|
|
.query("skills")
|
|
.withIndex("by_owner_publisher_active_updated", (q) =>
|
|
q.eq("ownerPublisherId", publisherId).eq("softDeletedAt", undefined),
|
|
)
|
|
.order("desc")
|
|
.take(HOVER_STATS_COMPATIBILITY_ROW_LIMIT),
|
|
ctx.db
|
|
.query("packages")
|
|
.withIndex("by_owner_publisher_active_updated", (q) =>
|
|
q.eq("ownerPublisherId", publisherId).eq("softDeletedAt", undefined),
|
|
)
|
|
.order("desc")
|
|
.take(HOVER_STATS_COMPATIBILITY_ROW_LIMIT),
|
|
ctx.db
|
|
.query("skills")
|
|
.withIndex("by_owner_active_updated", (q) =>
|
|
q.eq("ownerUserId", ownerUserId).eq("softDeletedAt", undefined),
|
|
)
|
|
.order("desc")
|
|
.take(HOVER_STATS_COMPATIBILITY_ROW_LIMIT),
|
|
ctx.db
|
|
.query("packages")
|
|
.withIndex("by_owner", (q) => q.eq("ownerUserId", ownerUserId))
|
|
.order("desc")
|
|
.take(HOVER_STATS_COMPATIBILITY_ROW_LIMIT),
|
|
]);
|
|
|
|
const legacyOwnerRowsForPublisher = <T extends { ownerPublisherId?: Id<"publishers"> }>(
|
|
rows: T[],
|
|
) => rows.filter((row) => !row.ownerPublisherId || row.ownerPublisherId === publisherId);
|
|
const skills = new Map(
|
|
[...publisherSkills, ...legacyOwnerRowsForPublisher(ownerSkills)].map((skill) => [
|
|
skill._id,
|
|
skill,
|
|
]),
|
|
);
|
|
const packages = new Map(
|
|
[...publisherPackages, ...legacyOwnerRowsForPublisher(ownerPackages)].map((pkg) => [
|
|
pkg._id,
|
|
pkg,
|
|
]),
|
|
);
|
|
|
|
return [
|
|
...Array.from(skills.values(), getSkillPublisherContribution),
|
|
...Array.from(packages.values(), getPackagePublisherContribution),
|
|
].reduce((total, contribution) => total + contribution.totalInstalls, 0);
|
|
}
|
|
|
|
/** Lightweight aggregate stats for user hover tooltips. */
|
|
export const getHoverStats = query({
|
|
args: { userId: v.id("users") },
|
|
handler: async (ctx, args) => {
|
|
const user = await ctx.db.get(args.userId);
|
|
const publisher = user ? await getPersonalPublisherForUserOrFallback(ctx, user) : null;
|
|
const totalInstalls =
|
|
user && publisher
|
|
? (publisher.totalInstalls ??
|
|
(await getPublisherInstallFallback(ctx, publisher._id, user._id)))
|
|
: 0;
|
|
const totalDownloads = publisher?.totalDownloads ?? user?.totalDownloads ?? totalInstalls;
|
|
|
|
return {
|
|
publishedSkills: publisher?.publishedSkills ?? user?.publishedSkills ?? 0,
|
|
totalStars: publisher?.totalStars ?? user?.totalStars ?? 0,
|
|
// Older cached frontend bundles still read this field during rollout.
|
|
totalDownloads,
|
|
totalInstalls,
|
|
};
|
|
},
|
|
});
|
|
|
|
export const getReservedHandleInternal = internalQuery({
|
|
args: { handle: v.string() },
|
|
handler: async (ctx, args) => {
|
|
return await getLatestActiveReservedHandle(ctx, args.handle);
|
|
},
|
|
});
|
|
|
|
export const setRole = mutation({
|
|
args: {
|
|
userId: v.id("users"),
|
|
role: v.union(v.literal("admin"), v.literal("moderator"), v.literal("user")),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const { user } = await requireUser(ctx);
|
|
return setRoleWithActor(ctx, user, args.userId, args.role);
|
|
},
|
|
});
|
|
|
|
export const reserveHandleInternal = internalMutation({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
handle: v.string(),
|
|
rightfulOwnerUserId: v.id("users"),
|
|
reason: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const actor = await ctx.db.get(args.actorUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new Error("User not found");
|
|
assertAdmin(actor);
|
|
|
|
const rightfulOwner = await ctx.db.get(args.rightfulOwnerUserId);
|
|
if (!rightfulOwner || rightfulOwner.deletedAt || rightfulOwner.deactivatedAt) {
|
|
throw new Error("Rightful owner not found");
|
|
}
|
|
|
|
const normalizedHandle = normalizeReservedHandle(args.handle);
|
|
if (!normalizedHandle) throw new Error("Handle required");
|
|
|
|
const existingUser = await ctx.db
|
|
.query("users")
|
|
.withIndex("handle", (q) => q.eq("handle", normalizedHandle))
|
|
.unique();
|
|
if (existingUser && existingUser._id !== args.rightfulOwnerUserId) {
|
|
throw new Error("Handle already claimed by another user");
|
|
}
|
|
|
|
const now = Date.now();
|
|
await upsertReservedHandleForRightfulOwner(ctx, {
|
|
handle: normalizedHandle,
|
|
rightfulOwnerUserId: args.rightfulOwnerUserId,
|
|
reason: args.reason,
|
|
now,
|
|
});
|
|
|
|
await ctx.db.insert("auditLogs", {
|
|
actorUserId: args.actorUserId,
|
|
action: "handle.reserve",
|
|
targetType: "handle",
|
|
targetId: normalizedHandle,
|
|
metadata: {
|
|
handle: normalizedHandle,
|
|
rightfulOwnerUserId: args.rightfulOwnerUserId,
|
|
reason: args.reason || undefined,
|
|
},
|
|
createdAt: now,
|
|
});
|
|
|
|
return {
|
|
ok: true as const,
|
|
handle: normalizedHandle,
|
|
rightfulOwnerUserId: args.rightfulOwnerUserId,
|
|
};
|
|
},
|
|
});
|
|
|
|
export const setRoleInternal = internalMutation({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
targetUserId: v.id("users"),
|
|
role: v.union(v.literal("admin"), v.literal("moderator"), v.literal("user")),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const actor = await ctx.db.get(args.actorUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new Error("User not found");
|
|
return setRoleWithActor(ctx, actor, args.targetUserId, args.role);
|
|
},
|
|
});
|
|
|
|
async function setRoleWithActor(
|
|
ctx: MutationCtx,
|
|
actor: Doc<"users">,
|
|
targetUserId: Id<"users">,
|
|
role: "admin" | "moderator" | "user",
|
|
) {
|
|
assertAdmin(actor);
|
|
const target = await ctx.db.get(targetUserId);
|
|
if (!target) throw new Error("User not found");
|
|
const now = Date.now();
|
|
await ctx.db.patch(targetUserId, { role, updatedAt: now });
|
|
await ctx.db.insert("auditLogs", {
|
|
actorUserId: actor._id,
|
|
action: "role.change",
|
|
targetType: "user",
|
|
targetId: targetUserId,
|
|
metadata: { role },
|
|
createdAt: now,
|
|
});
|
|
return { ok: true as const, role };
|
|
}
|
|
|
|
export const banUser = mutation({
|
|
args: { userId: v.id("users"), reason: v.optional(v.string()) },
|
|
handler: async (ctx, args) => {
|
|
const { user } = await requireUser(ctx);
|
|
return banUserWithActor(ctx, user, args.userId, args.reason);
|
|
},
|
|
});
|
|
|
|
export const banUserInternal = internalMutation({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
targetUserId: v.id("users"),
|
|
reason: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const actor = await ctx.db.get(args.actorUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new Error("User not found");
|
|
return banUserWithActor(ctx, actor, args.targetUserId, args.reason);
|
|
},
|
|
});
|
|
|
|
export const unbanUser = mutation({
|
|
args: { userId: v.id("users"), reason: v.optional(v.string()) },
|
|
handler: async (ctx, args) => {
|
|
const { user } = await requireUser(ctx);
|
|
return unbanUserWithActor(ctx, user, args.userId, args.reason);
|
|
},
|
|
});
|
|
|
|
export const unbanUserInternal = internalMutation({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
targetUserId: v.id("users"),
|
|
reason: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const actor = await ctx.db.get(args.actorUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new Error("User not found");
|
|
return unbanUserWithActor(ctx, actor, args.targetUserId, args.reason);
|
|
},
|
|
});
|
|
|
|
export const unbanUserForBanAppealServiceInternal = internalMutation({
|
|
args: {
|
|
targetUserId: v.id("users"),
|
|
reason: v.optional(v.string()),
|
|
reviewerDiscordId: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
return unbanUserForBanAppealService(ctx, args);
|
|
},
|
|
});
|
|
|
|
export const reclassifyBanInternal = internalMutation({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
targetUserId: v.id("users"),
|
|
reason: v.string(),
|
|
dryRun: v.optional(v.boolean()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const actor = await ctx.db.get(args.actorUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new Error("User not found");
|
|
assertAdmin(actor);
|
|
|
|
const target = await ctx.db.get(args.targetUserId);
|
|
if (!target) throw new Error("User not found");
|
|
if (target.deactivatedAt || target.purgedAt) {
|
|
throw new Error("Cannot reclassify a deactivated account");
|
|
}
|
|
if (!target.deletedAt) {
|
|
throw new Error("User is not currently banned");
|
|
}
|
|
|
|
const nextReason = args.reason.trim();
|
|
if (!nextReason) throw new Error("Reason required");
|
|
if (nextReason.length > 500) throw new Error("Reason too long (max 500 chars)");
|
|
|
|
const previousReason = target.banReason ?? null;
|
|
const changed = previousReason !== nextReason;
|
|
const dryRun = args.dryRun !== false;
|
|
|
|
if (!dryRun && changed) {
|
|
const now = Date.now();
|
|
await ctx.db.patch(args.targetUserId, {
|
|
banReason: nextReason,
|
|
updatedAt: now,
|
|
});
|
|
await ctx.db.insert("auditLogs", {
|
|
actorUserId: actor._id,
|
|
action: "user.ban.reclassify",
|
|
targetType: "user",
|
|
targetId: args.targetUserId,
|
|
metadata: {
|
|
previousReason,
|
|
nextReason,
|
|
},
|
|
createdAt: now,
|
|
});
|
|
}
|
|
|
|
return {
|
|
ok: true as const,
|
|
dryRun,
|
|
userId: args.targetUserId,
|
|
handle: target.handle ?? null,
|
|
previousReason,
|
|
nextReason,
|
|
changed,
|
|
};
|
|
},
|
|
});
|
|
|
|
async function banUserWithActor(
|
|
ctx: MutationCtx,
|
|
actor: Doc<"users">,
|
|
targetUserId: Id<"users">,
|
|
reasonRaw?: string,
|
|
) {
|
|
assertModerator(actor);
|
|
|
|
if (targetUserId === actor._id) throw new Error("Cannot ban yourself");
|
|
|
|
const target = await ctx.db.get(targetUserId);
|
|
if (!target) throw new Error("User not found");
|
|
if (target.role === "admin" && actor.role !== "admin") {
|
|
throw new Error("Forbidden");
|
|
}
|
|
|
|
return applyUserBan(ctx, {
|
|
target,
|
|
targetUserId,
|
|
actorUserId: actor._id,
|
|
deletedByRole: actor.role === "admin" ? "admin" : "moderator",
|
|
auditAction: "user.ban",
|
|
emailSource: "manual",
|
|
reasonRaw,
|
|
hiddenBy: actor._id,
|
|
});
|
|
}
|
|
|
|
async function applyUserBan(ctx: MutationCtx, args: ApplyUserBanArgs) {
|
|
const now = Date.now();
|
|
const reason = args.reasonRaw?.trim();
|
|
if (reason && reason.length > 500) {
|
|
throw new Error("Reason too long (max 500 chars)");
|
|
}
|
|
if (args.target.deactivatedAt) {
|
|
return {
|
|
ok: true as const,
|
|
alreadyBanned: true,
|
|
deletedSkills: 0,
|
|
deletedSkillComments: 0,
|
|
};
|
|
}
|
|
if (args.target.deletedAt) {
|
|
await ctx.runMutation(internal.packages.applyBanToOwnedPackagesBatchInternal, {
|
|
ownerUserId: args.targetUserId,
|
|
bannedAt: args.target.deletedAt,
|
|
deletedBy: args.actorUserId,
|
|
deletedByRole: args.deletedByRole,
|
|
cursor: undefined,
|
|
});
|
|
return {
|
|
ok: true as const,
|
|
alreadyBanned: true,
|
|
deletedSkills: 0,
|
|
deletedSkillComments: 0,
|
|
};
|
|
}
|
|
|
|
const banSkillsResult = (await ctx.runMutation(
|
|
internal.skills.applyBanToOwnedSkillsBatchInternal,
|
|
{
|
|
ownerUserId: args.targetUserId,
|
|
bannedAt: now,
|
|
...(args.hiddenBy ? { hiddenBy: args.hiddenBy } : {}),
|
|
cursor: undefined,
|
|
},
|
|
)) as { hiddenCount?: number; scheduled?: boolean };
|
|
const hiddenCount = banSkillsResult.hiddenCount ?? 0;
|
|
const scheduledSkills = banSkillsResult.scheduled ?? false;
|
|
|
|
const tokens = await ctx.db
|
|
.query("apiTokens")
|
|
.withIndex("by_user", (q) => q.eq("userId", args.targetUserId))
|
|
.collect();
|
|
for (const token of tokens) {
|
|
if (!token.revokedAt) {
|
|
await ctx.db.patch(token._id, { revokedAt: now });
|
|
}
|
|
}
|
|
|
|
await ctx.db.patch(args.targetUserId, {
|
|
deletedAt: now,
|
|
role: "user",
|
|
updatedAt: now,
|
|
banReason: reason || undefined,
|
|
});
|
|
|
|
const banPackagesResult = ((await ctx.runMutation(
|
|
internal.packages.applyBanToOwnedPackagesBatchInternal,
|
|
{
|
|
ownerUserId: args.targetUserId,
|
|
bannedAt: now,
|
|
deletedBy: args.actorUserId,
|
|
deletedByRole: args.deletedByRole,
|
|
cursor: undefined,
|
|
},
|
|
)) ?? {}) as { deletedCount?: number; revokedTokenCount?: number; scheduled?: boolean };
|
|
const deletedPackageCount = banPackagesResult.deletedCount ?? 0;
|
|
const revokedPackagePublishTokens = banPackagesResult.revokedTokenCount ?? 0;
|
|
const scheduledPackages = banPackagesResult.scheduled ?? false;
|
|
|
|
await ctx.runMutation(internal.telemetry.clearUserTelemetryInternal, {
|
|
userId: args.targetUserId,
|
|
});
|
|
|
|
await ctx.db.insert("auditLogs", {
|
|
actorUserId: args.actorUserId,
|
|
action: args.auditAction,
|
|
targetType: "user",
|
|
targetId: args.targetUserId,
|
|
metadata: {
|
|
...args.auditMetadata,
|
|
hiddenSkills: hiddenCount,
|
|
deletedPackages: deletedPackageCount,
|
|
revokedPackagePublishTokens,
|
|
scheduledPackages,
|
|
deletedSkillComments: 0,
|
|
reason: reason || undefined,
|
|
},
|
|
createdAt: now,
|
|
});
|
|
|
|
await scheduleBanNotificationEmail(ctx, {
|
|
target: args.target,
|
|
bannedAt: now,
|
|
source: args.emailSource,
|
|
reason,
|
|
trigger: args.emailTrigger,
|
|
hiddenArtifacts:
|
|
scheduledSkills || scheduledPackages ? undefined : hiddenCount + deletedPackageCount,
|
|
});
|
|
|
|
return {
|
|
ok: true as const,
|
|
alreadyBanned: false,
|
|
deletedSkills: hiddenCount,
|
|
deletedSkillComments: 0,
|
|
scheduledSkills,
|
|
};
|
|
}
|
|
|
|
async function unbanUserForBanAppealService(
|
|
ctx: MutationCtx,
|
|
args: { targetUserId: Id<"users">; reason?: string; reviewerDiscordId: string },
|
|
) {
|
|
const target = await ctx.db.get(args.targetUserId);
|
|
if (!target) throw new Error("User not found");
|
|
if (target.deactivatedAt) {
|
|
throw new Error("Cannot unban a permanently deleted account");
|
|
}
|
|
if (!target.deletedAt) {
|
|
return { ok: true as const, alreadyUnbanned: true };
|
|
}
|
|
|
|
const reason = args.reason?.trim();
|
|
if (reason && reason.length > 500) {
|
|
throw new Error("Reason too long (max 500 chars)");
|
|
}
|
|
|
|
const now = Date.now();
|
|
const bannedAt = target.deletedAt;
|
|
const banLog = await getCurrentBanAuditLog(ctx, args.targetUserId, bannedAt);
|
|
if (!banLog) {
|
|
throw new Error("Cannot unban account without a matching ban record");
|
|
}
|
|
|
|
await ctx.db.patch(args.targetUserId, {
|
|
deletedAt: undefined,
|
|
banReason: undefined,
|
|
role: "user",
|
|
updatedAt: now,
|
|
});
|
|
|
|
const restoreSkillsResult = (await ctx.runMutation(
|
|
internal.skills.restoreOwnedSkillsForUnbanBatchInternal,
|
|
{
|
|
ownerUserId: args.targetUserId,
|
|
bannedAt,
|
|
cursor: undefined,
|
|
},
|
|
)) as { restoredCount?: number; scheduled?: boolean };
|
|
const restoredSkillCount = restoreSkillsResult.restoredCount ?? 0;
|
|
const scheduledSkills = restoreSkillsResult.scheduled ?? false;
|
|
|
|
const restorePackagesResult = ((await ctx.runMutation(
|
|
internal.packages.restoreOwnedPackagesForUnbanBatchInternal,
|
|
{
|
|
ownerUserId: args.targetUserId,
|
|
bannedAt,
|
|
cursor: undefined,
|
|
},
|
|
)) ?? {}) as { restoredCount?: number; scheduled?: boolean };
|
|
const restoredPackageCount = restorePackagesResult.restoredCount ?? 0;
|
|
const scheduledPackages = restorePackagesResult.scheduled ?? false;
|
|
|
|
await ctx.db.insert("auditLogs", {
|
|
action: "user.unban",
|
|
targetType: "user",
|
|
targetId: args.targetUserId,
|
|
metadata: {
|
|
reason: reason || undefined,
|
|
restoredSkills: restoredSkillCount,
|
|
restoredPackages: restoredPackageCount,
|
|
scheduledPackages,
|
|
source: "ban_appeal.service",
|
|
reviewerDiscordId: args.reviewerDiscordId,
|
|
},
|
|
createdAt: now,
|
|
});
|
|
|
|
await scheduleRestoredAccountNotificationEmail(ctx, {
|
|
target,
|
|
restoredAt: now,
|
|
skillsRestored: scheduledSkills ? undefined : restoredSkillCount,
|
|
packagesRestored: scheduledPackages ? undefined : restoredPackageCount,
|
|
});
|
|
|
|
return {
|
|
ok: true as const,
|
|
alreadyUnbanned: false,
|
|
restoredSkills: restoredSkillCount,
|
|
scheduledSkills,
|
|
restoredPackages: restoredPackageCount,
|
|
scheduledPackages,
|
|
};
|
|
}
|
|
|
|
async function unbanUserWithActor(
|
|
ctx: MutationCtx,
|
|
actor: Doc<"users">,
|
|
targetUserId: Id<"users">,
|
|
reasonRaw?: string,
|
|
) {
|
|
assertAdmin(actor);
|
|
if (targetUserId === actor._id) throw new Error("Cannot unban yourself");
|
|
|
|
const target = await ctx.db.get(targetUserId);
|
|
if (!target) throw new Error("User not found");
|
|
if (target.deactivatedAt) {
|
|
throw new Error("Cannot unban a permanently deleted account");
|
|
}
|
|
if (!target.deletedAt) {
|
|
return { ok: true as const, alreadyUnbanned: true };
|
|
}
|
|
|
|
const reason = reasonRaw?.trim();
|
|
if (reason && reason.length > 500) {
|
|
throw new Error("Reason too long (max 500 chars)");
|
|
}
|
|
|
|
const now = Date.now();
|
|
const bannedAt = target.deletedAt;
|
|
await ctx.db.patch(targetUserId, {
|
|
deletedAt: undefined,
|
|
banReason: undefined,
|
|
role: "user",
|
|
updatedAt: now,
|
|
});
|
|
|
|
const restoreSkillsResult = (await ctx.runMutation(
|
|
internal.skills.restoreOwnedSkillsForUnbanBatchInternal,
|
|
{
|
|
ownerUserId: targetUserId,
|
|
bannedAt,
|
|
cursor: undefined,
|
|
},
|
|
)) as { restoredCount?: number; scheduled?: boolean };
|
|
const restoredCount = restoreSkillsResult.restoredCount ?? 0;
|
|
const scheduledSkills = restoreSkillsResult.scheduled ?? false;
|
|
|
|
const restorePackagesResult = ((await ctx.runMutation(
|
|
internal.packages.restoreOwnedPackagesForUnbanBatchInternal,
|
|
{
|
|
actorUserId: actor._id,
|
|
ownerUserId: targetUserId,
|
|
bannedAt,
|
|
cursor: undefined,
|
|
},
|
|
)) ?? {}) as { restoredCount?: number; scheduled?: boolean };
|
|
const restoredPackageCount = restorePackagesResult.restoredCount ?? 0;
|
|
const scheduledPackages = restorePackagesResult.scheduled ?? false;
|
|
|
|
await ctx.db.insert("auditLogs", {
|
|
actorUserId: actor._id,
|
|
action: "user.unban",
|
|
targetType: "user",
|
|
targetId: targetUserId,
|
|
metadata: {
|
|
reason: reason || undefined,
|
|
restoredSkills: restoredCount,
|
|
restoredPackages: restoredPackageCount,
|
|
scheduledPackages,
|
|
},
|
|
createdAt: now,
|
|
});
|
|
|
|
await scheduleRestoredAccountNotificationEmail(ctx, {
|
|
target,
|
|
restoredAt: now,
|
|
skillsRestored: scheduledSkills ? undefined : restoredCount,
|
|
packagesRestored: scheduledPackages ? undefined : restoredPackageCount,
|
|
});
|
|
|
|
return {
|
|
ok: true as const,
|
|
alreadyUnbanned: false,
|
|
restoredSkills: restoredCount,
|
|
scheduledSkills,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Moderation hold management
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Admin-only: lift the moderation hold placed on a user after a false-positive
|
|
* malicious upload detection.
|
|
*
|
|
* When the static scanner flags a skill as malicious, the publisher is placed
|
|
* under a moderation hold (`requiresModerationAt` set). This hides all their
|
|
* skills and causes all future publishes to start hidden. The hold has no
|
|
* self-service release path -- only an admin can lift it.
|
|
*
|
|
* This mutation:
|
|
* 1. Clears `requiresModerationAt` and `requiresModerationReason` on the user
|
|
* 2. Restores skills that were hidden due to the moderation hold
|
|
* 3. Creates an audit log entry
|
|
*/
|
|
export const liftModerationHold = mutation({
|
|
args: {
|
|
userId: v.id("users"),
|
|
reason: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const { user } = await requireUser(ctx);
|
|
return liftModerationHoldWithActor(ctx, user, args.userId, args.reason);
|
|
},
|
|
});
|
|
|
|
export const liftModerationHoldInternal = internalMutation({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
targetUserId: v.id("users"),
|
|
reason: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const actor = await ctx.db.get(args.actorUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new Error("User not found");
|
|
return liftModerationHoldWithActor(ctx, actor, args.targetUserId, args.reason);
|
|
},
|
|
});
|
|
|
|
async function liftModerationHoldWithActor(
|
|
ctx: MutationCtx,
|
|
actor: Doc<"users">,
|
|
targetUserId: Id<"users">,
|
|
reasonRaw?: string,
|
|
) {
|
|
assertAdmin(actor);
|
|
|
|
const target = await ctx.db.get(targetUserId);
|
|
if (!target) throw new Error("User not found");
|
|
if (target.deletedAt || target.deactivatedAt) {
|
|
throw new Error("Cannot lift hold on a deleted or deactivated account");
|
|
}
|
|
if (!target.requiresModerationAt) {
|
|
return { ok: true as const, alreadyCleared: true, restoredSkills: 0, scheduledSkills: false };
|
|
}
|
|
|
|
const reason = reasonRaw?.trim();
|
|
if (reason && reason.length > 500) {
|
|
throw new Error("Reason too long (max 500 chars)");
|
|
}
|
|
|
|
const holdPlacedAt = target.requiresModerationAt;
|
|
const now = Date.now();
|
|
|
|
// Clear the moderation hold on the user
|
|
await ctx.db.patch(targetUserId, {
|
|
requiresModerationAt: undefined,
|
|
requiresModerationReason: undefined,
|
|
updatedAt: now,
|
|
});
|
|
|
|
// Restore skills that were hidden due to the moderation hold.
|
|
// The batch handler checks if the user has been re-held between pages
|
|
// and aborts if so (race condition safety).
|
|
const restoreResult = (await ctx.runMutation(
|
|
internal.skills.restoreOwnedSkillsForModerationLiftBatchInternal,
|
|
{
|
|
ownerUserId: targetUserId,
|
|
holdPlacedAt,
|
|
cursor: undefined,
|
|
},
|
|
)) as { restoredCount?: number; scheduled?: boolean };
|
|
const restoredCount = restoreResult.restoredCount ?? 0;
|
|
const scheduledSkills = restoreResult.scheduled ?? false;
|
|
|
|
await ctx.db.insert("auditLogs", {
|
|
actorUserId: actor._id,
|
|
action: "user.moderation.lift",
|
|
targetType: "user",
|
|
targetId: targetUserId,
|
|
metadata: {
|
|
reason: reason || undefined,
|
|
holdPlacedAt,
|
|
restoredSkills: restoredCount,
|
|
},
|
|
createdAt: now,
|
|
});
|
|
|
|
return {
|
|
ok: true as const,
|
|
alreadyCleared: false,
|
|
restoredSkills: restoredCount,
|
|
scheduledSkills,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Admin-only: set or unset the trustedPublisher flag for a user.
|
|
* Trusted publishers bypass the pending.scan auto-hide for new skill publishes.
|
|
*/
|
|
export const setTrustedPublisher = mutation({
|
|
args: {
|
|
userId: v.id("users"),
|
|
trusted: v.boolean(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const { user } = await requireUser(ctx);
|
|
assertAdmin(user);
|
|
|
|
const target = await ctx.db.get(args.userId);
|
|
if (!target) throw new Error("User not found");
|
|
|
|
const now = Date.now();
|
|
await ctx.db.patch(args.userId, {
|
|
trustedPublisher: args.trusted || undefined,
|
|
updatedAt: now,
|
|
});
|
|
|
|
await ctx.db.insert("auditLogs", {
|
|
actorUserId: user._id,
|
|
action: args.trusted ? "user.trusted.set" : "user.trusted.unset",
|
|
targetType: "user",
|
|
targetId: args.userId,
|
|
metadata: { trusted: args.trusted },
|
|
createdAt: now,
|
|
});
|
|
|
|
return { ok: true as const, trusted: args.trusted };
|
|
},
|
|
});
|
|
|
|
export const setTrustedPublisherInternal = internalMutation({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
targetUserId: v.id("users"),
|
|
trusted: v.boolean(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const actor = await ctx.db.get(args.actorUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new Error("User not found");
|
|
assertAdmin(actor);
|
|
|
|
const target = await ctx.db.get(args.targetUserId);
|
|
if (!target) throw new Error("User not found");
|
|
|
|
const now = Date.now();
|
|
await ctx.db.patch(args.targetUserId, {
|
|
trustedPublisher: args.trusted || undefined,
|
|
updatedAt: now,
|
|
});
|
|
|
|
await ctx.db.insert("auditLogs", {
|
|
actorUserId: args.actorUserId,
|
|
action: args.trusted ? "user.trusted.set" : "user.trusted.unset",
|
|
targetType: "user",
|
|
targetId: args.targetUserId,
|
|
metadata: { trusted: args.trusted },
|
|
createdAt: now,
|
|
});
|
|
|
|
return { ok: true as const, trusted: args.trusted };
|
|
},
|
|
});
|
|
|
|
async function ensurePublisherHandleWithActor(
|
|
ctx: MutationCtx,
|
|
args: {
|
|
actorUserId: Id<"users">;
|
|
handle: string;
|
|
displayName?: string;
|
|
trusted?: boolean;
|
|
},
|
|
) {
|
|
const actor = await ctx.db.get(args.actorUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new Error("User not found");
|
|
assertAdmin(actor);
|
|
|
|
const normalizedHandle = normalizeReservedHandle(args.handle);
|
|
if (!normalizedHandle) throw new Error("Handle required");
|
|
if (isReservedPublicOwnerHandle(normalizedHandle)) {
|
|
throw new ConvexError(formatReservedPublicOwnerHandleMessage(normalizedHandle));
|
|
}
|
|
|
|
const existing = await ctx.db
|
|
.query("users")
|
|
.withIndex("handle", (q) => q.eq("handle", normalizedHandle))
|
|
.unique();
|
|
if (existing?.deletedAt || existing?.deactivatedAt) {
|
|
throw new Error("Handle belongs to a deleted or deactivated user");
|
|
}
|
|
if (!existing && isReservedOpenClawExtensionHandle(normalizedHandle)) {
|
|
throw new ConvexError(formatReservedPublicOwnerHandleMessage(normalizedHandle));
|
|
}
|
|
|
|
const now = Date.now();
|
|
const displayName = args.displayName?.trim() || normalizedHandle;
|
|
const trusted = args.trusted === false ? undefined : true;
|
|
const userId =
|
|
existing?._id ??
|
|
(await ctx.db.insert("users", {
|
|
handle: normalizedHandle,
|
|
displayName,
|
|
role: "user",
|
|
trustedPublisher: trusted,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}));
|
|
|
|
if (existing) {
|
|
const nextDisplayName =
|
|
args.displayName?.trim() &&
|
|
(!existing.displayName || existing.displayName === existing.handle)
|
|
? displayName
|
|
: existing.displayName;
|
|
await ctx.db.patch(existing._id, {
|
|
displayName: nextDisplayName,
|
|
trustedPublisher: trusted,
|
|
updatedAt: now,
|
|
});
|
|
}
|
|
|
|
await upsertReservedHandleForRightfulOwner(ctx, {
|
|
handle: normalizedHandle,
|
|
rightfulOwnerUserId: userId,
|
|
reason: "shared publisher",
|
|
now,
|
|
});
|
|
|
|
await ctx.db.insert("auditLogs", {
|
|
actorUserId: args.actorUserId,
|
|
action: "user.publisher.ensure",
|
|
targetType: "user",
|
|
targetId: userId,
|
|
metadata: {
|
|
handle: normalizedHandle,
|
|
trusted: trusted === true,
|
|
},
|
|
createdAt: now,
|
|
});
|
|
|
|
return {
|
|
ok: true as const,
|
|
userId,
|
|
handle: normalizedHandle,
|
|
created: !existing,
|
|
trusted: trusted === true,
|
|
};
|
|
}
|
|
|
|
export const ensurePublisherHandle = mutation({
|
|
args: {
|
|
handle: v.string(),
|
|
displayName: v.optional(v.string()),
|
|
trusted: v.optional(v.boolean()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const { user } = await requireUser(ctx);
|
|
return await ensurePublisherHandleWithActor(ctx, {
|
|
actorUserId: user._id,
|
|
handle: args.handle,
|
|
displayName: args.displayName,
|
|
trusted: args.trusted,
|
|
});
|
|
},
|
|
});
|
|
|
|
export const ensurePublisherHandleInternal = internalMutation({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
handle: v.string(),
|
|
displayName: v.optional(v.string()),
|
|
trusted: v.optional(v.boolean()),
|
|
},
|
|
handler: async (ctx, args) => await ensurePublisherHandleWithActor(ctx, args),
|
|
});
|
|
|
|
function normalizeMaliciousArtifactName(name: string) {
|
|
return name.trim().toLowerCase();
|
|
}
|
|
|
|
function readMaliciousArtifactFindingFromAudit(
|
|
log: Doc<"auditLogs">,
|
|
): MaliciousArtifactFinding | null {
|
|
if (log.action !== MALICIOUS_ARTIFACT_FINDING_ACTION) return null;
|
|
const metadata = log.metadata as
|
|
| {
|
|
artifactKind?: unknown;
|
|
artifactName?: unknown;
|
|
}
|
|
| undefined;
|
|
const artifactKind = metadata?.artifactKind;
|
|
const artifactName = typeof metadata?.artifactName === "string" ? metadata.artifactName : "";
|
|
if ((artifactKind !== "skill" && artifactKind !== "plugin") || !artifactName.trim()) {
|
|
return null;
|
|
}
|
|
return { artifactKind, artifactName };
|
|
}
|
|
|
|
function getMaliciousArtifactEscalationReason(findings: MaliciousArtifactFinding[]) {
|
|
const distinctArtifacts = new Set<string>();
|
|
const attemptsByArtifact = new Map<string, number>();
|
|
|
|
for (const finding of findings) {
|
|
const artifactKey = `${finding.artifactKind}:${normalizeMaliciousArtifactName(
|
|
finding.artifactName,
|
|
)}`;
|
|
distinctArtifacts.add(artifactKey);
|
|
attemptsByArtifact.set(artifactKey, (attemptsByArtifact.get(artifactKey) ?? 0) + 1);
|
|
}
|
|
|
|
if (distinctArtifacts.size >= MALICIOUS_ARTIFACT_DISTINCT_BAN_THRESHOLD) {
|
|
return "distinct_artifact_threshold" as const;
|
|
}
|
|
for (const attempts of attemptsByArtifact.values()) {
|
|
if (attempts >= MALICIOUS_ARTIFACT_ATTEMPT_BAN_THRESHOLD) {
|
|
return "attempt_threshold" as const;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export const recordMaliciousArtifactFindingInternal = internalMutation({
|
|
args: {
|
|
ownerUserId: v.id("users"),
|
|
artifactKind: v.union(v.literal("skill"), v.literal("plugin")),
|
|
artifactName: v.string(),
|
|
version: v.optional(v.string()),
|
|
trigger: v.optional(v.string()),
|
|
sha256hash: v.optional(v.string()),
|
|
findingSummary: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const target = await ctx.db.get(args.ownerUserId);
|
|
if (!target) return { ok: false as const, reason: "user_not_found" as const };
|
|
if (target.deletedAt || target.deactivatedAt) return { ok: true as const, alreadyBanned: true };
|
|
|
|
const artifactName = args.artifactName.trim();
|
|
if (!artifactName) {
|
|
return { ok: false as const, reason: "missing_artifact" as const };
|
|
}
|
|
const now = Date.now();
|
|
const trigger = args.trigger?.trim() || "scanner.malicious";
|
|
const version = args.version?.trim() || undefined;
|
|
const sha256hash = args.sha256hash?.trim() || undefined;
|
|
const findingSummary = args.findingSummary?.trim() || undefined;
|
|
await ctx.db.insert("auditLogs", {
|
|
actorUserId: args.ownerUserId,
|
|
action: MALICIOUS_ARTIFACT_FINDING_ACTION,
|
|
targetType: "user",
|
|
targetId: args.ownerUserId,
|
|
metadata: {
|
|
artifactKind: args.artifactKind,
|
|
artifactName,
|
|
version,
|
|
trigger,
|
|
sha256hash,
|
|
findingSummary,
|
|
},
|
|
createdAt: now,
|
|
});
|
|
|
|
if (target.role === "admin" || target.role === "moderator") {
|
|
await scheduleMaliciousArtifactNotificationEmail(ctx, {
|
|
target,
|
|
findingAt: now,
|
|
artifact: { kind: args.artifactKind, name: artifactName },
|
|
version,
|
|
trigger,
|
|
findingSummary,
|
|
});
|
|
return { ok: true as const, escalated: false as const, reason: "protected_role" as const };
|
|
}
|
|
|
|
const auditLogs = await ctx.db
|
|
.query("auditLogs")
|
|
.withIndex("by_target", (q) => q.eq("targetType", "user").eq("targetId", args.ownerUserId))
|
|
.order("desc")
|
|
.take(MALICIOUS_ARTIFACT_AUDIT_LOOKBACK);
|
|
const priorFindings = auditLogs
|
|
.map(readMaliciousArtifactFindingFromAudit)
|
|
.filter((finding): finding is MaliciousArtifactFinding => Boolean(finding));
|
|
const escalationReason = getMaliciousArtifactEscalationReason(priorFindings);
|
|
if (!escalationReason) {
|
|
await scheduleMaliciousArtifactNotificationEmail(ctx, {
|
|
target,
|
|
findingAt: now,
|
|
artifact: { kind: args.artifactKind, name: artifactName },
|
|
version,
|
|
trigger,
|
|
findingSummary,
|
|
});
|
|
return { ok: true as const, escalated: false as const };
|
|
}
|
|
|
|
await ctx.runMutation(internal.users.autobanMalwareAuthorInternal, {
|
|
ownerUserId: args.ownerUserId,
|
|
slug: artifactName,
|
|
trigger,
|
|
...(sha256hash ? { sha256hash } : {}),
|
|
artifactKind: args.artifactKind,
|
|
artifactName,
|
|
});
|
|
|
|
return { ok: true as const, escalated: true as const, reason: escalationReason };
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Auto-ban a user whose skill was flagged malicious by a scanner.
|
|
* Skips moderators/admins. No actor required — this is a system-level action.
|
|
*/
|
|
export const autobanMalwareAuthorInternal = internalMutation({
|
|
args: {
|
|
ownerUserId: v.id("users"),
|
|
sha256hash: v.optional(v.string()),
|
|
slug: v.string(),
|
|
trigger: v.optional(v.string()),
|
|
artifactKind: v.optional(v.union(v.literal("skill"), v.literal("plugin"))),
|
|
artifactName: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const target = await ctx.db.get(args.ownerUserId);
|
|
if (!target) return { ok: false, reason: "user_not_found" };
|
|
if (target.deletedAt || target.deactivatedAt) return { ok: true, alreadyBanned: true };
|
|
|
|
// Never auto-ban moderators or admins
|
|
if (target.role === "admin" || target.role === "moderator") {
|
|
console.log(`[autoban] Skipping ${target.handle ?? args.ownerUserId}: role=${target.role}`);
|
|
return { ok: false, reason: "protected_role" };
|
|
}
|
|
|
|
const now = Date.now();
|
|
|
|
const banSkillsResult = (await ctx.runMutation(
|
|
internal.skills.applyBanToOwnedSkillsBatchInternal,
|
|
{
|
|
ownerUserId: args.ownerUserId,
|
|
bannedAt: now,
|
|
cursor: undefined,
|
|
},
|
|
)) as { hiddenCount?: number; scheduled?: boolean };
|
|
const hiddenCount = banSkillsResult.hiddenCount ?? 0;
|
|
const scheduledSkills = banSkillsResult.scheduled ?? false;
|
|
|
|
// Revoke all API tokens
|
|
const tokens = await ctx.db
|
|
.query("apiTokens")
|
|
.withIndex("by_user", (q) => q.eq("userId", args.ownerUserId))
|
|
.collect();
|
|
for (const token of tokens) {
|
|
if (!token.revokedAt) {
|
|
await ctx.db.patch(token._id, { revokedAt: now });
|
|
}
|
|
}
|
|
|
|
// Ban the user
|
|
await ctx.db.patch(args.ownerUserId, {
|
|
deletedAt: now,
|
|
role: "user",
|
|
updatedAt: now,
|
|
banReason: "malware auto-ban",
|
|
});
|
|
|
|
const banPackagesResult = ((await ctx.runMutation(
|
|
internal.packages.applyBanToOwnedPackagesBatchInternal,
|
|
{
|
|
ownerUserId: args.ownerUserId,
|
|
bannedAt: now,
|
|
deletedBy: args.ownerUserId,
|
|
deletedByRole: "user",
|
|
cursor: undefined,
|
|
},
|
|
)) ?? {}) as { deletedCount?: number; revokedTokenCount?: number; scheduled?: boolean };
|
|
const deletedPackageCount = banPackagesResult.deletedCount ?? 0;
|
|
const revokedPackagePublishTokens = banPackagesResult.revokedTokenCount ?? 0;
|
|
const scheduledPackages = banPackagesResult.scheduled ?? false;
|
|
|
|
await ctx.runMutation(internal.telemetry.clearUserTelemetryInternal, {
|
|
userId: args.ownerUserId,
|
|
});
|
|
|
|
const metadata: Record<string, unknown> = {
|
|
trigger: args.trigger?.trim() || "scanner.malicious",
|
|
slug: args.slug,
|
|
hiddenSkills: hiddenCount,
|
|
deletedPackages: deletedPackageCount,
|
|
revokedPackagePublishTokens,
|
|
scheduledPackages,
|
|
deletedSkillComments: 0,
|
|
};
|
|
if (args.sha256hash?.trim()) {
|
|
metadata.sha256hash = args.sha256hash.trim();
|
|
}
|
|
|
|
// Audit log -- use the target as actor since there's no human actor
|
|
await ctx.db.insert("auditLogs", {
|
|
actorUserId: args.ownerUserId,
|
|
action: "user.autoban.malware",
|
|
targetType: "user",
|
|
targetId: args.ownerUserId,
|
|
metadata,
|
|
createdAt: now,
|
|
});
|
|
|
|
const trigger = args.trigger?.trim() || "scanner.malicious";
|
|
const artifactKind = args.artifactKind ?? "skill";
|
|
const artifactName = args.artifactName?.trim() || args.slug;
|
|
await scheduleBanNotificationEmail(ctx, {
|
|
target,
|
|
bannedAt: now,
|
|
source: "autoban",
|
|
reason: trigger,
|
|
trigger,
|
|
artifact: { kind: artifactKind, name: artifactName },
|
|
hiddenArtifacts:
|
|
scheduledSkills || scheduledPackages ? undefined : hiddenCount + deletedPackageCount,
|
|
});
|
|
|
|
console.warn(
|
|
`[autoban] Banned ${target.handle ?? args.ownerUserId} — malicious skill: ${args.slug}`,
|
|
);
|
|
|
|
return {
|
|
ok: true,
|
|
alreadyBanned: false,
|
|
deletedSkills: hiddenCount,
|
|
deletedSkillComments: 0,
|
|
scheduledSkills,
|
|
};
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Auto-ban a user whose publisher abuse nomination reached the potential-ban bucket.
|
|
* Skips moderators/admins. No actor required — this is a system-level action.
|
|
*/
|
|
export const autobanPublisherAbuseOwnerInternal = internalMutation({
|
|
args: {
|
|
ownerUserId: v.id("users"),
|
|
nominationId: v.id("publisherAbuseReviewNominations"),
|
|
scoreId: v.id("publisherAbuseScores"),
|
|
reason: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const target = await ctx.db.get(args.ownerUserId);
|
|
if (!target) return { ok: false as const, reason: "user_not_found" as const };
|
|
const reason = args.reason.trim();
|
|
if (!reason) return { ok: false as const, reason: "missing_reason" as const };
|
|
if (reason.length > 500) return { ok: false as const, reason: "reason_too_long" as const };
|
|
|
|
const nominationCheck = await getActionablePublisherAbuseAutobanNomination(ctx, args);
|
|
if (!nominationCheck.ok) {
|
|
return { ok: false as const, reason: nominationCheck.reason };
|
|
}
|
|
const nomination = nominationCheck.nomination;
|
|
|
|
const now = Date.now();
|
|
if (target.deactivatedAt) {
|
|
await markPublisherAbuseAutobanNominationBanned(ctx, { nomination, reason, now });
|
|
return { ok: true as const, alreadyBanned: true };
|
|
}
|
|
if (target.deletedAt) {
|
|
await ctx.runMutation(internal.packages.applyBanToOwnedPackagesBatchInternal, {
|
|
ownerUserId: args.ownerUserId,
|
|
bannedAt: target.deletedAt,
|
|
deletedBy: args.ownerUserId,
|
|
deletedByRole: "user",
|
|
cursor: undefined,
|
|
});
|
|
await markPublisherAbuseAutobanNominationBanned(ctx, { nomination, reason, now });
|
|
return { ok: true as const, alreadyBanned: true };
|
|
}
|
|
|
|
if (target.role === "admin" || target.role === "moderator") {
|
|
console.log(
|
|
`[autoban] Skipping publisher abuse candidate ${target.handle ?? args.ownerUserId}: role=${target.role}`,
|
|
);
|
|
return { ok: false as const, reason: "protected_role" as const };
|
|
}
|
|
|
|
const result = await applyUserBan(ctx, {
|
|
target,
|
|
targetUserId: args.ownerUserId,
|
|
actorUserId: args.ownerUserId,
|
|
deletedByRole: "user",
|
|
auditAction: "user.autoban.publisher_abuse",
|
|
emailSource: "autoban",
|
|
reasonRaw: reason,
|
|
emailTrigger: "publisher_abuse",
|
|
auditMetadata: {
|
|
nominationId: args.nominationId,
|
|
scoreId: args.scoreId,
|
|
},
|
|
});
|
|
|
|
console.warn(
|
|
`[autoban] Banned ${target.handle ?? args.ownerUserId} — publisher abuse nomination ${args.nominationId}`,
|
|
);
|
|
|
|
await markPublisherAbuseAutobanNominationBanned(ctx, { nomination, reason, now });
|
|
|
|
return result;
|
|
},
|
|
});
|
|
|
|
export const placeUserUnderModerationInternal = internalMutation({
|
|
args: {
|
|
ownerUserId: v.id("users"),
|
|
slug: v.string(),
|
|
reason: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const target = await ctx.db.get(args.ownerUserId);
|
|
if (!target) return { ok: false, reason: "user_not_found" as const };
|
|
if (target.deletedAt || target.deactivatedAt) {
|
|
return { ok: true, alreadyModerated: true as const, hiddenSkills: 0 };
|
|
}
|
|
if (target.role === "admin" || target.role === "moderator") {
|
|
console.log(
|
|
`[moderation] Skipping ${target.handle ?? args.ownerUserId}: role=${target.role}`,
|
|
);
|
|
return { ok: false, reason: "protected_role" as const };
|
|
}
|
|
|
|
const now = Date.now();
|
|
const alreadyModerated = Boolean(target.requiresModerationAt);
|
|
const moderationReason = `Auto-held for moderation after malicious upload (${args.reason})`;
|
|
|
|
if (!alreadyModerated) {
|
|
await ctx.db.patch(args.ownerUserId, {
|
|
requiresModerationAt: now,
|
|
requiresModerationReason: moderationReason,
|
|
updatedAt: now,
|
|
});
|
|
}
|
|
|
|
const hideSkillsResult = (await ctx.runMutation(
|
|
internal.skills.applyUserModerationToOwnedSkillsBatchInternal,
|
|
{
|
|
ownerUserId: args.ownerUserId,
|
|
hiddenAt: now,
|
|
cursor: undefined,
|
|
},
|
|
)) as { hiddenCount?: number; scheduled?: boolean };
|
|
|
|
await ctx.db.insert("auditLogs", {
|
|
actorUserId: args.ownerUserId,
|
|
action: "user.moderation.auto",
|
|
targetType: "user",
|
|
targetId: args.ownerUserId,
|
|
metadata: {
|
|
trigger: "moderation.hold",
|
|
slug: args.slug,
|
|
reason: args.reason,
|
|
hiddenSkills: hideSkillsResult.hiddenCount ?? 0,
|
|
},
|
|
createdAt: now,
|
|
});
|
|
|
|
return {
|
|
ok: true as const,
|
|
alreadyModerated,
|
|
hiddenSkills: hideSkillsResult.hiddenCount ?? 0,
|
|
scheduledSkills: hideSkillsResult.scheduled ?? false,
|
|
};
|
|
},
|
|
});
|
|
|
|
export const recordStaffEmailAttemptAuditInternal = internalMutation({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
toEmail: v.string(),
|
|
recipientUserId: v.optional(v.id("users")),
|
|
recipientHandle: v.optional(v.string()),
|
|
subject: v.string(),
|
|
template: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const actor = await ctx.db.get(args.actorUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) {
|
|
throw new Error("Unauthorized");
|
|
}
|
|
assertAdmin(actor);
|
|
const auditLogId = await ctx.db.insert("auditLogs", {
|
|
actorUserId: args.actorUserId,
|
|
action: "staff.email.send",
|
|
targetType: args.recipientUserId ? "user" : "email",
|
|
targetId: args.recipientUserId ?? args.toEmail,
|
|
metadata: {
|
|
toEmail: args.toEmail,
|
|
recipientHandle: args.recipientHandle ?? null,
|
|
subject: args.subject,
|
|
template: args.template ?? "raw",
|
|
providerId: null,
|
|
status: "attempted",
|
|
source: "clawhub-admin.email",
|
|
},
|
|
createdAt: Date.now(),
|
|
});
|
|
return { ok: true as const, auditLogId };
|
|
},
|
|
});
|
|
|
|
export const recordStaffEmailSentAuditInternal = internalMutation({
|
|
args: {
|
|
actorUserId: v.id("users"),
|
|
auditLogId: v.id("auditLogs"),
|
|
providerId: v.optional(v.union(v.string(), v.null())),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const actor = await ctx.db.get(args.actorUserId);
|
|
if (!actor || actor.deletedAt || actor.deactivatedAt) {
|
|
throw new Error("Unauthorized");
|
|
}
|
|
assertAdmin(actor);
|
|
const auditLog = await ctx.db.get(args.auditLogId);
|
|
if (!auditLog || auditLog.action !== "staff.email.send") {
|
|
throw new Error("Staff email audit log not found");
|
|
}
|
|
const metadata =
|
|
auditLog.metadata &&
|
|
typeof auditLog.metadata === "object" &&
|
|
!Array.isArray(auditLog.metadata)
|
|
? auditLog.metadata
|
|
: {};
|
|
await ctx.db.patch(args.auditLogId, {
|
|
metadata: {
|
|
...metadata,
|
|
providerId: args.providerId ?? null,
|
|
status: "sent",
|
|
},
|
|
});
|
|
return { ok: true as const };
|
|
},
|
|
});
|