From e23a42a27919b1ee02828aab1e1f6e5838b19c72 Mon Sep 17 00:00:00 2001 From: vyctorbrzezowski Date: Fri, 15 May 2026 22:54:49 -0300 Subject: [PATCH 01/17] fix: hide package resources when owners are banned --- CHANGELOG.md | 1 + convex/packages.public.test.ts | 362 +++++++++++++++++++++++++++++++++ convex/packages.ts | 244 +++++++++++++++++++++- convex/schema.ts | 1 + convex/users.test.ts | 10 +- convex/users.ts | 72 ++++++- specs/security-moderation.md | 8 + 7 files changed, 693 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3ef4568ed..92f2d1527d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixes - API: fix `GET /api/v1/skills` pagination so `cursor` advances to the next page instead of repeating the first page for supported non-trending sorts (#2275) (thanks @vyctorbrzezowski, @enerj). +- Security/API: hide owned package/plugin catalog entries, revoke package publish tokens, and restore only matching ban-hidden packages on user unban (thanks @vyctorbrzezowski). ## 0.17.0 - 2026-05-19 diff --git a/convex/packages.public.test.ts b/convex/packages.public.test.ts index b699edd51d..44fe403cdc 100644 --- a/convex/packages.public.test.ts +++ b/convex/packages.public.test.ts @@ -29,6 +29,9 @@ import { listPageForViewerInternal, listVersions, updateReleaseStaticScanInternal, + applyAccountDeletionToOwnedPackagesBatchInternal, + applyBanToOwnedPackagesBatchInternal, + restoreOwnedPackagesForUnbanBatchInternal, softDeletePackageInternal, restorePackageInternal, transferPackageOwnerForUserInternal, @@ -525,6 +528,30 @@ const softDeletePackageInternalHandler = ( } > )._handler; +const applyBanToOwnedPackagesBatchInternalHandler = ( + applyBanToOwnedPackagesBatchInternal as unknown as WrappedHandler< + { + ownerUserId: string; + bannedAt: number; + deletedBy: string; + deletedByRole: "admin" | "moderator" | "user"; + cursor?: string; + }, + { deletedCount: number; revokedTokenCount: number; scheduled: boolean } + > +)._handler; +const restoreOwnedPackagesForUnbanBatchInternalHandler = ( + restoreOwnedPackagesForUnbanBatchInternal as unknown as WrappedHandler< + { ownerUserId: string; bannedAt: number; cursor?: string }, + { restoredCount: number; scheduled: boolean; stale?: true } + > +)._handler; +const applyAccountDeletionToOwnedPackagesBatchInternalHandler = ( + applyAccountDeletionToOwnedPackagesBatchInternal as unknown as WrappedHandler< + { ownerUserId: string; deletedAt: number; cursor?: string }, + { deletedCount: number; revokedTokenCount: number; scheduled: boolean } + > +)._handler; const restorePackageInternalHandler = ( restorePackageInternal as unknown as WrappedHandler< { userId: string; name: string }, @@ -6802,6 +6829,321 @@ function makeSoftDeleteCtx(options?: { }; } +function makeOwnedPackageBatchCtx(options?: { + pkg?: Record; + owner?: Record | null; + packageTokens?: Array>; + releases?: Array>; + publishers?: Record | null>; + isDone?: boolean; + continueCursor?: string; +}) { + const pkg = options?.pkg ?? makePackageDoc({ ownerUserId: "users:owner" }); + const releases = options?.releases ?? [ + makeReleaseDoc({ _id: "packageReleases:demo-1", packageId: pkg._id }), + ]; + const packageTokens = options?.packageTokens ?? [ + { + _id: "packagePublishTokens:demo", + packageId: pkg._id, + version: "1.0.1", + revokedAt: undefined, + }, + ]; + const patch = vi.fn(); + const insert = vi.fn().mockResolvedValue("auditLogs:1"); + const runAfter = vi.fn(); + + return { + patch, + insert, + runAfter, + ctx: { + scheduler: { runAfter }, + db: { + get: vi.fn(async (id: string) => { + if (id === "users:owner") { + return options?.owner === undefined + ? { _id: "users:owner", deletedAt: undefined, deactivatedAt: undefined } + : options.owner; + } + if (options?.publishers && id in options.publishers) { + return options.publishers[id]; + } + return null; + }), + query: vi.fn((table: string) => { + if (table === "packages") { + return { + withIndex: vi.fn(() => ({ + order: vi.fn(() => ({ + paginate: vi.fn().mockResolvedValue({ + page: [pkg], + isDone: options?.isDone ?? true, + continueCursor: options?.continueCursor ?? "", + }), + })), + })), + }; + } + if (table === "packagePublishTokens") { + return { + withIndex: vi.fn(() => ({ + collect: vi.fn().mockResolvedValue(packageTokens), + })), + }; + } + if (table === "packageReleases") { + return { + withIndex: vi.fn(() => ({ + collect: vi.fn().mockResolvedValue(releases), + })), + }; + } + if (table === "packageSearchDigest") { + return { + withIndex: vi.fn(() => ({ + unique: vi + .fn() + .mockResolvedValue({ _id: "packageSearchDigest:demo", packageId: pkg._id }), + })), + }; + } + if (table === "packageCapabilitySearchDigest") { + return { + withIndex: vi.fn(() => ({ + collect: vi.fn().mockResolvedValue([]), + })), + }; + } + if (table === "packagePluginCategorySearchDigest") { + return { + withIndex: vi.fn(() => ({ + collect: vi.fn().mockResolvedValue([]), + })), + }; + } + throw new Error(`Unexpected table: ${table}`); + }), + insert, + patch, + replace: vi.fn(), + delete: vi.fn(), + normalizeId: vi.fn(), + }, + }, + }; +} + +describe("owned package sanction batches", () => { + it("soft-deletes owned packages with a ban reason and revokes package publish tokens", async () => { + const { ctx, patch } = makeOwnedPackageBatchCtx(); + + const result = await applyBanToOwnedPackagesBatchInternalHandler(ctx as never, { + ownerUserId: "users:owner", + bannedAt: 1_000, + deletedBy: "users:moderator", + deletedByRole: "moderator", + }); + + expect(result).toMatchObject({ deletedCount: 1, revokedTokenCount: 1, scheduled: false }); + expect(patch).toHaveBeenCalledWith( + "packages:demo", + expect.objectContaining({ + softDeletedAt: 1_000, + softDeletedReason: "user.banned", + softDeletedBy: "users:moderator", + softDeletedByRole: "moderator", + }), + ); + expect(patch).toHaveBeenCalledWith("packagePublishTokens:demo", { revokedAt: 1_000 }); + }); + + it("stops stale package ban pages when the owner has already been unbanned", async () => { + const { ctx, patch } = makeOwnedPackageBatchCtx(); + + const result = await applyBanToOwnedPackagesBatchInternalHandler(ctx as never, { + ownerUserId: "users:owner", + bannedAt: 1_000, + deletedBy: "users:moderator", + deletedByRole: "moderator", + cursor: "next-page", + }); + + expect(result).toMatchObject({ + stale: true, + deletedCount: 0, + revokedTokenCount: 0, + scheduled: false, + }); + expect(patch).not.toHaveBeenCalledWith("packages:demo", expect.anything()); + expect(patch).not.toHaveBeenCalledWith("packagePublishTokens:demo", expect.anything()); + }); + + it("retimestamps earlier ban-hidden packages during a later ban", async () => { + const { ctx, patch } = makeOwnedPackageBatchCtx({ + owner: { _id: "users:owner", deletedAt: 2_000, deactivatedAt: undefined }, + pkg: makePackageDoc({ + softDeletedAt: 1_000, + softDeletedReason: "user.banned", + softDeletedBy: "users:first-moderator", + softDeletedByRole: "moderator", + }), + }); + + const result = await applyBanToOwnedPackagesBatchInternalHandler(ctx as never, { + ownerUserId: "users:owner", + bannedAt: 2_000, + deletedBy: "users:second-moderator", + deletedByRole: "admin", + }); + + expect(result).toMatchObject({ deletedCount: 0, revokedTokenCount: 1, scheduled: false }); + expect(patch).toHaveBeenCalledWith( + "packages:demo", + expect.objectContaining({ + softDeletedAt: 2_000, + softDeletedBy: "users:second-moderator", + softDeletedByRole: "admin", + updatedAt: 2_000, + }), + ); + expect(patch).toHaveBeenCalledWith("packagePublishTokens:demo", { revokedAt: 2_000 }); + }); + + it("does not hide org-owned packages when banning a member in the legacy owner field", async () => { + const { ctx, patch } = makeOwnedPackageBatchCtx({ + owner: { + _id: "users:owner", + deletedAt: 1_000, + deactivatedAt: undefined, + personalPublisherId: "publishers:personal", + }, + pkg: makePackageDoc({ + ownerUserId: "users:owner", + ownerPublisherId: "publishers:org", + }), + publishers: { + "publishers:org": { _id: "publishers:org", kind: "org" }, + }, + }); + + const result = await applyBanToOwnedPackagesBatchInternalHandler(ctx as never, { + ownerUserId: "users:owner", + bannedAt: 1_000, + deletedBy: "users:moderator", + deletedByRole: "moderator", + }); + + expect(result).toMatchObject({ deletedCount: 0, revokedTokenCount: 0, scheduled: false }); + expect(patch).not.toHaveBeenCalledWith("packages:demo", expect.anything()); + expect(patch).not.toHaveBeenCalledWith("packagePublishTokens:demo", expect.anything()); + }); + + it("restores only packages that were hidden by the matching ban batch", async () => { + const { ctx, patch } = makeOwnedPackageBatchCtx({ + pkg: makePackageDoc({ + softDeletedAt: 1_000, + softDeletedReason: "user.banned", + softDeletedByRole: "moderator", + }), + releases: [ + makeReleaseDoc({ + _id: "packageReleases:demo-1", + softDeletedAt: 1_000, + distTags: ["latest"], + version: "1.0.0", + changelog: "", + compatibility: null, + capabilities: null, + verification: null, + }), + ], + }); + + const result = await restoreOwnedPackagesForUnbanBatchInternalHandler(ctx as never, { + ownerUserId: "users:owner", + bannedAt: 1_000, + }); + + expect(result).toMatchObject({ restoredCount: 1, scheduled: false }); + expect(patch).toHaveBeenCalledWith( + "packages:demo", + expect.objectContaining({ + softDeletedAt: undefined, + softDeletedReason: undefined, + softDeletedBy: undefined, + softDeletedByRole: undefined, + }), + ); + }); + + it("stops stale package restore batches when the owner is banned again", async () => { + const { ctx, patch } = makeOwnedPackageBatchCtx({ + owner: { _id: "users:owner", deletedAt: 2_000, deactivatedAt: undefined }, + pkg: makePackageDoc({ + softDeletedAt: 1_000, + softDeletedReason: "user.banned", + }), + }); + + const result = await restoreOwnedPackagesForUnbanBatchInternalHandler(ctx as never, { + ownerUserId: "users:owner", + bannedAt: 1_000, + }); + + expect(result).toMatchObject({ stale: true, restoredCount: 0, scheduled: false }); + expect(patch).not.toHaveBeenCalledWith("packages:demo", expect.anything()); + }); + + it("marks account-deleted packages separately from ban-restorable packages", async () => { + const { ctx, patch } = makeOwnedPackageBatchCtx(); + + const result = await applyAccountDeletionToOwnedPackagesBatchInternalHandler(ctx as never, { + ownerUserId: "users:owner", + deletedAt: 3_000, + }); + + expect(result).toMatchObject({ deletedCount: 1, revokedTokenCount: 1, scheduled: false }); + expect(patch).toHaveBeenCalledWith( + "packages:demo", + expect.objectContaining({ + softDeletedAt: 3_000, + softDeletedReason: "user.deactivated", + softDeletedBy: "users:owner", + softDeletedByRole: "user", + }), + ); + }); + + it("does not delete org-owned packages when deleting a member account", async () => { + const { ctx, patch } = makeOwnedPackageBatchCtx({ + owner: { + _id: "users:owner", + deletedAt: undefined, + deactivatedAt: 3_000, + personalPublisherId: "publishers:personal", + }, + pkg: makePackageDoc({ + ownerUserId: "users:owner", + ownerPublisherId: "publishers:org", + }), + publishers: { + "publishers:org": { _id: "publishers:org", kind: "org" }, + }, + }); + + const result = await applyAccountDeletionToOwnedPackagesBatchInternalHandler(ctx as never, { + ownerUserId: "users:owner", + deletedAt: 3_000, + }); + + expect(result).toMatchObject({ deletedCount: 0, revokedTokenCount: 0, scheduled: false }); + expect(patch).not.toHaveBeenCalledWith("packages:demo", expect.anything()); + expect(patch).not.toHaveBeenCalledWith("packagePublishTokens:demo", expect.anything()); + }); +}); + describe("softDeletePackageInternal", () => { it("soft-deletes a package owned by a personal publisher and writes ownerHandle to the search digest", async () => { const { ctx, patch, insert } = makeSoftDeleteCtx(); @@ -6944,4 +7286,24 @@ describe("restorePackageInternal", () => { expect(result).toMatchObject({ ok: true, alreadyRestored: true, releaseCount: 0 }); expect(patch).not.toHaveBeenCalledWith("packages:demo", expect.anything()); }); + + it("does not let package owners directly restore packages hidden by a user ban", async () => { + const { ctx, patch } = makeSoftDeleteCtx({ + pkg: makePackageDoc({ + ownerUserId: "users:owner", + ownerPublisherId: "publishers:owner-personal", + softDeletedAt: 1_000, + softDeletedReason: "user.banned", + softDeletedByRole: "moderator", + }), + }); + + await expect( + restorePackageInternalHandler(ctx as never, { + userId: "users:owner", + name: "demo-plugin", + }), + ).rejects.toThrow("Forbidden"); + expect(patch).not.toHaveBeenCalledWith("packages:demo", expect.anything()); + }); }); diff --git a/convex/packages.ts b/convex/packages.ts index 359a931086..f4e64d0ee3 100644 --- a/convex/packages.ts +++ b/convex/packages.ts @@ -255,6 +255,8 @@ const packageAutobanRemediationInternalRefs = internal as unknown as { }; }; type DbReaderCtx = Pick; +const BAN_USER_PACKAGES_BATCH_SIZE = 25; +type PackageSoftDeletedReason = "user.banned" | "user.deactivated"; type PackagePublishActor = | { kind: "user"; @@ -2635,6 +2637,8 @@ async function softDeletePackageDoc( params: { actorUserId: Id<"users">; actorRole?: Doc<"users">["role"]; + deletedAt?: number; + reason?: PackageSoftDeletedReason; source: "cli" | "dashboard"; }, ) { @@ -2654,7 +2658,7 @@ async function softDeletePackageDoc( }; } - const now = Date.now(); + const now = params.deletedAt ?? Date.now(); const releases = await ctx.db .query("packageReleases") .withIndex("by_package", (q) => q.eq("packageId", pkg._id)) @@ -2670,6 +2674,7 @@ async function softDeletePackageDoc( const packagePatch: Partial> = { softDeletedAt: now, + softDeletedReason: params.reason, softDeletedBy: params.actorUserId, softDeletedByRole: params.actorRole ?? "user", updatedAt: now, @@ -2696,6 +2701,7 @@ async function softDeletePackageDoc( ownerUserId: pkg.ownerUserId, ownerPublisherId: pkg.ownerPublisherId, actorRole: params.actorRole ?? "user", + softDeletedReason: params.reason ?? null, releaseCount, releaseIds: deletedReleaseIds, source: params.source, @@ -2771,6 +2777,7 @@ async function restorePackageDoc( params: { actorUserId: Id<"users">; actorRole?: Doc<"users">["role"]; + allowBanRestore?: boolean; source: "cli" | "dashboard"; }, ) { @@ -2785,7 +2792,12 @@ async function restorePackageDoc( const now = Date.now(); const actorRole = params.actorRole ?? "user"; - if (actorRole !== "admin" && actorRole !== "moderator" && pkg.softDeletedByRole !== "user") { + const isPrivilegedActor = actorRole === "admin" || actorRole === "moderator"; + const isUserRestorableDelete = + pkg.softDeletedByRole === "user" && pkg.softDeletedReason !== "user.banned"; + const isUnbanBatchRestore = + params.allowBanRestore === true && pkg.softDeletedReason === "user.banned"; + if (!isPrivilegedActor && !isUserRestorableDelete && !isUnbanBatchRestore) { throw new ConvexError( "Forbidden: This package was hidden by moderation and cannot be restored by the owner. Please contact a moderator.", ); @@ -2825,6 +2837,7 @@ async function restorePackageDoc( const packagePatch: Partial> = { softDeletedAt: undefined, + softDeletedReason: undefined, softDeletedBy: undefined, softDeletedByRole: undefined, tags: nextTags, @@ -2890,6 +2903,233 @@ async function restorePackageDoc( }; } +async function revokePackagePublishTokensForPackage( + ctx: Pick, + packageId: Id<"packages">, + revokedAt: number, +) { + const tokens = await ctx.db + .query("packagePublishTokens") + .withIndex("by_package", (q) => q.eq("packageId", packageId)) + .collect(); + let revokedCount = 0; + for (const token of tokens) { + if (token.revokedAt) continue; + await ctx.db.patch(token._id, { revokedAt }); + revokedCount += 1; + } + return revokedCount; +} + +async function isPackageOwnedByPersonalUser( + ctx: Pick, + pkg: Pick, "ownerPublisherId">, + owner: Doc<"users">, +) { + if (!pkg.ownerPublisherId) return true; + if (owner.personalPublisherId && pkg.ownerPublisherId === owner.personalPublisherId) { + return true; + } + const ownerPublisher = await ctx.db.get(pkg.ownerPublisherId); + return ownerPublisher?.kind === "user" && ownerPublisher.linkedUserId === owner._id; +} + +export const applyBanToOwnedPackagesBatchInternal = internalMutation({ + args: { + ownerUserId: v.id("users"), + bannedAt: v.number(), + deletedBy: v.id("users"), + deletedByRole: v.union(v.literal("admin"), v.literal("moderator"), v.literal("user")), + cursor: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const owner = await ctx.db.get(args.ownerUserId); + const isInitialPage = args.cursor === undefined; + const ownerMatchesCurrentBan = owner?.deletedAt === args.bannedAt; + const ownerIsActiveBeforeBanCommit = + isInitialPage && owner?.deletedAt === undefined && !owner?.deactivatedAt; + if ( + !owner || + owner.deactivatedAt || + (!ownerMatchesCurrentBan && !ownerIsActiveBeforeBanCommit) + ) { + return { + ok: true as const, + deletedCount: 0, + revokedTokenCount: 0, + scheduled: false, + stale: true as const, + }; + } + + const { page, isDone, continueCursor } = await ctx.db + .query("packages") + .withIndex("by_owner", (q) => q.eq("ownerUserId", args.ownerUserId)) + .order("desc") + .paginate({ + cursor: args.cursor ?? null, + numItems: BAN_USER_PACKAGES_BATCH_SIZE, + }); + + let deletedCount = 0; + let revokedTokenCount = 0; + for (const pkg of page) { + if (!(await isPackageOwnedByPersonalUser(ctx, pkg, owner))) continue; + revokedTokenCount += await revokePackagePublishTokensForPackage(ctx, pkg._id, args.bannedAt); + if (pkg.softDeletedAt) { + if (pkg.softDeletedReason === "user.banned" && pkg.softDeletedAt !== args.bannedAt) { + const packagePatch: Partial> = { + softDeletedAt: args.bannedAt, + softDeletedBy: args.deletedBy, + softDeletedByRole: args.deletedByRole, + updatedAt: args.bannedAt, + }; + const nextPackage: Doc<"packages"> = { ...pkg, ...packagePatch }; + await ctx.db.patch(pkg._id, packagePatch); + const ownerPublisher = await getOwnerPublisher(ctx, { + ownerPublisherId: pkg.ownerPublisherId, + ownerUserId: pkg.ownerUserId, + }); + await upsertPackageSearchDigest(ctx, { + ...extractPackageDigestFields(nextPackage), + ownerHandle: ownerPublisher?.handle ?? "", + ownerKind: ownerPublisher?.kind, + }); + } + continue; + } + + await softDeletePackageDoc(ctx, pkg, { + actorUserId: args.deletedBy, + actorRole: args.deletedByRole, + deletedAt: args.bannedAt, + reason: "user.banned", + source: "dashboard", + }); + deletedCount += 1; + } + + scheduleNextBatchIfNeeded( + ctx.scheduler, + internal.packages.applyBanToOwnedPackagesBatchInternal, + args, + isDone, + continueCursor, + ); + + return { ok: true as const, deletedCount, revokedTokenCount, scheduled: !isDone }; + }, +}); + +export const restoreOwnedPackagesForUnbanBatchInternal = internalMutation({ + args: { + ownerUserId: v.id("users"), + bannedAt: v.number(), + cursor: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const owner = await ctx.db.get(args.ownerUserId); + if (!owner || owner.deletedAt || owner.deactivatedAt) { + return { ok: true as const, restoredCount: 0, scheduled: false, stale: true as const }; + } + + const { page, isDone, continueCursor } = await ctx.db + .query("packages") + .withIndex("by_owner", (q) => q.eq("ownerUserId", args.ownerUserId)) + .order("desc") + .paginate({ + cursor: args.cursor ?? null, + numItems: BAN_USER_PACKAGES_BATCH_SIZE, + }); + + let restoredCount = 0; + for (const pkg of page) { + if (!(await isPackageOwnedByPersonalUser(ctx, pkg, owner))) continue; + if ( + !pkg.softDeletedAt || + pkg.softDeletedAt !== args.bannedAt || + pkg.softDeletedReason !== "user.banned" + ) { + continue; + } + + await restorePackageDoc(ctx, pkg, { + actorUserId: args.ownerUserId, + actorRole: "user", + allowBanRestore: true, + source: "dashboard", + }); + restoredCount += 1; + } + + scheduleNextBatchIfNeeded( + ctx.scheduler, + internal.packages.restoreOwnedPackagesForUnbanBatchInternal, + args, + isDone, + continueCursor, + ); + + return { ok: true as const, restoredCount, scheduled: !isDone }; + }, +}); + +export const applyAccountDeletionToOwnedPackagesBatchInternal = internalMutation({ + args: { + ownerUserId: v.id("users"), + deletedAt: v.number(), + cursor: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const owner = await ctx.db.get(args.ownerUserId); + if (!owner) { + return { + ok: true as const, + deletedCount: 0, + revokedTokenCount: 0, + scheduled: false, + stale: true as const, + }; + } + + const { page, isDone, continueCursor } = await ctx.db + .query("packages") + .withIndex("by_owner", (q) => q.eq("ownerUserId", args.ownerUserId)) + .order("desc") + .paginate({ + cursor: args.cursor ?? null, + numItems: BAN_USER_PACKAGES_BATCH_SIZE, + }); + + let deletedCount = 0; + let revokedTokenCount = 0; + for (const pkg of page) { + if (!(await isPackageOwnedByPersonalUser(ctx, pkg, owner))) continue; + revokedTokenCount += await revokePackagePublishTokensForPackage(ctx, pkg._id, args.deletedAt); + if (pkg.softDeletedAt) continue; + + await softDeletePackageDoc(ctx, pkg, { + actorUserId: args.ownerUserId, + actorRole: "user", + deletedAt: args.deletedAt, + reason: "user.deactivated", + source: "dashboard", + }); + deletedCount += 1; + } + + scheduleNextBatchIfNeeded( + ctx.scheduler, + internal.packages.applyAccountDeletionToOwnedPackagesBatchInternal, + args, + isDone, + continueCursor, + ); + + return { ok: true as const, deletedCount, revokedTokenCount, scheduled: !isDone }; + }, +}); + export const softDeletePackageInternal = internalMutation({ args: { userId: v.id("users"), diff --git a/convex/schema.ts b/convex/schema.ts index 13d1db25ea..39ddb4d968 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -891,6 +891,7 @@ const packages = defineTable({ reportCount: v.optional(v.number()), lastReportedAt: v.optional(v.number()), softDeletedAt: v.optional(v.number()), + softDeletedReason: v.optional(v.union(v.literal("user.banned"), v.literal("user.deactivated"))), softDeletedBy: v.optional(v.id("users")), softDeletedByRole: v.optional( v.union(v.literal("admin"), v.literal("moderator"), v.literal("user")), diff --git a/convex/users.test.ts b/convex/users.test.ts index b020f9fa5b..c6c82d5583 100644 --- a/convex/users.test.ts +++ b/convex/users.test.ts @@ -1933,7 +1933,15 @@ describe("users.banUserInternal", () => { deletedSkills: 0, deletedComments: { skillComments: 1, soulComments: 1 }, }); - expect(runMutation).not.toHaveBeenCalled(); + expect(runMutation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + ownerUserId: "users:target", + bannedAt: 1_600_000_000_000, + deletedBy: "users:actor", + deletedByRole: "moderator", + }), + ); expect(patch).toHaveBeenCalledWith("comments:active", { softDeletedAt: 1_600_000_000_000, deletedBy: "users:actor", diff --git a/convex/users.ts b/convex/users.ts index f9d5c37f7a..e46cf72ddf 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -520,6 +520,12 @@ export const deleteAccount = mutation({ } } + await ctx.runMutation(internal.packages.applyAccountDeletionToOwnedPackagesBatchInternal, { + ownerUserId: userId, + deletedAt: now, + cursor: undefined, + }); + const user = await ctx.db.get(userId); await ctx.db.patch(userId, { deactivatedAt: now, @@ -1600,12 +1606,24 @@ async function banUserWithActor( }; } if (target.deletedAt) { + await ctx.runMutation(internal.packages.applyBanToOwnedPackagesBatchInternal, { + ownerUserId: targetUserId, + bannedAt: target.deletedAt, + deletedBy: actor._id, + deletedByRole: actor.role === "admin" ? "admin" : "moderator", + cursor: undefined, + }); const deletedComments = await softDeleteUserCommentsForBan(ctx, { userId: targetUserId, deletedBy: actor._id, deletedAt: target.deletedAt, }); - return { ok: true as const, alreadyBanned: true, deletedSkills: 0, deletedComments }; + return { + ok: true as const, + alreadyBanned: true, + deletedSkills: 0, + deletedComments, + }; } const banSkillsResult = (await ctx.runMutation( @@ -1620,6 +1638,20 @@ async function banUserWithActor( const hiddenCount = banSkillsResult.hiddenCount ?? 0; const scheduledSkills = banSkillsResult.scheduled ?? false; + const banPackagesResult = ((await ctx.runMutation( + internal.packages.applyBanToOwnedPackagesBatchInternal, + { + ownerUserId: targetUserId, + bannedAt: now, + deletedBy: actor._id, + deletedByRole: actor.role === "admin" ? "admin" : "moderator", + cursor: undefined, + }, + )) ?? {}) as { deletedCount?: number; revokedTokenCount?: number; scheduled?: boolean }; + const deletedPackageCount = banPackagesResult.deletedCount ?? 0; + const revokedPackagePublishTokens = banPackagesResult.revokedTokenCount ?? 0; + const scheduledPackages = banPackagesResult.scheduled ?? false; + const tokens = await ctx.db .query("apiTokens") .withIndex("by_user", (q) => q.eq("userId", targetUserId)) @@ -1652,6 +1684,9 @@ async function banUserWithActor( targetId: targetUserId, metadata: { hiddenSkills: hiddenCount, + deletedPackages: deletedPackageCount, + revokedPackagePublishTokens, + scheduledPackages, deletedSkillComments: deletedComments.skillComments, deletedSoulComments: deletedComments.soulComments, reason: reason || undefined, @@ -1711,12 +1746,28 @@ async function unbanUserWithActor( const restoredCount = restoreSkillsResult.restoredCount ?? 0; const scheduledSkills = restoreSkillsResult.scheduled ?? false; + const restorePackagesResult = ((await ctx.runMutation( + internal.packages.restoreOwnedPackagesForUnbanBatchInternal, + { + 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 }, + metadata: { + reason: reason || undefined, + restoredSkills: restoredCount, + restoredPackages: restoredPackageCount, + scheduledPackages, + }, createdAt: now, }); @@ -2046,6 +2097,20 @@ export const autobanMalwareAuthorInternal = internalMutation({ const hiddenCount = banSkillsResult.hiddenCount ?? 0; const scheduledSkills = banSkillsResult.scheduled ?? false; + 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; + // Revoke all API tokens const tokens = await ctx.db .query("apiTokens") @@ -2079,6 +2144,9 @@ export const autobanMalwareAuthorInternal = internalMutation({ trigger: args.trigger?.trim() || "scanner.malicious", slug: args.slug, hiddenSkills: hiddenCount, + deletedPackages: deletedPackageCount, + revokedPackagePublishTokens, + scheduledPackages, deletedSkillComments: deletedComments.skillComments, deletedSoulComments: deletedComments.soulComments, }; diff --git a/specs/security-moderation.md b/specs/security-moderation.md index ce59f661e2..9f05a51072 100644 --- a/specs/security-moderation.md +++ b/specs/security-moderation.md @@ -192,11 +192,19 @@ See also: [acceptable-usage.md](./acceptable-usage.md) for the marketplace polic - Banning a user: - hard-deletes all owned skills + - soft-deletes all owned packages/plugins with a ban-specific reason marker + and revokes package publish tokens + - the first package batch may run before `users.deletedAt` is committed; + later paginated package batches must match the current ban timestamp + - packages already hidden by an earlier user ban are retimestamped to the + current ban so the next matching unban can restore them - soft-deletes all authored skill comments + soul comments - revokes API tokens - sets `deletedAt` on the user - Admins can manually unban (`deletedAt` + `banReason` cleared); revoked API tokens stay revoked and should be recreated by the user. +- Unban restore batches only restore packages/plugins hidden by the matching + ban timestamp and must stop if the user has been banned again. - Optional ban reason is stored in `users.banReason` and audit logs. - Admins can reclassify an existing ban reason without unbanning or restoring content. This preserves the ban while removing users from remediation flows From 0b80fea34db5794633b650ffa4f42a5a33ac016b Mon Sep 17 00:00:00 2001 From: vyctorbrzezowski Date: Sat, 16 May 2026 19:22:50 -0300 Subject: [PATCH 02/17] fix(api): attribute unban package restores to moderator --- convex/packages.public.test.ts | 21 +++++++++++++++++++-- convex/packages.ts | 9 +++++++-- convex/users.ts | 1 + 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/convex/packages.public.test.ts b/convex/packages.public.test.ts index 44fe403cdc..eb011d9941 100644 --- a/convex/packages.public.test.ts +++ b/convex/packages.public.test.ts @@ -542,7 +542,7 @@ const applyBanToOwnedPackagesBatchInternalHandler = ( )._handler; const restoreOwnedPackagesForUnbanBatchInternalHandler = ( restoreOwnedPackagesForUnbanBatchInternal as unknown as WrappedHandler< - { ownerUserId: string; bannedAt: number; cursor?: string }, + { actorUserId: string; ownerUserId: string; bannedAt: number; cursor?: string }, { restoredCount: number; scheduled: boolean; stale?: true } > )._handler; @@ -6862,6 +6862,14 @@ function makeOwnedPackageBatchCtx(options?: { scheduler: { runAfter }, db: { get: vi.fn(async (id: string) => { + if (id === "users:admin") { + return { + _id: "users:admin", + role: "admin", + deletedAt: undefined, + deactivatedAt: undefined, + }; + } if (id === "users:owner") { return options?.owner === undefined ? { _id: "users:owner", deletedAt: undefined, deactivatedAt: undefined } @@ -7041,7 +7049,7 @@ describe("owned package sanction batches", () => { }); it("restores only packages that were hidden by the matching ban batch", async () => { - const { ctx, patch } = makeOwnedPackageBatchCtx({ + const { ctx, patch, insert } = makeOwnedPackageBatchCtx({ pkg: makePackageDoc({ softDeletedAt: 1_000, softDeletedReason: "user.banned", @@ -7062,6 +7070,7 @@ describe("owned package sanction batches", () => { }); const result = await restoreOwnedPackagesForUnbanBatchInternalHandler(ctx as never, { + actorUserId: "users:admin", ownerUserId: "users:owner", bannedAt: 1_000, }); @@ -7076,6 +7085,13 @@ describe("owned package sanction batches", () => { softDeletedByRole: undefined, }), ); + expect(insert).toHaveBeenCalledWith( + "auditLogs", + expect.objectContaining({ + actorUserId: "users:admin", + action: "package.undelete", + }), + ); }); it("stops stale package restore batches when the owner is banned again", async () => { @@ -7088,6 +7104,7 @@ describe("owned package sanction batches", () => { }); const result = await restoreOwnedPackagesForUnbanBatchInternalHandler(ctx as never, { + actorUserId: "users:admin", ownerUserId: "users:owner", bannedAt: 1_000, }); diff --git a/convex/packages.ts b/convex/packages.ts index f4e64d0ee3..fcd93af360 100644 --- a/convex/packages.ts +++ b/convex/packages.ts @@ -3023,11 +3023,16 @@ export const applyBanToOwnedPackagesBatchInternal = internalMutation({ export const restoreOwnedPackagesForUnbanBatchInternal = internalMutation({ args: { + actorUserId: v.id("users"), ownerUserId: v.id("users"), bannedAt: v.number(), cursor: v.optional(v.string()), }, handler: async (ctx, args) => { + const actor = await ctx.db.get(args.actorUserId); + if (!actor || actor.deletedAt || actor.deactivatedAt) { + throw new ConvexError("Unauthorized"); + } const owner = await ctx.db.get(args.ownerUserId); if (!owner || owner.deletedAt || owner.deactivatedAt) { return { ok: true as const, restoredCount: 0, scheduled: false, stale: true as const }; @@ -3054,8 +3059,8 @@ export const restoreOwnedPackagesForUnbanBatchInternal = internalMutation({ } await restorePackageDoc(ctx, pkg, { - actorUserId: args.ownerUserId, - actorRole: "user", + actorUserId: actor._id, + actorRole: actor.role, allowBanRestore: true, source: "dashboard", }); diff --git a/convex/users.ts b/convex/users.ts index e46cf72ddf..891de3a577 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -1749,6 +1749,7 @@ async function unbanUserWithActor( const restorePackagesResult = ((await ctx.runMutation( internal.packages.restoreOwnedPackagesForUnbanBatchInternal, { + actorUserId: actor._id, ownerUserId: targetUserId, bannedAt, cursor: undefined, From edb266be390857837681eee092a893bb8fa0e3a6 Mon Sep 17 00:00:00 2001 From: vyctorbrzezowski Date: Mon, 18 May 2026 17:44:12 -0300 Subject: [PATCH 03/17] fix(web): clarify package effects in ban confirmations --- src/routes/management.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/routes/management.tsx b/src/routes/management.tsx index a2dc973960..44dbcf53c8 100644 --- a/src/routes/management.tsx +++ b/src/routes/management.tsx @@ -668,7 +668,11 @@ function Management() { disabled={!canBanOwner} onClick={() => { if (!ownerUserId || ownerUserId === me?._id) return; - if (!window.confirm(`Ban @${ownerHandle} and delete their skills?`)) { + if ( + !window.confirm( + `Ban @${ownerHandle}, hide their skills and personal package/plugin resources, and revoke package publish tokens?`, + ) + ) { return; } const reason = promptBanReason(`@${ownerHandle}`); @@ -1016,7 +1020,7 @@ function Management() { if (user._id === me?._id) return; if ( !window.confirm( - `Ban @${user.handle ?? user.name ?? "user"} and delete their skills?`, + `Ban @${user.handle ?? user.name ?? "user"}, hide their skills and personal package/plugin resources, and revoke package publish tokens?`, ) ) { return; @@ -1036,7 +1040,11 @@ function Management() { type="button" onClick={() => { const label = `@${user.handle ?? user.name ?? "user"}`; - if (!window.confirm(`Unban ${label} and restore eligible skills?`)) { + if ( + !window.confirm( + `Unban ${label} and restore eligible skills and ban-hidden personal package/plugin resources?`, + ) + ) { return; } const reason = promptUnbanReason(label); From 352734f88b05745a7c181bf9afece0e3e34182a8 Mon Sep 17 00:00:00 2001 From: vyctorbrzezowski Date: Thu, 21 May 2026 13:20:07 -0300 Subject: [PATCH 04/17] fix(api): continue package ban batches during in-flight bans --- convex/packages.public.test.ts | 78 ++++++++++++++++++++++++++++++++++ convex/packages.ts | 8 +++- convex/users.ts | 2 + 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/convex/packages.public.test.ts b/convex/packages.public.test.ts index eb011d9941..1e3ede4bb9 100644 --- a/convex/packages.public.test.ts +++ b/convex/packages.public.test.ts @@ -536,6 +536,7 @@ const applyBanToOwnedPackagesBatchInternalHandler = ( deletedBy: string; deletedByRole: "admin" | "moderator" | "user"; cursor?: string; + allowActiveOwnerBeforeCommit?: boolean; }, { deletedCount: number; revokedTokenCount: number; scheduled: boolean } > @@ -6952,6 +6953,7 @@ describe("owned package sanction batches", () => { bannedAt: 1_000, deletedBy: "users:moderator", deletedByRole: "moderator", + allowActiveOwnerBeforeCommit: true, }); expect(result).toMatchObject({ deletedCount: 1, revokedTokenCount: 1, scheduled: false }); @@ -6988,6 +6990,82 @@ describe("owned package sanction batches", () => { expect(patch).not.toHaveBeenCalledWith("packagePublishTokens:demo", expect.anything()); }); + it("continues in-flight package ban pages before the user ban commit is visible", async () => { + const firstPage = makeOwnedPackageBatchCtx({ + owner: { _id: "users:owner", deletedAt: undefined, deactivatedAt: undefined }, + pkg: makePackageDoc({ _id: "packages:first", ownerUserId: "users:owner" }), + packageTokens: [ + { + _id: "packagePublishTokens:first", + packageId: "packages:first", + version: "1.0.1", + revokedAt: undefined, + }, + ], + isDone: false, + continueCursor: "next-page", + }); + + const firstResult = await applyBanToOwnedPackagesBatchInternalHandler(firstPage.ctx as never, { + ownerUserId: "users:owner", + bannedAt: 1_000, + deletedBy: "users:moderator", + deletedByRole: "moderator", + allowActiveOwnerBeforeCommit: true, + }); + + expect(firstResult).toMatchObject({ deletedCount: 1, revokedTokenCount: 1, scheduled: true }); + expect(firstPage.patch).toHaveBeenCalledWith( + "packages:first", + expect.objectContaining({ softDeletedAt: 1_000, softDeletedReason: "user.banned" }), + ); + expect(firstPage.patch).toHaveBeenCalledWith("packagePublishTokens:first", { + revokedAt: 1_000, + }); + expect(firstPage.runAfter).toHaveBeenCalledWith( + 0, + expect.anything(), + expect.objectContaining({ + cursor: "next-page", + allowActiveOwnerBeforeCommit: true, + }), + ); + + const secondPage = makeOwnedPackageBatchCtx({ + owner: { _id: "users:owner", deletedAt: undefined, deactivatedAt: undefined }, + pkg: makePackageDoc({ _id: "packages:second", ownerUserId: "users:owner" }), + packageTokens: [ + { + _id: "packagePublishTokens:second", + packageId: "packages:second", + version: "1.0.1", + revokedAt: undefined, + }, + ], + }); + + const secondResult = await applyBanToOwnedPackagesBatchInternalHandler( + secondPage.ctx as never, + { + ownerUserId: "users:owner", + bannedAt: 1_000, + deletedBy: "users:moderator", + deletedByRole: "moderator", + cursor: "next-page", + allowActiveOwnerBeforeCommit: true, + }, + ); + + expect(secondResult).toMatchObject({ deletedCount: 1, revokedTokenCount: 1, scheduled: false }); + expect(secondPage.patch).toHaveBeenCalledWith( + "packages:second", + expect.objectContaining({ softDeletedAt: 1_000, softDeletedReason: "user.banned" }), + ); + expect(secondPage.patch).toHaveBeenCalledWith("packagePublishTokens:second", { + revokedAt: 1_000, + }); + }); + it("retimestamps earlier ban-hidden packages during a later ban", async () => { const { ctx, patch } = makeOwnedPackageBatchCtx({ owner: { _id: "users:owner", deletedAt: 2_000, deactivatedAt: undefined }, diff --git a/convex/packages.ts b/convex/packages.ts index fcd93af360..d2bebe4a46 100644 --- a/convex/packages.ts +++ b/convex/packages.ts @@ -2941,13 +2941,17 @@ export const applyBanToOwnedPackagesBatchInternal = internalMutation({ deletedBy: v.id("users"), deletedByRole: v.union(v.literal("admin"), v.literal("moderator"), v.literal("user")), cursor: v.optional(v.string()), + allowActiveOwnerBeforeCommit: v.optional(v.boolean()), }, handler: async (ctx, args) => { const owner = await ctx.db.get(args.ownerUserId); - const isInitialPage = args.cursor === undefined; const ownerMatchesCurrentBan = owner?.deletedAt === args.bannedAt; + // Ban callers run this before users.deletedAt is visible; keep the allow flag + // through scheduled pages so multi-page batches do not stale-stop mid-ban. const ownerIsActiveBeforeBanCommit = - isInitialPage && owner?.deletedAt === undefined && !owner?.deactivatedAt; + args.allowActiveOwnerBeforeCommit === true && + owner?.deletedAt === undefined && + !owner?.deactivatedAt; if ( !owner || owner.deactivatedAt || diff --git a/convex/users.ts b/convex/users.ts index 891de3a577..725319334f 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -1646,6 +1646,7 @@ async function banUserWithActor( deletedBy: actor._id, deletedByRole: actor.role === "admin" ? "admin" : "moderator", cursor: undefined, + allowActiveOwnerBeforeCommit: true, }, )) ?? {}) as { deletedCount?: number; revokedTokenCount?: number; scheduled?: boolean }; const deletedPackageCount = banPackagesResult.deletedCount ?? 0; @@ -2106,6 +2107,7 @@ export const autobanMalwareAuthorInternal = internalMutation({ deletedBy: args.ownerUserId, deletedByRole: "user", cursor: undefined, + allowActiveOwnerBeforeCommit: true, }, )) ?? {}) as { deletedCount?: number; revokedTokenCount?: number; scheduled?: boolean }; const deletedPackageCount = banPackagesResult.deletedCount ?? 0; From 40dbc00edc19a5c069a6a1c46fd834df57b53e9d Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Wed, 27 May 2026 13:00:23 -0500 Subject: [PATCH 05/17] fix: keep package unban restore scoped to ban batch --- convex/packages.public.test.ts | 69 ++++++++++++++++++++++++++++++---- convex/packages.ts | 17 +++++++-- 2 files changed, 75 insertions(+), 11 deletions(-) diff --git a/convex/packages.public.test.ts b/convex/packages.public.test.ts index 760e61dd2d..e557aed262 100644 --- a/convex/packages.public.test.ts +++ b/convex/packages.public.test.ts @@ -7427,7 +7427,7 @@ describe("owned package sanction batches", () => { expect(patch).not.toHaveBeenCalledWith("packagePublishTokens:demo", expect.anything()); }); - it("continues in-flight package ban pages before the user ban commit is visible", async () => { + it("does not carry the pre-commit package ban bypass to continuation pages", async () => { const firstPage = makeOwnedPackageBatchCtx({ owner: { _id: "users:owner", deletedAt: undefined, deactivatedAt: undefined }, pkg: makePackageDoc({ _id: "packages:first", ownerUserId: "users:owner" }), @@ -7464,11 +7464,13 @@ describe("owned package sanction batches", () => { expect.anything(), expect.objectContaining({ cursor: "next-page", - allowActiveOwnerBeforeCommit: true, }), ); + expect(firstPage.runAfter.mock.calls[0]?.[2]).not.toEqual( + expect.objectContaining({ allowActiveOwnerBeforeCommit: true }), + ); - const secondPage = makeOwnedPackageBatchCtx({ + const staleContinuationPage = makeOwnedPackageBatchCtx({ owner: { _id: "users:owner", deletedAt: undefined, deactivatedAt: undefined }, pkg: makePackageDoc({ _id: "packages:second", ownerUserId: "users:owner" }), packageTokens: [ @@ -7481,8 +7483,8 @@ describe("owned package sanction batches", () => { ], }); - const secondResult = await applyBanToOwnedPackagesBatchInternalHandler( - secondPage.ctx as never, + const staleContinuationResult = await applyBanToOwnedPackagesBatchInternalHandler( + staleContinuationPage.ctx as never, { ownerUserId: "users:owner", bannedAt: 1_000, @@ -7493,12 +7495,52 @@ describe("owned package sanction batches", () => { }, ); - expect(secondResult).toMatchObject({ deletedCount: 1, revokedTokenCount: 1, scheduled: false }); - expect(secondPage.patch).toHaveBeenCalledWith( + expect(staleContinuationResult).toMatchObject({ + stale: true, + deletedCount: 0, + revokedTokenCount: 0, + scheduled: false, + }); + expect(staleContinuationPage.patch).not.toHaveBeenCalledWith("packages:second", expect.anything()); + expect(staleContinuationPage.patch).not.toHaveBeenCalledWith( + "packagePublishTokens:second", + expect.anything(), + ); + + const committedContinuationPage = makeOwnedPackageBatchCtx({ + owner: { _id: "users:owner", deletedAt: 1_000, deactivatedAt: undefined }, + pkg: makePackageDoc({ _id: "packages:second", ownerUserId: "users:owner" }), + packageTokens: [ + { + _id: "packagePublishTokens:second", + packageId: "packages:second", + version: "1.0.1", + revokedAt: undefined, + }, + ], + }); + + const committedContinuationResult = await applyBanToOwnedPackagesBatchInternalHandler( + committedContinuationPage.ctx as never, + { + ownerUserId: "users:owner", + bannedAt: 1_000, + deletedBy: "users:moderator", + deletedByRole: "moderator", + cursor: "next-page", + }, + ); + + expect(committedContinuationResult).toMatchObject({ + deletedCount: 1, + revokedTokenCount: 1, + scheduled: false, + }); + expect(committedContinuationPage.patch).toHaveBeenCalledWith( "packages:second", expect.objectContaining({ softDeletedAt: 1_000, softDeletedReason: "user.banned" }), ); - expect(secondPage.patch).toHaveBeenCalledWith("packagePublishTokens:second", { + expect(committedContinuationPage.patch).toHaveBeenCalledWith("packagePublishTokens:second", { revokedAt: 1_000, }); }); @@ -7581,6 +7623,16 @@ describe("owned package sanction batches", () => { capabilities: null, verification: null, }), + makeReleaseDoc({ + _id: "packageReleases:malicious", + softDeletedAt: 500, + distTags: ["malicious"], + version: "0.9.0", + changelog: "", + compatibility: null, + capabilities: null, + verification: null, + }), ], }); @@ -7607,6 +7659,7 @@ describe("owned package sanction batches", () => { action: "package.undelete", }), ); + expect(patch).not.toHaveBeenCalledWith("packageReleases:malicious", expect.anything()); }); it("stops stale package restore batches when the owner is banned again", async () => { diff --git a/convex/packages.ts b/convex/packages.ts index 3347d1a738..7b5b52afb4 100644 --- a/convex/packages.ts +++ b/convex/packages.ts @@ -2874,6 +2874,7 @@ async function restorePackageDoc( actorUserId: Id<"users">; actorRole?: Doc<"users">["role"]; allowBanRestore?: boolean; + releaseSoftDeletedAt?: number; source: "cli" | "dashboard"; }, ) { @@ -2908,6 +2909,12 @@ async function restorePackageDoc( const activeReleases: Doc<"packageReleases">[] = []; for (const release of releases) { if (release.softDeletedAt) { + if ( + params.releaseSoftDeletedAt !== undefined && + release.softDeletedAt !== params.releaseSoftDeletedAt + ) { + continue; + } const restoredRelease = { ...release, softDeletedAt: undefined }; await ctx.db.patch(release._id, { softDeletedAt: undefined }); releaseCount += 1; @@ -3042,10 +3049,12 @@ export const applyBanToOwnedPackagesBatchInternal = internalMutation({ handler: async (ctx, args) => { const owner = await ctx.db.get(args.ownerUserId); const ownerMatchesCurrentBan = owner?.deletedAt === args.bannedAt; - // Ban callers run this before users.deletedAt is visible; keep the allow flag - // through scheduled pages so multi-page batches do not stale-stop mid-ban. + // Ban callers run the first package page before users.deletedAt is visible. + // Continuation pages must see the committed ban timestamp so a later unban + // cannot be treated as the same pre-commit window. const ownerIsActiveBeforeBanCommit = args.allowActiveOwnerBeforeCommit === true && + args.cursor === undefined && owner?.deletedAt === undefined && !owner?.deactivatedAt; if ( @@ -3109,10 +3118,11 @@ export const applyBanToOwnedPackagesBatchInternal = internalMutation({ deletedCount += 1; } + const { allowActiveOwnerBeforeCommit: _allowActiveOwnerBeforeCommit, ...nextArgs } = args; scheduleNextBatchIfNeeded( ctx.scheduler, internal.packages.applyBanToOwnedPackagesBatchInternal, - args, + nextArgs, isDone, continueCursor, ); @@ -3162,6 +3172,7 @@ export const restoreOwnedPackagesForUnbanBatchInternal = internalMutation({ actorUserId: actor._id, actorRole: actor.role, allowBanRestore: true, + releaseSoftDeletedAt: args.bannedAt, source: "dashboard", }); restoredCount += 1; From 63231d58950f059da5da7d9b9b730777a496ea7f Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Wed, 27 May 2026 13:07:23 -0500 Subject: [PATCH 06/17] fix: retimestamp package releases during repeated bans --- convex/packages.public.test.ts | 15 +++++++++++++++ convex/packages.ts | 10 ++++++++++ 2 files changed, 25 insertions(+) diff --git a/convex/packages.public.test.ts b/convex/packages.public.test.ts index e557aed262..8e66e296aa 100644 --- a/convex/packages.public.test.ts +++ b/convex/packages.public.test.ts @@ -7554,6 +7554,16 @@ describe("owned package sanction batches", () => { softDeletedBy: "users:first-moderator", softDeletedByRole: "moderator", }), + releases: [ + makeReleaseDoc({ + _id: "packageReleases:ban-hidden", + softDeletedAt: 1_000, + }), + makeReleaseDoc({ + _id: "packageReleases:moderation-hidden", + softDeletedAt: 500, + }), + ], }); const result = await applyBanToOwnedPackagesBatchInternalHandler(ctx as never, { @@ -7564,6 +7574,11 @@ describe("owned package sanction batches", () => { }); expect(result).toMatchObject({ deletedCount: 0, revokedTokenCount: 1, scheduled: false }); + expect(patch).toHaveBeenCalledWith( + "packageReleases:ban-hidden", + expect.objectContaining({ softDeletedAt: 2_000 }), + ); + expect(patch).not.toHaveBeenCalledWith("packageReleases:moderation-hidden", expect.anything()); expect(patch).toHaveBeenCalledWith( "packages:demo", expect.objectContaining({ diff --git a/convex/packages.ts b/convex/packages.ts index 7b5b52afb4..2c9b213e7b 100644 --- a/convex/packages.ts +++ b/convex/packages.ts @@ -3087,6 +3087,16 @@ export const applyBanToOwnedPackagesBatchInternal = internalMutation({ revokedTokenCount += await revokePackagePublishTokensForPackage(ctx, pkg._id, args.bannedAt); if (pkg.softDeletedAt) { if (pkg.softDeletedReason === "user.banned" && pkg.softDeletedAt !== args.bannedAt) { + const previousBanHiddenAt = pkg.softDeletedAt; + const releases = await ctx.db + .query("packageReleases") + .withIndex("by_package", (q) => q.eq("packageId", pkg._id)) + .collect(); + for (const release of releases) { + if (release.softDeletedAt === previousBanHiddenAt) { + await ctx.db.patch(release._id, { softDeletedAt: args.bannedAt }); + } + } const packagePatch: Partial> = { softDeletedAt: args.bannedAt, softDeletedBy: args.deletedBy, From 66b34687b53970c3e7552de86166f1dae1e3d5f8 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Wed, 27 May 2026 13:15:00 -0500 Subject: [PATCH 07/17] fix: start package ban batches after user ban commit --- convex/packages.public.test.ts | 15 +++------ convex/packages.ts | 18 ++--------- convex/users.ts | 58 ++++++++++++++++------------------ 3 files changed, 35 insertions(+), 56 deletions(-) diff --git a/convex/packages.public.test.ts b/convex/packages.public.test.ts index f25d00d3fd..256d373c99 100644 --- a/convex/packages.public.test.ts +++ b/convex/packages.public.test.ts @@ -560,7 +560,6 @@ const applyBanToOwnedPackagesBatchInternalHandler = ( deletedBy: string; deletedByRole: "admin" | "moderator" | "user"; cursor?: string; - allowActiveOwnerBeforeCommit?: boolean; }, { deletedCount: number; revokedTokenCount: number; scheduled: boolean } > @@ -7668,14 +7667,15 @@ function makeOwnedPackageBatchCtx(options?: { describe("owned package sanction batches", () => { it("soft-deletes owned packages with a ban reason and revokes package publish tokens", async () => { - const { ctx, patch } = makeOwnedPackageBatchCtx(); + const { ctx, patch } = makeOwnedPackageBatchCtx({ + owner: { _id: "users:owner", deletedAt: 1_000, deactivatedAt: undefined }, + }); const result = await applyBanToOwnedPackagesBatchInternalHandler(ctx as never, { ownerUserId: "users:owner", bannedAt: 1_000, deletedBy: "users:moderator", deletedByRole: "moderator", - allowActiveOwnerBeforeCommit: true, }); expect(result).toMatchObject({ deletedCount: 1, revokedTokenCount: 1, scheduled: false }); @@ -7712,9 +7712,9 @@ describe("owned package sanction batches", () => { expect(patch).not.toHaveBeenCalledWith("packagePublishTokens:demo", expect.anything()); }); - it("does not carry the pre-commit package ban bypass to continuation pages", async () => { + it("continues committed package ban pages without a pre-commit bypass", async () => { const firstPage = makeOwnedPackageBatchCtx({ - owner: { _id: "users:owner", deletedAt: undefined, deactivatedAt: undefined }, + owner: { _id: "users:owner", deletedAt: 1_000, deactivatedAt: undefined }, pkg: makePackageDoc({ _id: "packages:first", ownerUserId: "users:owner" }), packageTokens: [ { @@ -7733,7 +7733,6 @@ describe("owned package sanction batches", () => { bannedAt: 1_000, deletedBy: "users:moderator", deletedByRole: "moderator", - allowActiveOwnerBeforeCommit: true, }); expect(firstResult).toMatchObject({ deletedCount: 1, revokedTokenCount: 1, scheduled: true }); @@ -7751,9 +7750,6 @@ describe("owned package sanction batches", () => { cursor: "next-page", }), ); - expect(firstPage.runAfter.mock.calls[0]?.[2]).not.toEqual( - expect.objectContaining({ allowActiveOwnerBeforeCommit: true }), - ); const staleContinuationPage = makeOwnedPackageBatchCtx({ owner: { _id: "users:owner", deletedAt: undefined, deactivatedAt: undefined }, @@ -7776,7 +7772,6 @@ describe("owned package sanction batches", () => { deletedBy: "users:moderator", deletedByRole: "moderator", cursor: "next-page", - allowActiveOwnerBeforeCommit: true, }, ); diff --git a/convex/packages.ts b/convex/packages.ts index 0575c92847..d1655205ae 100644 --- a/convex/packages.ts +++ b/convex/packages.ts @@ -3044,24 +3044,11 @@ export const applyBanToOwnedPackagesBatchInternal = internalMutation({ deletedBy: v.id("users"), deletedByRole: v.union(v.literal("admin"), v.literal("moderator"), v.literal("user")), cursor: v.optional(v.string()), - allowActiveOwnerBeforeCommit: v.optional(v.boolean()), }, handler: async (ctx, args) => { const owner = await ctx.db.get(args.ownerUserId); const ownerMatchesCurrentBan = owner?.deletedAt === args.bannedAt; - // Ban callers run the first package page before users.deletedAt is visible. - // Continuation pages must see the committed ban timestamp so a later unban - // cannot be treated as the same pre-commit window. - const ownerIsActiveBeforeBanCommit = - args.allowActiveOwnerBeforeCommit === true && - args.cursor === undefined && - owner?.deletedAt === undefined && - !owner?.deactivatedAt; - if ( - !owner || - owner.deactivatedAt || - (!ownerMatchesCurrentBan && !ownerIsActiveBeforeBanCommit) - ) { + if (!owner || owner.deactivatedAt || !ownerMatchesCurrentBan) { return { ok: true as const, deletedCount: 0, @@ -3128,11 +3115,10 @@ export const applyBanToOwnedPackagesBatchInternal = internalMutation({ deletedCount += 1; } - const { allowActiveOwnerBeforeCommit: _allowActiveOwnerBeforeCommit, ...nextArgs } = args; scheduleNextBatchIfNeeded( ctx.scheduler, internal.packages.applyBanToOwnedPackagesBatchInternal, - nextArgs, + args, isDone, continueCursor, ); diff --git a/convex/users.ts b/convex/users.ts index 8ac8d36639..5d899f72f1 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -1638,21 +1638,6 @@ async function banUserWithActor( const hiddenCount = banSkillsResult.hiddenCount ?? 0; const scheduledSkills = banSkillsResult.scheduled ?? false; - const banPackagesResult = ((await ctx.runMutation( - internal.packages.applyBanToOwnedPackagesBatchInternal, - { - ownerUserId: targetUserId, - bannedAt: now, - deletedBy: actor._id, - deletedByRole: actor.role === "admin" ? "admin" : "moderator", - cursor: undefined, - allowActiveOwnerBeforeCommit: true, - }, - )) ?? {}) as { deletedCount?: number; revokedTokenCount?: number; scheduled?: boolean }; - const deletedPackageCount = banPackagesResult.deletedCount ?? 0; - const revokedPackagePublishTokens = banPackagesResult.revokedTokenCount ?? 0; - const scheduledPackages = banPackagesResult.scheduled ?? false; - const tokens = await ctx.db .query("apiTokens") .withIndex("by_user", (q) => q.eq("userId", targetUserId)) @@ -1676,6 +1661,20 @@ async function banUserWithActor( banReason: reason || undefined, }); + const banPackagesResult = ((await ctx.runMutation( + internal.packages.applyBanToOwnedPackagesBatchInternal, + { + ownerUserId: targetUserId, + bannedAt: now, + deletedBy: actor._id, + deletedByRole: actor.role === "admin" ? "admin" : "moderator", + 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: targetUserId }); await ctx.db.insert("auditLogs", { @@ -2099,21 +2098,6 @@ export const autobanMalwareAuthorInternal = internalMutation({ const hiddenCount = banSkillsResult.hiddenCount ?? 0; const scheduledSkills = banSkillsResult.scheduled ?? false; - const banPackagesResult = ((await ctx.runMutation( - internal.packages.applyBanToOwnedPackagesBatchInternal, - { - ownerUserId: args.ownerUserId, - bannedAt: now, - deletedBy: args.ownerUserId, - deletedByRole: "user", - cursor: undefined, - allowActiveOwnerBeforeCommit: true, - }, - )) ?? {}) as { deletedCount?: number; revokedTokenCount?: number; scheduled?: boolean }; - const deletedPackageCount = banPackagesResult.deletedCount ?? 0; - const revokedPackagePublishTokens = banPackagesResult.revokedTokenCount ?? 0; - const scheduledPackages = banPackagesResult.scheduled ?? false; - // Revoke all API tokens const tokens = await ctx.db .query("apiTokens") @@ -2139,6 +2123,20 @@ export const autobanMalwareAuthorInternal = internalMutation({ 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, }); From 1e8d93fca53c45d9bc34de19ccf4e73601b5c1e7 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Wed, 27 May 2026 13:22:08 -0500 Subject: [PATCH 08/17] fix: preserve manual package moderation after unban --- convex/packages.public.test.ts | 34 +++++++++++++++++++++++++++++++++- convex/packages.ts | 21 ++++++++++++++++++--- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/convex/packages.public.test.ts b/convex/packages.public.test.ts index 256d373c99..134990b0fb 100644 --- a/convex/packages.public.test.ts +++ b/convex/packages.public.test.ts @@ -7440,6 +7440,7 @@ describe("package scan backfill", () => { */ function makeSoftDeleteCtx(options?: { pkg?: Record; + actor?: Record; /** When true, no existing packageCapabilitySearchDigest rows exist (forces insert). */ noCapabilityDigest?: boolean; /** Personal publisher linked to the owner user. */ @@ -7490,7 +7491,7 @@ function makeSoftDeleteCtx(options?: { ctx: { db: { get: vi.fn(async (id: string) => { - if (id === "users:owner") return { _id: id, role: "user" }; + if (id === "users:owner") return options?.actor ?? { _id: id, role: "user" }; if (id === "publishers:owner-personal") return personalPublisher; return null; }), @@ -8086,6 +8087,37 @@ describe("softDeletePackageInternal", () => { // No release patches should have been made. expect(patch).not.toHaveBeenCalledWith("packageReleases:demo-1", expect.anything()); }); + + it("keeps manually moderated ban-hidden packages out of unban restore scope", async () => { + vi.spyOn(Date, "now").mockReturnValue(2_000); + const { ctx, patch } = makeSoftDeleteCtx({ + actor: { _id: "users:owner", role: "moderator" }, + pkg: makePackageDoc({ + ownerUserId: "users:owner", + ownerPublisherId: "publishers:owner-personal", + softDeletedAt: 1_000, + softDeletedReason: "user.banned", + softDeletedBy: "users:first-moderator", + softDeletedByRole: "moderator", + }), + }); + + const result = await softDeletePackageInternalHandler(ctx as never, { + userId: "users:owner", + name: "demo-plugin", + }); + + expect(result).toMatchObject({ ok: true, alreadyDeleted: true }); + expect(patch).toHaveBeenCalledWith( + "packages:demo", + expect.objectContaining({ + softDeletedAt: 2_000, + softDeletedReason: undefined, + softDeletedBy: "users:owner", + softDeletedByRole: "moderator", + }), + ); + }); }); describe("restorePackageInternal", () => { diff --git a/convex/packages.ts b/convex/packages.ts index d1655205ae..9a3f274143 100644 --- a/convex/packages.ts +++ b/convex/packages.ts @@ -2738,12 +2738,28 @@ async function softDeletePackageDoc( source: "cli" | "dashboard"; }, ) { + const now = params.deletedAt ?? Date.now(); if (pkg.softDeletedAt) { if (params.actorRole === "admin" || params.actorRole === "moderator") { - await ctx.db.patch(pkg._id, { + const packagePatch: Partial> = { softDeletedBy: params.actorUserId, softDeletedByRole: params.actorRole, - updatedAt: Date.now(), + updatedAt: now, + }; + if (pkg.softDeletedReason === "user.banned" && params.reason !== "user.banned") { + packagePatch.softDeletedAt = now; + packagePatch.softDeletedReason = params.reason; + } + const nextPackage: Doc<"packages"> = { ...pkg, ...packagePatch }; + await ctx.db.patch(pkg._id, packagePatch); + const deleteOwner = await getOwnerPublisher(ctx, { + ownerPublisherId: pkg.ownerPublisherId, + ownerUserId: pkg.ownerUserId, + }); + await upsertPackageSearchDigest(ctx, { + ...extractPackageDigestFields(nextPackage), + ownerHandle: deleteOwner?.handle ?? "", + ownerKind: deleteOwner?.kind, }); } return { @@ -2754,7 +2770,6 @@ async function softDeletePackageDoc( }; } - const now = params.deletedAt ?? Date.now(); const releases = await ctx.db .query("packageReleases") .withIndex("by_package", (q) => q.eq("packageId", pkg._id)) From fdc00f23b28183fccd168f5218049effcde216a7 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Wed, 27 May 2026 13:33:21 -0500 Subject: [PATCH 09/17] fix: cover personal publisher package sanctions --- convex/packages.public.test.ts | 181 +++++++++++++++++++++++++++++++-- convex/packages.ts | 134 ++++++++++++++++++------ 2 files changed, 276 insertions(+), 39 deletions(-) diff --git a/convex/packages.public.test.ts b/convex/packages.public.test.ts index 134990b0fb..4872fc12ae 100644 --- a/convex/packages.public.test.ts +++ b/convex/packages.public.test.ts @@ -560,19 +560,31 @@ const applyBanToOwnedPackagesBatchInternalHandler = ( deletedBy: string; deletedByRole: "admin" | "moderator" | "user"; cursor?: string; + scope?: "ownerUserId" | "personalPublisher"; }, { deletedCount: number; revokedTokenCount: number; scheduled: boolean } > )._handler; const restoreOwnedPackagesForUnbanBatchInternalHandler = ( restoreOwnedPackagesForUnbanBatchInternal as unknown as WrappedHandler< - { actorUserId: string; ownerUserId: string; bannedAt: number; cursor?: string }, + { + actorUserId: string; + ownerUserId: string; + bannedAt: number; + cursor?: string; + scope?: "ownerUserId" | "personalPublisher"; + }, { restoredCount: number; scheduled: boolean; stale?: true } > )._handler; const applyAccountDeletionToOwnedPackagesBatchInternalHandler = ( applyAccountDeletionToOwnedPackagesBatchInternal as unknown as WrappedHandler< - { ownerUserId: string; deletedAt: number; cursor?: string }, + { + ownerUserId: string; + deletedAt: number; + cursor?: string; + scope?: "ownerUserId" | "personalPublisher"; + }, { deletedCount: number; revokedTokenCount: number; scheduled: boolean } > )._handler; @@ -7557,6 +7569,7 @@ function makeOwnedPackageBatchCtx(options?: { owner?: Record | null; packageTokens?: Array>; releases?: Array>; + publisherPackages?: Array>; publishers?: Record | null>; isDone?: boolean; continueCursor?: string; @@ -7606,10 +7619,13 @@ function makeOwnedPackageBatchCtx(options?: { query: vi.fn((table: string) => { if (table === "packages") { return { - withIndex: vi.fn(() => ({ + withIndex: vi.fn((index: string) => ({ order: vi.fn(() => ({ paginate: vi.fn().mockResolvedValue({ - page: [pkg], + page: + index === "by_owner_publisher" + ? (options?.publisherPackages ?? []) + : [pkg], isDone: options?.isDone ?? true, continueCursor: options?.continueCursor ?? "", }), @@ -7692,6 +7708,55 @@ describe("owned package sanction batches", () => { expect(patch).toHaveBeenCalledWith("packagePublishTokens:demo", { revokedAt: 1_000 }); }); + it("soft-deletes packages owned through the user's personal publisher", async () => { + const personalPublisherPackage = makePackageDoc({ + _id: "packages:personal-publisher", + ownerUserId: "users:publishing-actor", + ownerPublisherId: "publishers:personal", + }); + const { ctx, patch } = makeOwnedPackageBatchCtx({ + owner: { + _id: "users:owner", + deletedAt: 1_000, + deactivatedAt: undefined, + personalPublisherId: "publishers:personal", + }, + publisherPackages: [personalPublisherPackage], + packageTokens: [ + { + _id: "packagePublishTokens:personal-publisher", + packageId: "packages:personal-publisher", + version: "1.0.1", + revokedAt: undefined, + }, + ], + publishers: { + "publishers:personal": { + _id: "publishers:personal", + kind: "user", + linkedUserId: "users:owner", + }, + }, + }); + + const result = await applyBanToOwnedPackagesBatchInternalHandler(ctx as never, { + ownerUserId: "users:owner", + bannedAt: 1_000, + deletedBy: "users:moderator", + deletedByRole: "moderator", + scope: "personalPublisher", + }); + + expect(result).toMatchObject({ deletedCount: 1, revokedTokenCount: 1, scheduled: false }); + expect(patch).toHaveBeenCalledWith( + "packages:personal-publisher", + expect.objectContaining({ softDeletedAt: 1_000, softDeletedReason: "user.banned" }), + ); + expect(patch).toHaveBeenCalledWith("packagePublishTokens:personal-publisher", { + revokedAt: 1_000, + }); + }); + it("stops stale package ban pages when the owner has already been unbanned", async () => { const { ctx, patch } = makeOwnedPackageBatchCtx(); @@ -7896,7 +7961,7 @@ describe("owned package sanction batches", () => { deletedByRole: "moderator", }); - expect(result).toMatchObject({ deletedCount: 0, revokedTokenCount: 0, scheduled: false }); + expect(result).toMatchObject({ deletedCount: 0, revokedTokenCount: 0, scheduled: true }); expect(patch).not.toHaveBeenCalledWith("packages:demo", expect.anything()); expect(patch).not.toHaveBeenCalledWith("packagePublishTokens:demo", expect.anything()); }); @@ -7958,6 +8023,64 @@ describe("owned package sanction batches", () => { expect(patch).not.toHaveBeenCalledWith("packageReleases:malicious", expect.anything()); }); + it("restores ban-hidden packages owned through the user's personal publisher", async () => { + const personalPublisherPackage = makePackageDoc({ + _id: "packages:personal-publisher", + ownerUserId: "users:publishing-actor", + ownerPublisherId: "publishers:personal", + softDeletedAt: 1_000, + softDeletedReason: "user.banned", + softDeletedByRole: "moderator", + latestReleaseId: "packageReleases:personal-publisher-1", + tags: { latest: "packageReleases:personal-publisher-1" }, + }); + const { ctx, patch } = makeOwnedPackageBatchCtx({ + owner: { + _id: "users:owner", + deletedAt: undefined, + deactivatedAt: undefined, + personalPublisherId: "publishers:personal", + }, + publisherPackages: [personalPublisherPackage], + publishers: { + "publishers:personal": { + _id: "publishers:personal", + kind: "user", + linkedUserId: "users:owner", + }, + }, + releases: [ + makeReleaseDoc({ + _id: "packageReleases:personal-publisher-1", + packageId: "packages:personal-publisher", + softDeletedAt: 1_000, + distTags: ["latest"], + version: "1.0.0", + changelog: "", + compatibility: null, + capabilities: null, + verification: null, + }), + ], + }); + + const result = await restoreOwnedPackagesForUnbanBatchInternalHandler(ctx as never, { + actorUserId: "users:admin", + ownerUserId: "users:owner", + bannedAt: 1_000, + scope: "personalPublisher", + }); + + expect(result).toMatchObject({ restoredCount: 1, scheduled: false }); + expect(patch).toHaveBeenCalledWith( + "packages:personal-publisher", + expect.objectContaining({ + softDeletedAt: undefined, + softDeletedReason: undefined, + }), + ); + }); + it("stops stale package restore batches when the owner is banned again", async () => { const { ctx, patch } = makeOwnedPackageBatchCtx({ owner: { _id: "users:owner", deletedAt: 2_000, deactivatedAt: undefined }, @@ -7997,6 +8120,52 @@ describe("owned package sanction batches", () => { ); }); + it("marks account-deleted packages owned through the user's personal publisher", async () => { + const personalPublisherPackage = makePackageDoc({ + _id: "packages:personal-publisher", + ownerUserId: "users:publishing-actor", + ownerPublisherId: "publishers:personal", + }); + const { ctx, patch } = makeOwnedPackageBatchCtx({ + owner: { + _id: "users:owner", + deactivatedAt: 3_000, + personalPublisherId: "publishers:personal", + }, + publisherPackages: [personalPublisherPackage], + packageTokens: [ + { + _id: "packagePublishTokens:personal-publisher", + packageId: "packages:personal-publisher", + version: "1.0.1", + revokedAt: undefined, + }, + ], + publishers: { + "publishers:personal": { + _id: "publishers:personal", + kind: "user", + linkedUserId: "users:owner", + }, + }, + }); + + const result = await applyAccountDeletionToOwnedPackagesBatchInternalHandler(ctx as never, { + ownerUserId: "users:owner", + deletedAt: 3_000, + scope: "personalPublisher", + }); + + expect(result).toMatchObject({ deletedCount: 1, revokedTokenCount: 1, scheduled: false }); + expect(patch).toHaveBeenCalledWith( + "packages:personal-publisher", + expect.objectContaining({ + softDeletedAt: 3_000, + softDeletedReason: "user.deactivated", + }), + ); + }); + it("does not delete org-owned packages when deleting a member account", async () => { const { ctx, patch } = makeOwnedPackageBatchCtx({ owner: { @@ -8019,7 +8188,7 @@ describe("owned package sanction batches", () => { deletedAt: 3_000, }); - expect(result).toMatchObject({ deletedCount: 0, revokedTokenCount: 0, scheduled: false }); + expect(result).toMatchObject({ deletedCount: 0, revokedTokenCount: 0, scheduled: true }); expect(patch).not.toHaveBeenCalledWith("packages:demo", expect.anything()); expect(patch).not.toHaveBeenCalledWith("packagePublishTokens:demo", expect.anything()); }); diff --git a/convex/packages.ts b/convex/packages.ts index 9a3f274143..bb1fef3602 100644 --- a/convex/packages.ts +++ b/convex/packages.ts @@ -286,6 +286,10 @@ const packageAutobanRemediationInternalRefs = internal as unknown as { type DbReaderCtx = Pick; const BAN_USER_PACKAGES_BATCH_SIZE = 25; type PackageSoftDeletedReason = "user.banned" | "user.deactivated"; +const ownedPackageScanScopeValidator = v.optional( + v.union(v.literal("ownerUserId"), v.literal("personalPublisher")), +); +type OwnedPackageScanScope = "ownerUserId" | "personalPublisher"; type PackagePublishActor = | { kind: "user"; @@ -3052,6 +3056,46 @@ async function isPackageOwnedByPersonalUser( return ownerPublisher?.kind === "user" && ownerPublisher.linkedUserId === owner._id; } +function getOwnedPackageScanScope(args: { scope?: OwnedPackageScanScope }) { + return args.scope ?? "ownerUserId"; +} + +function shouldSkipOwnedPackageScanRow( + pkg: Pick, "ownerUserId">, + args: { ownerUserId: Id<"users">; scope?: OwnedPackageScanScope }, +) { + return getOwnedPackageScanScope(args) === "personalPublisher" && pkg.ownerUserId === args.ownerUserId; +} + +function scheduleNextOwnedPackageScanBatch( + ctx: Pick, + fn: unknown, + args: { ownerUserId: Id<"users">; cursor?: string; scope?: OwnedPackageScanScope } & Record< + string, + unknown + >, + owner: Pick, "personalPublisherId">, + isDone: boolean, + continueCursor: string | null, +) { + if (!isDone) { + void ctx.scheduler.runAfter(0, fn as never, { + ...args, + cursor: continueCursor ?? undefined, + } as never); + return true; + } + if (getOwnedPackageScanScope(args) === "ownerUserId" && owner.personalPublisherId) { + void ctx.scheduler.runAfter(0, fn as never, { + ...args, + scope: "personalPublisher", + cursor: undefined, + } as never); + return true; + } + return false; +} + export const applyBanToOwnedPackagesBatchInternal = internalMutation({ args: { ownerUserId: v.id("users"), @@ -3059,6 +3103,7 @@ export const applyBanToOwnedPackagesBatchInternal = internalMutation({ deletedBy: v.id("users"), deletedByRole: v.union(v.literal("admin"), v.literal("moderator"), v.literal("user")), cursor: v.optional(v.string()), + scope: ownedPackageScanScopeValidator, }, handler: async (ctx, args) => { const owner = await ctx.db.get(args.ownerUserId); @@ -3073,18 +3118,24 @@ export const applyBanToOwnedPackagesBatchInternal = internalMutation({ }; } - const { page, isDone, continueCursor } = await ctx.db - .query("packages") - .withIndex("by_owner", (q) => q.eq("ownerUserId", args.ownerUserId)) - .order("desc") - .paginate({ - cursor: args.cursor ?? null, - numItems: BAN_USER_PACKAGES_BATCH_SIZE, - }); + const scope = getOwnedPackageScanScope(args); + const packageQuery = + scope === "personalPublisher" && owner.personalPublisherId + ? ctx.db + .query("packages") + .withIndex("by_owner_publisher", (q) => + q.eq("ownerPublisherId", owner.personalPublisherId), + ) + : ctx.db.query("packages").withIndex("by_owner", (q) => q.eq("ownerUserId", args.ownerUserId)); + const { page, isDone, continueCursor } = await packageQuery.order("desc").paginate({ + cursor: args.cursor ?? null, + numItems: BAN_USER_PACKAGES_BATCH_SIZE, + }); let deletedCount = 0; let revokedTokenCount = 0; for (const pkg of page) { + if (shouldSkipOwnedPackageScanRow(pkg, args)) continue; if (!(await isPackageOwnedByPersonalUser(ctx, pkg, owner))) continue; revokedTokenCount += await revokePackagePublishTokensForPackage(ctx, pkg._id, args.bannedAt); if (pkg.softDeletedAt) { @@ -3130,15 +3181,16 @@ export const applyBanToOwnedPackagesBatchInternal = internalMutation({ deletedCount += 1; } - scheduleNextBatchIfNeeded( - ctx.scheduler, + const scheduled = scheduleNextOwnedPackageScanBatch( + ctx, internal.packages.applyBanToOwnedPackagesBatchInternal, args, + owner, isDone, continueCursor, ); - return { ok: true as const, deletedCount, revokedTokenCount, scheduled: !isDone }; + return { ok: true as const, deletedCount, revokedTokenCount, scheduled }; }, }); @@ -3148,6 +3200,7 @@ export const restoreOwnedPackagesForUnbanBatchInternal = internalMutation({ ownerUserId: v.id("users"), bannedAt: v.number(), cursor: v.optional(v.string()), + scope: ownedPackageScanScopeValidator, }, handler: async (ctx, args) => { const actor = await ctx.db.get(args.actorUserId); @@ -3159,17 +3212,23 @@ export const restoreOwnedPackagesForUnbanBatchInternal = internalMutation({ return { ok: true as const, restoredCount: 0, scheduled: false, stale: true as const }; } - const { page, isDone, continueCursor } = await ctx.db - .query("packages") - .withIndex("by_owner", (q) => q.eq("ownerUserId", args.ownerUserId)) - .order("desc") - .paginate({ - cursor: args.cursor ?? null, - numItems: BAN_USER_PACKAGES_BATCH_SIZE, - }); + const scope = getOwnedPackageScanScope(args); + const packageQuery = + scope === "personalPublisher" && owner.personalPublisherId + ? ctx.db + .query("packages") + .withIndex("by_owner_publisher", (q) => + q.eq("ownerPublisherId", owner.personalPublisherId), + ) + : ctx.db.query("packages").withIndex("by_owner", (q) => q.eq("ownerUserId", args.ownerUserId)); + const { page, isDone, continueCursor } = await packageQuery.order("desc").paginate({ + cursor: args.cursor ?? null, + numItems: BAN_USER_PACKAGES_BATCH_SIZE, + }); let restoredCount = 0; for (const pkg of page) { + if (shouldSkipOwnedPackageScanRow(pkg, args)) continue; if (!(await isPackageOwnedByPersonalUser(ctx, pkg, owner))) continue; if ( !pkg.softDeletedAt || @@ -3189,15 +3248,16 @@ export const restoreOwnedPackagesForUnbanBatchInternal = internalMutation({ restoredCount += 1; } - scheduleNextBatchIfNeeded( - ctx.scheduler, + const scheduled = scheduleNextOwnedPackageScanBatch( + ctx, internal.packages.restoreOwnedPackagesForUnbanBatchInternal, args, + owner, isDone, continueCursor, ); - return { ok: true as const, restoredCount, scheduled: !isDone }; + return { ok: true as const, restoredCount, scheduled }; }, }); @@ -3206,6 +3266,7 @@ export const applyAccountDeletionToOwnedPackagesBatchInternal = internalMutation ownerUserId: v.id("users"), deletedAt: v.number(), cursor: v.optional(v.string()), + scope: ownedPackageScanScopeValidator, }, handler: async (ctx, args) => { const owner = await ctx.db.get(args.ownerUserId); @@ -3219,18 +3280,24 @@ export const applyAccountDeletionToOwnedPackagesBatchInternal = internalMutation }; } - const { page, isDone, continueCursor } = await ctx.db - .query("packages") - .withIndex("by_owner", (q) => q.eq("ownerUserId", args.ownerUserId)) - .order("desc") - .paginate({ - cursor: args.cursor ?? null, - numItems: BAN_USER_PACKAGES_BATCH_SIZE, - }); + const scope = getOwnedPackageScanScope(args); + const packageQuery = + scope === "personalPublisher" && owner.personalPublisherId + ? ctx.db + .query("packages") + .withIndex("by_owner_publisher", (q) => + q.eq("ownerPublisherId", owner.personalPublisherId), + ) + : ctx.db.query("packages").withIndex("by_owner", (q) => q.eq("ownerUserId", args.ownerUserId)); + const { page, isDone, continueCursor } = await packageQuery.order("desc").paginate({ + cursor: args.cursor ?? null, + numItems: BAN_USER_PACKAGES_BATCH_SIZE, + }); let deletedCount = 0; let revokedTokenCount = 0; for (const pkg of page) { + if (shouldSkipOwnedPackageScanRow(pkg, args)) continue; if (!(await isPackageOwnedByPersonalUser(ctx, pkg, owner))) continue; revokedTokenCount += await revokePackagePublishTokensForPackage(ctx, pkg._id, args.deletedAt); if (pkg.softDeletedAt) continue; @@ -3245,15 +3312,16 @@ export const applyAccountDeletionToOwnedPackagesBatchInternal = internalMutation deletedCount += 1; } - scheduleNextBatchIfNeeded( - ctx.scheduler, + const scheduled = scheduleNextOwnedPackageScanBatch( + ctx, internal.packages.applyAccountDeletionToOwnedPackagesBatchInternal, args, + owner, isDone, continueCursor, ); - return { ok: true as const, deletedCount, revokedTokenCount, scheduled: !isDone }; + return { ok: true as const, deletedCount, revokedTokenCount, scheduled }; }, }); From c9969f5d08764b8d58b75eaf224d7c0e0507974c Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Wed, 27 May 2026 13:45:46 -0500 Subject: [PATCH 10/17] fix: restore personal publisher packages in autoban remediation --- convex/autobanRemediation.test.ts | 197 +++++++++++++++++++++++++++++- convex/packages.ts | 76 ++++++++---- convex/users.ts | 76 ++++++++---- 3 files changed, 298 insertions(+), 51 deletions(-) diff --git a/convex/autobanRemediation.test.ts b/convex/autobanRemediation.test.ts index 6d6bff8265..a9a747af0a 100644 --- a/convex/autobanRemediation.test.ts +++ b/convex/autobanRemediation.test.ts @@ -18,7 +18,8 @@ const { recomputeLatestSkillModerationInternal, restoreOwnedSkillsForAutobanRemediationBatchInternal, } = await import("./skills"); -const { remediateAutobansInternal } = await import("./users"); +const { listRestorableAutobanPackageCandidatesPageInternal, remediateAutobansInternal } = + await import("./users"); type WrappedHandler = { _handler: (ctx: unknown, args: TArgs) => Promise; @@ -44,8 +45,20 @@ const restorePackagesHandler = ( ownerUserId: string; bannedAt: number; cursor?: string; + scope?: "ownerUserId" | "personalPublisher"; }> )._handler; +const listPackageCandidatesHandler = ( + listRestorableAutobanPackageCandidatesPageInternal as unknown as WrappedHandler< + { + ownerUserId: string; + bannedAt: number; + cursor?: string; + scope?: "ownerUserId" | "personalPublisher"; + }, + { packageIds: string[]; isDone: boolean; continueCursor: string | null } + > +)._handler; const remediateAutobansHandler = ( remediateAutobansInternal as unknown as WrappedHandler< { @@ -1055,6 +1068,188 @@ describe("autoban remediation package restore", () => { ); }); + it("restores packages owned through the user's personal publisher", async () => { + const bannedAt = 1778569308754; + const patch = vi.fn(); + const insert = vi.fn(); + const scheduler = { runAfter: vi.fn() }; + const query = vi.fn((table: string) => { + if (table === "packages") { + return { + withIndex: (name: string) => { + expect(name).toBe("by_owner_publisher"); + return { + order: () => ({ + paginate: vi.fn(async () => ({ + page: [ + { + _id: "packages:personal", + name: "@scope/personal", + normalizedName: "@scope/personal", + displayName: "@scope/personal", + family: "external-code-plugin", + ownerUserId: "users:publishing-actor", + ownerPublisherId: "publishers:personal", + softDeletedAt: bannedAt, + scanStatus: "clean", + tags: { latest: "packageReleases:good" }, + latestReleaseId: "packageReleases:good", + stats: {}, + compatibility: {}, + capabilities: {}, + verification: {}, + isOfficial: false, + createdAt: 1, + updatedAt: 1, + }, + ], + isDone: true, + continueCursor: null, + })), + }), + }; + }, + }; + } + if (table === "packageReleases") { + return { + withIndex: (name: string) => { + expect(name).toBe("by_package"); + return { + collect: vi.fn(async () => [ + { + _id: "packageReleases:good", + packageId: "packages:personal", + version: "1.0.0", + changelog: "", + integritySha256: "good-sha", + compatibility: {}, + capabilities: {}, + verification: {}, + softDeletedAt: bannedAt, + llmAnalysis: { status: "clean" }, + distTags: ["latest"], + createdAt: 1, + }, + ]), + }; + }, + }; + } + if ( + table === "packageSearchDigest" || + table === "packageCapabilitySearchDigest" || + table === "packagePluginCategorySearchDigest" + ) { + return { + withIndex: () => ({ + unique: vi.fn(async () => null), + collect: vi.fn(async () => []), + }), + }; + } + throw new Error(`Unexpected table ${table}`); + }); + + const result = (await restorePackagesHandler( + { + db: { + query, + patch, + insert, + get: vi.fn(async (id: string) => { + if (id === "users:target") { + return { + _id: "users:target", + role: "user", + personalPublisherId: "publishers:personal", + }; + } + if (id === "publishers:personal") { + return { _id: id, kind: "user", linkedUserId: "users:target" }; + } + return null; + }), + replace: vi.fn(), + delete: vi.fn(), + normalizeId: vi.fn(() => null), + }, + scheduler, + } as never, + { + actorUserId: "users:admin", + ownerUserId: "users:target", + bannedAt, + scope: "personalPublisher", + }, + )) as { restoredCount: number; restoredReleases: number; skippedMalicious: number }; + + expect(result).toMatchObject({ + restoredCount: 1, + restoredReleases: 1, + skippedMalicious: 0, + }); + expect(patch).toHaveBeenCalledWith("packageReleases:good", { softDeletedAt: undefined }); + expect(patch).toHaveBeenCalledWith( + "packages:personal", + expect.objectContaining({ softDeletedAt: undefined }), + ); + }); + + it("lists restorable personal-publisher package candidates for dry-run counts", async () => { + const bannedAt = 1778569308754; + const result = await listPackageCandidatesHandler( + { + db: { + get: vi.fn(async (id: string) => + id === "users:target" ? { _id: id, personalPublisherId: "publishers:personal" } : null, + ), + query: vi.fn((table: string) => { + expect(table).toBe("packages"); + return { + withIndex: (name: string) => { + expect(name).toBe("by_owner_publisher"); + return { + order: () => ({ + paginate: vi.fn(async () => ({ + page: [ + { + _id: "packages:legacy-duplicate", + ownerUserId: "users:target", + softDeletedAt: bannedAt, + scanStatus: "clean", + }, + { + _id: "packages:personal", + ownerUserId: "users:publishing-actor", + softDeletedAt: bannedAt, + scanStatus: "clean", + }, + ], + isDone: true, + continueCursor: null, + })), + }), + }; + }, + }; + }), + }, + } as never, + { + ownerUserId: "users:target", + bannedAt, + scope: "personalPublisher", + }, + ); + + expect(result).toEqual({ + packageIds: ["packages:personal"], + isDone: true, + continueCursor: null, + }); + }); + it("skips timestamp-matched packages when no non-malicious release can be selected", async () => { const bannedAt = 1778569308754; const patch = vi.fn(); diff --git a/convex/packages.ts b/convex/packages.ts index bb1fef3602..8f934d1e28 100644 --- a/convex/packages.ts +++ b/convex/packages.ts @@ -43,7 +43,6 @@ import { readArtifactReportStatus, appendPackageModerationEventLog, } from "./lib/artifactModeration"; -import { scheduleNextBatchIfNeeded } from "./lib/batching"; import { normalizeClawScanNoteForWrite } from "./lib/clawScanNote"; import { requireGitHubAccountAge } from "./lib/githubAccount"; import { normalizeGitHubRepository } from "./lib/githubActionsOidc"; @@ -3064,7 +3063,9 @@ function shouldSkipOwnedPackageScanRow( pkg: Pick, "ownerUserId">, args: { ownerUserId: Id<"users">; scope?: OwnedPackageScanScope }, ) { - return getOwnedPackageScanScope(args) === "personalPublisher" && pkg.ownerUserId === args.ownerUserId; + return ( + getOwnedPackageScanScope(args) === "personalPublisher" && pkg.ownerUserId === args.ownerUserId + ); } function scheduleNextOwnedPackageScanBatch( @@ -3079,18 +3080,26 @@ function scheduleNextOwnedPackageScanBatch( continueCursor: string | null, ) { if (!isDone) { - void ctx.scheduler.runAfter(0, fn as never, { - ...args, - cursor: continueCursor ?? undefined, - } as never); + void ctx.scheduler.runAfter( + 0, + fn as never, + { + ...args, + cursor: continueCursor ?? undefined, + } as never, + ); return true; } if (getOwnedPackageScanScope(args) === "ownerUserId" && owner.personalPublisherId) { - void ctx.scheduler.runAfter(0, fn as never, { - ...args, - scope: "personalPublisher", - cursor: undefined, - } as never); + void ctx.scheduler.runAfter( + 0, + fn as never, + { + ...args, + scope: "personalPublisher", + cursor: undefined, + } as never, + ); return true; } return false; @@ -3126,7 +3135,9 @@ export const applyBanToOwnedPackagesBatchInternal = internalMutation({ .withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", owner.personalPublisherId), ) - : ctx.db.query("packages").withIndex("by_owner", (q) => q.eq("ownerUserId", args.ownerUserId)); + : ctx.db + .query("packages") + .withIndex("by_owner", (q) => q.eq("ownerUserId", args.ownerUserId)); const { page, isDone, continueCursor } = await packageQuery.order("desc").paginate({ cursor: args.cursor ?? null, numItems: BAN_USER_PACKAGES_BATCH_SIZE, @@ -3220,7 +3231,9 @@ export const restoreOwnedPackagesForUnbanBatchInternal = internalMutation({ .withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", owner.personalPublisherId), ) - : ctx.db.query("packages").withIndex("by_owner", (q) => q.eq("ownerUserId", args.ownerUserId)); + : ctx.db + .query("packages") + .withIndex("by_owner", (q) => q.eq("ownerUserId", args.ownerUserId)); const { page, isDone, continueCursor } = await packageQuery.order("desc").paginate({ cursor: args.cursor ?? null, numItems: BAN_USER_PACKAGES_BATCH_SIZE, @@ -3288,7 +3301,9 @@ export const applyAccountDeletionToOwnedPackagesBatchInternal = internalMutation .withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", owner.personalPublisherId), ) - : ctx.db.query("packages").withIndex("by_owner", (q) => q.eq("ownerUserId", args.ownerUserId)); + : ctx.db + .query("packages") + .withIndex("by_owner", (q) => q.eq("ownerUserId", args.ownerUserId)); const { page, isDone, continueCursor } = await packageQuery.order("desc").paginate({ cursor: args.cursor ?? null, numItems: BAN_USER_PACKAGES_BATCH_SIZE, @@ -3399,6 +3414,7 @@ export const restoreOwnedPackagesForAutobanRemediationBatchInternal = internalMu ownerUserId: v.id("users"), bannedAt: v.number(), cursor: v.optional(v.string()), + scope: ownedPackageScanScopeValidator, }, handler: async (ctx, args) => { const owner = await ctx.db.get(args.ownerUserId); @@ -3413,19 +3429,28 @@ export const restoreOwnedPackagesForAutobanRemediationBatchInternal = internalMu }; } - const { page, isDone, continueCursor } = await ctx.db - .query("packages") - .withIndex("by_owner", (q) => q.eq("ownerUserId", args.ownerUserId)) - .order("desc") - .paginate({ - cursor: args.cursor ?? null, - numItems: 25, - }); + const scope = getOwnedPackageScanScope(args); + const packageQuery = + scope === "personalPublisher" && owner.personalPublisherId + ? ctx.db + .query("packages") + .withIndex("by_owner_publisher", (q) => + q.eq("ownerPublisherId", owner.personalPublisherId), + ) + : ctx.db + .query("packages") + .withIndex("by_owner", (q) => q.eq("ownerUserId", args.ownerUserId)); + const { page, isDone, continueCursor } = await packageQuery.order("desc").paginate({ + cursor: args.cursor ?? null, + numItems: BAN_USER_PACKAGES_BATCH_SIZE, + }); let restoredCount = 0; let restoredReleases = 0; let skippedMalicious = 0; for (const pkg of page) { + if (shouldSkipOwnedPackageScanRow(pkg, args)) continue; + if (!(await isPackageOwnedByPersonalUser(ctx, pkg, owner))) continue; if (!pkg.softDeletedAt || pkg.softDeletedAt !== args.bannedAt) continue; if (pkg.scanStatus === "malicious") { skippedMalicious += 1; @@ -3534,11 +3559,12 @@ export const restoreOwnedPackagesForAutobanRemediationBatchInternal = internalMu restoredCount += 1; } - scheduleNextBatchIfNeeded( - ctx.scheduler, + const scheduled = scheduleNextOwnedPackageScanBatch( + ctx, packageAutobanRemediationInternalRefs.packages .restoreOwnedPackagesForAutobanRemediationBatchInternal, args, + owner, isDone, continueCursor, ); @@ -3548,7 +3574,7 @@ export const restoreOwnedPackagesForAutobanRemediationBatchInternal = internalMu restoredCount, restoredReleases, skippedMalicious, - scheduled: !isDone, + scheduled, }; }, }); diff --git a/convex/users.ts b/convex/users.ts index 5d899f72f1..e894027f3c 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -41,6 +41,10 @@ const DEFAULT_AUTOBAN_REMEDIATION_REASON = const MAX_AUTOBAN_REMEDIATION_LIMIT = 100; const AUTOBAN_AUDIT_MATCH_WINDOW_MS = 5_000; const AUTOBAN_REMEDIATION_COUNT_PAGE_SIZE = 100; +const autobanPackageScanScopeValidator = v.optional( + v.union(v.literal("ownerUserId"), v.literal("personalPublisher")), +); +type AutobanPackageScanScope = "ownerUserId" | "personalPublisher"; const autobanRemediationInternalRefs = internal as unknown as { users: { countRestorableAutobanSkillsPageInternal: unknown; @@ -1387,24 +1391,32 @@ async function countRestorableAutobanPackages( bannedAt: number, ) { let count = 0; - let cursor: string | null = null; - let isDone = false; + const owner = await ctx.db.get(ownerUserId); + const scopes: AutobanPackageScanScope[] = owner?.personalPublisherId + ? ["ownerUserId", "personalPublisher"] + : ["ownerUserId"]; - while (!isDone) { - const result: AutobanRemediationPackageCandidatePage = await runAutobanRemediationQueryRef( - ctx, - autobanRemediationInternalRefs.users.listRestorableAutobanPackageCandidatesPageInternal, - { - ownerUserId, - bannedAt, - cursor: cursor ?? undefined, - }, - ); - for (const packageId of result.packageIds) { - if (await hasRestorableAutobanPackageRelease(ctx, packageId, bannedAt)) count += 1; + for (const scope of scopes) { + let cursor: string | null = null; + let isDone = false; + + while (!isDone) { + const result: AutobanRemediationPackageCandidatePage = await runAutobanRemediationQueryRef( + ctx, + autobanRemediationInternalRefs.users.listRestorableAutobanPackageCandidatesPageInternal, + { + ownerUserId, + bannedAt, + cursor: cursor ?? undefined, + scope, + }, + ); + for (const packageId of result.packageIds) { + if (await hasRestorableAutobanPackageRelease(ctx, packageId, bannedAt)) count += 1; + } + isDone = result.isDone; + cursor = result.continueCursor; } - isDone = result.isDone; - cursor = result.continueCursor; } return count; @@ -1471,20 +1483,34 @@ export const listRestorableAutobanPackageCandidatesPageInternal = internalQuery( ownerUserId: v.id("users"), bannedAt: v.number(), cursor: v.optional(v.string()), + scope: autobanPackageScanScopeValidator, }, handler: async (ctx, args) => { - const result = await ctx.db - .query("packages") - .withIndex("by_owner", (q) => q.eq("ownerUserId", args.ownerUserId)) - .order("desc") - .paginate({ - cursor: args.cursor ?? null, - numItems: AUTOBAN_REMEDIATION_COUNT_PAGE_SIZE, - }); + const owner = await ctx.db.get(args.ownerUserId); + const scope = args.scope ?? "ownerUserId"; + const packageQuery = + scope === "personalPublisher" && owner?.personalPublisherId + ? ctx.db + .query("packages") + .withIndex("by_owner_publisher", (q) => + q.eq("ownerPublisherId", owner.personalPublisherId), + ) + : ctx.db + .query("packages") + .withIndex("by_owner", (q) => q.eq("ownerUserId", args.ownerUserId)); + const result = await packageQuery.order("desc").paginate({ + cursor: args.cursor ?? null, + numItems: AUTOBAN_REMEDIATION_COUNT_PAGE_SIZE, + }); return { packageIds: result.page - .filter((pkg) => pkg.softDeletedAt === args.bannedAt && pkg.scanStatus !== "malicious") + .filter( + (pkg) => + !(scope === "personalPublisher" && pkg.ownerUserId === args.ownerUserId) && + pkg.softDeletedAt === args.bannedAt && + pkg.scanStatus !== "malicious", + ) .map((pkg) => pkg._id), isDone: result.isDone, continueCursor: result.continueCursor, From 65867bbfbe72cd03993eafaae693ce1a45c1aa76 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Wed, 27 May 2026 13:58:46 -0500 Subject: [PATCH 11/17] fix: bound package publish token revocation batches --- convex/packages.public.test.ts | 41 +++++++++++++++++++++++++++++----- convex/packages.ts | 40 ++++++++++++++++++++++++++------- convex/schema.ts | 3 ++- 3 files changed, 70 insertions(+), 14 deletions(-) diff --git a/convex/packages.public.test.ts b/convex/packages.public.test.ts index 4872fc12ae..51428d149a 100644 --- a/convex/packages.public.test.ts +++ b/convex/packages.public.test.ts @@ -7623,9 +7623,7 @@ function makeOwnedPackageBatchCtx(options?: { order: vi.fn(() => ({ paginate: vi.fn().mockResolvedValue({ page: - index === "by_owner_publisher" - ? (options?.publisherPackages ?? []) - : [pkg], + index === "by_owner_publisher" ? (options?.publisherPackages ?? []) : [pkg], isDone: options?.isDone ?? true, continueCursor: options?.continueCursor ?? "", }), @@ -7636,7 +7634,9 @@ function makeOwnedPackageBatchCtx(options?: { if (table === "packagePublishTokens") { return { withIndex: vi.fn(() => ({ - collect: vi.fn().mockResolvedValue(packageTokens), + order: vi.fn(() => ({ + take: vi.fn(async (limit: number) => packageTokens.slice(0, limit)), + })), })), }; } @@ -7708,6 +7708,34 @@ describe("owned package sanction batches", () => { expect(patch).toHaveBeenCalledWith("packagePublishTokens:demo", { revokedAt: 1_000 }); }); + it("bounds package publish token revocation during owned package bans", async () => { + const packageTokens = Array.from({ length: 26 }, (_, index) => ({ + _id: `packagePublishTokens:active-${index}`, + packageId: "packages:demo", + version: "1.0.1", + revokedAt: undefined, + })); + const { ctx, patch, runAfter } = makeOwnedPackageBatchCtx({ + owner: { _id: "users:owner", deletedAt: 1_000, deactivatedAt: undefined }, + packageTokens, + }); + + const result = await applyBanToOwnedPackagesBatchInternalHandler(ctx as never, { + ownerUserId: "users:owner", + bannedAt: 1_000, + deletedBy: "users:moderator", + deletedByRole: "moderator", + }); + + expect(result).toMatchObject({ deletedCount: 1, revokedTokenCount: 25, scheduled: false }); + expect(patch).toHaveBeenCalledWith("packagePublishTokens:active-24", { revokedAt: 1_000 }); + expect(patch).not.toHaveBeenCalledWith("packagePublishTokens:active-25", expect.anything()); + expect(runAfter).toHaveBeenCalledWith(0, expect.anything(), { + packageId: "packages:demo", + revokedAt: 1_000, + }); + }); + it("soft-deletes packages owned through the user's personal publisher", async () => { const personalPublisherPackage = makePackageDoc({ _id: "packages:personal-publisher", @@ -7847,7 +7875,10 @@ describe("owned package sanction batches", () => { revokedTokenCount: 0, scheduled: false, }); - expect(staleContinuationPage.patch).not.toHaveBeenCalledWith("packages:second", expect.anything()); + expect(staleContinuationPage.patch).not.toHaveBeenCalledWith( + "packages:second", + expect.anything(), + ); expect(staleContinuationPage.patch).not.toHaveBeenCalledWith( "packagePublishTokens:second", expect.anything(), diff --git a/convex/packages.ts b/convex/packages.ts index 8f934d1e28..4bb7f20311 100644 --- a/convex/packages.ts +++ b/convex/packages.ts @@ -284,6 +284,7 @@ const packageAutobanRemediationInternalRefs = internal as unknown as { }; type DbReaderCtx = Pick; const BAN_USER_PACKAGES_BATCH_SIZE = 25; +const PACKAGE_PUBLISH_TOKEN_REVOKE_BATCH_SIZE = 25; type PackageSoftDeletedReason = "user.banned" | "user.deactivated"; const ownedPackageScanScopeValidator = v.optional( v.union(v.literal("ownerUserId"), v.literal("personalPublisher")), @@ -3025,21 +3026,31 @@ async function restorePackageDoc( } async function revokePackagePublishTokensForPackage( - ctx: Pick, + ctx: Pick, packageId: Id<"packages">, revokedAt: number, ) { const tokens = await ctx.db .query("packagePublishTokens") - .withIndex("by_package", (q) => q.eq("packageId", packageId)) - .collect(); + .withIndex("by_package_revoked_created", (q) => + q.eq("packageId", packageId).eq("revokedAt", undefined), + ) + .order("desc") + .take(PACKAGE_PUBLISH_TOKEN_REVOKE_BATCH_SIZE + 1); let revokedCount = 0; - for (const token of tokens) { - if (token.revokedAt) continue; + for (const token of tokens.slice(0, PACKAGE_PUBLISH_TOKEN_REVOKE_BATCH_SIZE)) { await ctx.db.patch(token._id, { revokedAt }); revokedCount += 1; } - return revokedCount; + const scheduled = tokens.length > PACKAGE_PUBLISH_TOKEN_REVOKE_BATCH_SIZE; + if (scheduled) { + void ctx.scheduler.runAfter( + 0, + internal.packages.revokePackagePublishTokensForPackageBatchInternal, + { packageId, revokedAt }, + ); + } + return { revokedCount, scheduled }; } async function isPackageOwnedByPersonalUser( @@ -3105,6 +3116,17 @@ function scheduleNextOwnedPackageScanBatch( return false; } +export const revokePackagePublishTokensForPackageBatchInternal = internalMutation({ + args: { + packageId: v.id("packages"), + revokedAt: v.number(), + }, + handler: async (ctx, args) => { + const result = await revokePackagePublishTokensForPackage(ctx, args.packageId, args.revokedAt); + return { ok: true as const, ...result }; + }, +}); + export const applyBanToOwnedPackagesBatchInternal = internalMutation({ args: { ownerUserId: v.id("users"), @@ -3148,7 +3170,8 @@ export const applyBanToOwnedPackagesBatchInternal = internalMutation({ for (const pkg of page) { if (shouldSkipOwnedPackageScanRow(pkg, args)) continue; if (!(await isPackageOwnedByPersonalUser(ctx, pkg, owner))) continue; - revokedTokenCount += await revokePackagePublishTokensForPackage(ctx, pkg._id, args.bannedAt); + const revokeResult = await revokePackagePublishTokensForPackage(ctx, pkg._id, args.bannedAt); + revokedTokenCount += revokeResult.revokedCount; if (pkg.softDeletedAt) { if (pkg.softDeletedReason === "user.banned" && pkg.softDeletedAt !== args.bannedAt) { const previousBanHiddenAt = pkg.softDeletedAt; @@ -3314,7 +3337,8 @@ export const applyAccountDeletionToOwnedPackagesBatchInternal = internalMutation for (const pkg of page) { if (shouldSkipOwnedPackageScanRow(pkg, args)) continue; if (!(await isPackageOwnedByPersonalUser(ctx, pkg, owner))) continue; - revokedTokenCount += await revokePackagePublishTokensForPackage(ctx, pkg._id, args.deletedAt); + const revokeResult = await revokePackagePublishTokensForPackage(ctx, pkg._id, args.deletedAt); + revokedTokenCount += revokeResult.revokedCount; if (pkg.softDeletedAt) continue; await softDeletePackageDoc(ctx, pkg, { diff --git a/convex/schema.ts b/convex/schema.ts index 895035bf74..3a6b9432e4 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -1203,7 +1203,8 @@ const packagePublishTokens = defineTable({ createdAt: v.number(), }) .index("by_hash", ["tokenHash"]) - .index("by_package", ["packageId", "version", "createdAt"]); + .index("by_package", ["packageId", "version", "createdAt"]) + .index("by_package_revoked_created", ["packageId", "revokedAt", "createdAt"]); const packageSearchDigest = defineTable({ packageId: v.id("packages"), From 2c0f359e1e47d518719be3c329aa7a23f12c3eab Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Wed, 27 May 2026 14:27:55 -0500 Subject: [PATCH 12/17] fix: clear package ban reason during remediation restore --- convex/autobanRemediation.test.ts | 1 + convex/packages.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/convex/autobanRemediation.test.ts b/convex/autobanRemediation.test.ts index a9a747af0a..c7105dc5a1 100644 --- a/convex/autobanRemediation.test.ts +++ b/convex/autobanRemediation.test.ts @@ -1054,6 +1054,7 @@ describe("autoban remediation package restore", () => { "packages:demo", expect.objectContaining({ softDeletedAt: undefined, + softDeletedReason: undefined, latestReleaseId: "packageReleases:good", tags: { latest: "packageReleases:good" }, }), diff --git a/convex/packages.ts b/convex/packages.ts index 4bb7f20311..01077c11a7 100644 --- a/convex/packages.ts +++ b/convex/packages.ts @@ -3528,6 +3528,7 @@ export const restoreOwnedPackagesForAutobanRemediationBatchInternal = internalMu softDeletedAt: undefined, softDeletedBy: undefined, softDeletedByRole: undefined, + softDeletedReason: undefined, tags: nextTags, latestReleaseId: nextLatest?._id, latestVersionSummary: nextLatest From bc55fd87da6a3c885f00803442af7a1fc1a27b37 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Wed, 27 May 2026 14:35:41 -0500 Subject: [PATCH 13/17] fix: block direct package ban restores --- convex/packages.public.test.ts | 21 +++++++++++++++++++++ convex/packages.ts | 3 ++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/convex/packages.public.test.ts b/convex/packages.public.test.ts index 51428d149a..125e1b34b8 100644 --- a/convex/packages.public.test.ts +++ b/convex/packages.public.test.ts @@ -8418,4 +8418,25 @@ describe("restorePackageInternal", () => { ).rejects.toThrow("Forbidden"); expect(patch).not.toHaveBeenCalledWith("packages:demo", expect.anything()); }); + + it("does not let moderators directly restore packages hidden by a user ban", async () => { + const { ctx, patch } = makeSoftDeleteCtx({ + actor: { _id: "users:owner", role: "moderator" }, + pkg: makePackageDoc({ + ownerUserId: "users:owner", + ownerPublisherId: "publishers:owner-personal", + softDeletedAt: 1_000, + softDeletedReason: "user.banned", + softDeletedByRole: "moderator", + }), + }); + + await expect( + restorePackageInternalHandler(ctx as never, { + userId: "users:owner", + name: "demo-plugin", + }), + ).rejects.toThrow("Forbidden"); + expect(patch).not.toHaveBeenCalledWith("packages:demo", expect.anything()); + }); }); diff --git a/convex/packages.ts b/convex/packages.ts index 01077c11a7..c2770012a6 100644 --- a/convex/packages.ts +++ b/convex/packages.ts @@ -2909,11 +2909,12 @@ async function restorePackageDoc( const now = Date.now(); const actorRole = params.actorRole ?? "user"; const isPrivilegedActor = actorRole === "admin" || actorRole === "moderator"; + const isPrivilegedRestorableDelete = isPrivilegedActor && pkg.softDeletedReason !== "user.banned"; const isUserRestorableDelete = pkg.softDeletedByRole === "user" && pkg.softDeletedReason !== "user.banned"; const isUnbanBatchRestore = params.allowBanRestore === true && pkg.softDeletedReason === "user.banned"; - if (!isPrivilegedActor && !isUserRestorableDelete && !isUnbanBatchRestore) { + if (!isPrivilegedRestorableDelete && !isUserRestorableDelete && !isUnbanBatchRestore) { throw new ConvexError( "Forbidden: This package was hidden by moderation and cannot be restored by the owner. Please contact a moderator.", ); From 2f751695a8f4cc3f058d6f3ddde23143ab0c1463 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Wed, 27 May 2026 14:58:44 -0500 Subject: [PATCH 14/17] fix: scan linked personal publisher packages during sanctions --- convex/autobanRemediation.test.ts | 76 +++++++++++++++++++++ convex/packages.public.test.ts | 110 ++++++++++++++++++++++++++++++ convex/packages.ts | 57 ++++++++++------ convex/users.ts | 28 ++++++-- 4 files changed, 244 insertions(+), 27 deletions(-) diff --git a/convex/autobanRemediation.test.ts b/convex/autobanRemediation.test.ts index c7105dc5a1..e8ea5c69fc 100644 --- a/convex/autobanRemediation.test.ts +++ b/convex/autobanRemediation.test.ts @@ -1251,6 +1251,82 @@ describe("autoban remediation package restore", () => { }); }); + it("lists linked legacy personal-publisher package candidates without users.personalPublisherId", async () => { + const bannedAt = 1778569308754; + const result = await listPackageCandidatesHandler( + { + db: { + get: vi.fn(async (id: string) => + id === "users:target" ? { _id: id, personalPublisherId: undefined } : null, + ), + query: vi.fn((table: string) => { + if (table === "publishers") { + return { + withIndex: ( + name: string, + cb: (q: { eq: (field: string, value: string) => unknown }) => unknown, + ) => { + expect(name).toBe("by_linked_user"); + let linkedUserId = ""; + cb({ + eq: (field: string, value: string) => { + if (field === "linkedUserId") linkedUserId = value; + return {}; + }, + }); + return { + unique: vi.fn(async () => + linkedUserId === "users:target" + ? { + _id: "publishers:personal", + kind: "user", + linkedUserId: "users:target", + } + : null, + ), + }; + }, + }; + } + expect(table).toBe("packages"); + return { + withIndex: (name: string) => { + expect(name).toBe("by_owner_publisher"); + return { + order: () => ({ + paginate: vi.fn(async () => ({ + page: [ + { + _id: "packages:personal", + ownerUserId: "users:publishing-actor", + softDeletedAt: bannedAt, + scanStatus: "clean", + }, + ], + isDone: true, + continueCursor: null, + })), + }), + }; + }, + }; + }), + }, + } as never, + { + ownerUserId: "users:target", + bannedAt, + scope: "personalPublisher", + }, + ); + + expect(result).toEqual({ + packageIds: ["packages:personal"], + isDone: true, + continueCursor: null, + }); + }); + it("skips timestamp-matched packages when no non-malicious release can be selected", async () => { const bannedAt = 1778569308754; const patch = vi.fn(); diff --git a/convex/packages.public.test.ts b/convex/packages.public.test.ts index 125e1b34b8..434607c849 100644 --- a/convex/packages.public.test.ts +++ b/convex/packages.public.test.ts @@ -7631,6 +7631,34 @@ function makeOwnedPackageBatchCtx(options?: { })), }; } + if (table === "publishers") { + return { + withIndex: vi.fn( + ( + index: string, + cb: (q: { eq: (field: string, value: string) => unknown }) => unknown, + ) => { + expect(index).toBe("by_linked_user"); + let linkedUserId = ""; + cb({ + eq: (field: string, value: string) => { + if (field === "linkedUserId") linkedUserId = value; + return {}; + }, + }); + return { + unique: vi.fn( + async () => + Object.values(options?.publishers ?? {}).find( + (publisher) => + publisher?.kind === "user" && publisher.linkedUserId === linkedUserId, + ) ?? null, + ), + }; + }, + ), + }; + } if (table === "packagePublishTokens") { return { withIndex: vi.fn(() => ({ @@ -7785,6 +7813,88 @@ describe("owned package sanction batches", () => { }); }); + it("schedules linked legacy personal publisher scans when the user row lacks the publisher id", async () => { + const { ctx, runAfter } = makeOwnedPackageBatchCtx({ + owner: { + _id: "users:owner", + deletedAt: 1_000, + deactivatedAt: undefined, + }, + publishers: { + "publishers:personal": { + _id: "publishers:personal", + kind: "user", + linkedUserId: "users:owner", + }, + }, + }); + + const result = await applyBanToOwnedPackagesBatchInternalHandler(ctx as never, { + ownerUserId: "users:owner", + bannedAt: 1_000, + deletedBy: "users:moderator", + deletedByRole: "moderator", + }); + + expect(result).toMatchObject({ scheduled: true }); + expect(runAfter).toHaveBeenCalledWith( + 0, + expect.anything(), + expect.objectContaining({ + scope: "personalPublisher", + cursor: undefined, + }), + ); + }); + + it("soft-deletes linked legacy personal publisher packages without users.personalPublisherId", async () => { + const personalPublisherPackage = makePackageDoc({ + _id: "packages:personal-publisher", + ownerUserId: "users:publishing-actor", + ownerPublisherId: "publishers:personal", + }); + const { ctx, patch } = makeOwnedPackageBatchCtx({ + owner: { + _id: "users:owner", + deletedAt: 1_000, + deactivatedAt: undefined, + }, + publisherPackages: [personalPublisherPackage], + packageTokens: [ + { + _id: "packagePublishTokens:personal-publisher", + packageId: "packages:personal-publisher", + version: "1.0.1", + revokedAt: undefined, + }, + ], + publishers: { + "publishers:personal": { + _id: "publishers:personal", + kind: "user", + linkedUserId: "users:owner", + }, + }, + }); + + const result = await applyBanToOwnedPackagesBatchInternalHandler(ctx as never, { + ownerUserId: "users:owner", + bannedAt: 1_000, + deletedBy: "users:moderator", + deletedByRole: "moderator", + scope: "personalPublisher", + }); + + expect(result).toMatchObject({ deletedCount: 1, revokedTokenCount: 1, scheduled: false }); + expect(patch).toHaveBeenCalledWith( + "packages:personal-publisher", + expect.objectContaining({ softDeletedAt: 1_000, softDeletedReason: "user.banned" }), + ); + expect(patch).toHaveBeenCalledWith("packagePublishTokens:personal-publisher", { + revokedAt: 1_000, + }); + }); + it("stops stale package ban pages when the owner has already been unbanned", async () => { const { ctx, patch } = makeOwnedPackageBatchCtx(); diff --git a/convex/packages.ts b/convex/packages.ts index c2770012a6..e9fb06cd3c 100644 --- a/convex/packages.ts +++ b/convex/packages.ts @@ -69,6 +69,7 @@ import { toPublicPublisher } from "./lib/public"; import { assertCanManageOwnedResource, getPublisherByHandle, + getPersonalPublisherForUser, getOwnerPublisher, getPublisherMembership, isPublisherRoleAllowed, @@ -3067,6 +3068,22 @@ async function isPackageOwnedByPersonalUser( return ownerPublisher?.kind === "user" && ownerPublisher.linkedUserId === owner._id; } +async function getOwnedPackagePersonalPublisherId( + ctx: Pick, + owner: Pick, "_id" | "personalPublisherId">, +) { + if (owner.personalPublisherId) return owner.personalPublisherId; + const linkedPublisher = await getPersonalPublisherForUser(ctx, owner._id); + if ( + linkedPublisher?.kind === "user" && + !linkedPublisher.deletedAt && + !linkedPublisher.deactivatedAt + ) { + return linkedPublisher._id; + } + return undefined; +} + function getOwnedPackageScanScope(args: { scope?: OwnedPackageScanScope }) { return args.scope ?? "ownerUserId"; } @@ -3087,7 +3104,7 @@ function scheduleNextOwnedPackageScanBatch( string, unknown >, - owner: Pick, "personalPublisherId">, + personalPublisherId: Id<"publishers"> | undefined, isDone: boolean, continueCursor: string | null, ) { @@ -3102,7 +3119,7 @@ function scheduleNextOwnedPackageScanBatch( ); return true; } - if (getOwnedPackageScanScope(args) === "ownerUserId" && owner.personalPublisherId) { + if (getOwnedPackageScanScope(args) === "ownerUserId" && personalPublisherId) { void ctx.scheduler.runAfter( 0, fn as never, @@ -3151,13 +3168,12 @@ export const applyBanToOwnedPackagesBatchInternal = internalMutation({ } const scope = getOwnedPackageScanScope(args); + const personalPublisherId = await getOwnedPackagePersonalPublisherId(ctx, owner); const packageQuery = - scope === "personalPublisher" && owner.personalPublisherId + scope === "personalPublisher" && personalPublisherId ? ctx.db .query("packages") - .withIndex("by_owner_publisher", (q) => - q.eq("ownerPublisherId", owner.personalPublisherId), - ) + .withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", personalPublisherId)) : ctx.db .query("packages") .withIndex("by_owner", (q) => q.eq("ownerUserId", args.ownerUserId)); @@ -3220,7 +3236,7 @@ export const applyBanToOwnedPackagesBatchInternal = internalMutation({ ctx, internal.packages.applyBanToOwnedPackagesBatchInternal, args, - owner, + personalPublisherId, isDone, continueCursor, ); @@ -3248,13 +3264,12 @@ export const restoreOwnedPackagesForUnbanBatchInternal = internalMutation({ } const scope = getOwnedPackageScanScope(args); + const personalPublisherId = await getOwnedPackagePersonalPublisherId(ctx, owner); const packageQuery = - scope === "personalPublisher" && owner.personalPublisherId + scope === "personalPublisher" && personalPublisherId ? ctx.db .query("packages") - .withIndex("by_owner_publisher", (q) => - q.eq("ownerPublisherId", owner.personalPublisherId), - ) + .withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", personalPublisherId)) : ctx.db .query("packages") .withIndex("by_owner", (q) => q.eq("ownerUserId", args.ownerUserId)); @@ -3289,7 +3304,7 @@ export const restoreOwnedPackagesForUnbanBatchInternal = internalMutation({ ctx, internal.packages.restoreOwnedPackagesForUnbanBatchInternal, args, - owner, + personalPublisherId, isDone, continueCursor, ); @@ -3318,13 +3333,12 @@ export const applyAccountDeletionToOwnedPackagesBatchInternal = internalMutation } const scope = getOwnedPackageScanScope(args); + const personalPublisherId = await getOwnedPackagePersonalPublisherId(ctx, owner); const packageQuery = - scope === "personalPublisher" && owner.personalPublisherId + scope === "personalPublisher" && personalPublisherId ? ctx.db .query("packages") - .withIndex("by_owner_publisher", (q) => - q.eq("ownerPublisherId", owner.personalPublisherId), - ) + .withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", personalPublisherId)) : ctx.db .query("packages") .withIndex("by_owner", (q) => q.eq("ownerUserId", args.ownerUserId)); @@ -3356,7 +3370,7 @@ export const applyAccountDeletionToOwnedPackagesBatchInternal = internalMutation ctx, internal.packages.applyAccountDeletionToOwnedPackagesBatchInternal, args, - owner, + personalPublisherId, isDone, continueCursor, ); @@ -3455,13 +3469,12 @@ export const restoreOwnedPackagesForAutobanRemediationBatchInternal = internalMu } const scope = getOwnedPackageScanScope(args); + const personalPublisherId = await getOwnedPackagePersonalPublisherId(ctx, owner); const packageQuery = - scope === "personalPublisher" && owner.personalPublisherId + scope === "personalPublisher" && personalPublisherId ? ctx.db .query("packages") - .withIndex("by_owner_publisher", (q) => - q.eq("ownerPublisherId", owner.personalPublisherId), - ) + .withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", personalPublisherId)) : ctx.db .query("packages") .withIndex("by_owner", (q) => q.eq("ownerUserId", args.ownerUserId)); @@ -3590,7 +3603,7 @@ export const restoreOwnedPackagesForAutobanRemediationBatchInternal = internalMu packageAutobanRemediationInternalRefs.packages .restoreOwnedPackagesForAutobanRemediationBatchInternal, args, - owner, + personalPublisherId, isDone, continueCursor, ); diff --git a/convex/users.ts b/convex/users.ts index e894027f3c..ad24732985 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -17,6 +17,7 @@ import { isReservedPublicOwnerHandle } from "./lib/publicRouteReservations"; import { ensurePersonalPublisherForUser, getActiveUserByHandleOrPersonalPublisher, + getPersonalPublisherForUser, getPublisherByHandle, getUserByHandleOrPersonalPublisher, } from "./lib/publishers"; @@ -45,6 +46,23 @@ const autobanPackageScanScopeValidator = v.optional( v.union(v.literal("ownerUserId"), v.literal("personalPublisher")), ); type AutobanPackageScanScope = "ownerUserId" | "personalPublisher"; + +async function getAutobanPersonalPublisherId( + ctx: Pick, + owner: Pick, "_id" | "personalPublisherId"> | null | undefined, +) { + if (!owner) return undefined; + if (owner.personalPublisherId) return owner.personalPublisherId; + const linkedPublisher = await getPersonalPublisherForUser(ctx, owner._id); + if ( + linkedPublisher?.kind === "user" && + !linkedPublisher.deletedAt && + !linkedPublisher.deactivatedAt + ) { + return linkedPublisher._id; + } + return undefined; +} const autobanRemediationInternalRefs = internal as unknown as { users: { countRestorableAutobanSkillsPageInternal: unknown; @@ -1392,7 +1410,8 @@ async function countRestorableAutobanPackages( ) { let count = 0; const owner = await ctx.db.get(ownerUserId); - const scopes: AutobanPackageScanScope[] = owner?.personalPublisherId + const personalPublisherId = await getAutobanPersonalPublisherId(ctx, owner); + const scopes: AutobanPackageScanScope[] = personalPublisherId ? ["ownerUserId", "personalPublisher"] : ["ownerUserId"]; @@ -1488,13 +1507,12 @@ export const listRestorableAutobanPackageCandidatesPageInternal = internalQuery( handler: async (ctx, args) => { const owner = await ctx.db.get(args.ownerUserId); const scope = args.scope ?? "ownerUserId"; + const personalPublisherId = await getAutobanPersonalPublisherId(ctx, owner); const packageQuery = - scope === "personalPublisher" && owner?.personalPublisherId + scope === "personalPublisher" && personalPublisherId ? ctx.db .query("packages") - .withIndex("by_owner_publisher", (q) => - q.eq("ownerPublisherId", owner.personalPublisherId), - ) + .withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", personalPublisherId)) : ctx.db .query("packages") .withIndex("by_owner", (q) => q.eq("ownerUserId", args.ownerUserId)); From a2bd69387a52eb9efbbfcf70f68a4005eef8d155 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Wed, 27 May 2026 15:16:02 -0500 Subject: [PATCH 15/17] fix: tighten package sanction restore batches --- convex/autobanRemediation.test.ts | 59 +++++++++++++++++++ convex/packages.public.test.ts | 95 +++++++++++++++++++++++++++++-- convex/packages.ts | 8 +-- convex/users.ts | 33 ++++++++--- 4 files changed, 178 insertions(+), 17 deletions(-) diff --git a/convex/autobanRemediation.test.ts b/convex/autobanRemediation.test.ts index e8ea5c69fc..82cd564337 100644 --- a/convex/autobanRemediation.test.ts +++ b/convex/autobanRemediation.test.ts @@ -1251,6 +1251,65 @@ describe("autoban remediation package restore", () => { }); }); + it("does not count org-owned legacy package rows as autoban restore candidates", async () => { + const bannedAt = 1778569308754; + const result = await listPackageCandidatesHandler( + { + db: { + get: vi.fn(async (id: string) => { + if (id === "users:target") { + return { _id: id, personalPublisherId: "publishers:personal" }; + } + if (id === "publishers:org") return { _id: id, kind: "org" }; + return null; + }), + query: vi.fn((table: string) => { + expect(table).toBe("packages"); + return { + withIndex: (name: string) => { + expect(name).toBe("by_owner"); + return { + order: () => ({ + paginate: vi.fn(async () => ({ + page: [ + { + _id: "packages:org", + ownerUserId: "users:target", + ownerPublisherId: "publishers:org", + softDeletedAt: bannedAt, + scanStatus: "clean", + }, + { + _id: "packages:legacy-personal", + ownerUserId: "users:target", + ownerPublisherId: undefined, + softDeletedAt: bannedAt, + scanStatus: "clean", + }, + ], + isDone: true, + continueCursor: null, + })), + }), + }; + }, + }; + }), + }, + } as never, + { + ownerUserId: "users:target", + bannedAt, + }, + ); + + expect(result).toEqual({ + packageIds: ["packages:legacy-personal"], + isDone: true, + continueCursor: null, + }); + }); + it("lists linked legacy personal-publisher package candidates without users.personalPublisherId", async () => { const bannedAt = 1778569308754; const result = await listPackageCandidatesHandler( diff --git a/convex/packages.public.test.ts b/convex/packages.public.test.ts index 434607c849..4a3699d2ba 100644 --- a/convex/packages.public.test.ts +++ b/convex/packages.public.test.ts @@ -32,6 +32,7 @@ import { updateReleaseStaticScanInternal, applyAccountDeletionToOwnedPackagesBatchInternal, applyBanToOwnedPackagesBatchInternal, + revokePackagePublishTokensForPackageBatchInternal, restoreOwnedPackagesForUnbanBatchInternal, softDeletePackageInternal, restorePackageInternal, @@ -565,6 +566,12 @@ const applyBanToOwnedPackagesBatchInternalHandler = ( { deletedCount: number; revokedTokenCount: number; scheduled: boolean } > )._handler; +const revokePackagePublishTokensForPackageBatchInternalHandler = ( + revokePackagePublishTokensForPackageBatchInternal as unknown as WrappedHandler< + { packageId: string; revokedAt: number }, + { ok: true; revokedCount: number; scheduled: boolean } + > +)._handler; const restoreOwnedPackagesForUnbanBatchInternalHandler = ( restoreOwnedPackagesForUnbanBatchInternal as unknown as WrappedHandler< { @@ -7661,11 +7668,38 @@ function makeOwnedPackageBatchCtx(options?: { } if (table === "packagePublishTokens") { return { - withIndex: vi.fn(() => ({ - order: vi.fn(() => ({ - take: vi.fn(async (limit: number) => packageTokens.slice(0, limit)), - })), - })), + withIndex: vi.fn( + ( + _index: string, + builder?: (q: { + eq: (field: string, value: string | undefined) => unknown; + lte: (field: string, value: number) => unknown; + }) => unknown, + ) => { + let maxCreatedAt = Number.POSITIVE_INFINITY; + const queryBuilder = { + eq: () => queryBuilder, + lte: (field: string, value: number) => { + if (field === "createdAt") maxCreatedAt = value; + return queryBuilder; + }, + }; + builder?.(queryBuilder); + return { + order: vi.fn(() => ({ + take: vi.fn(async (limit: number) => + packageTokens + .filter( + (token) => + typeof token.createdAt !== "number" || + token.createdAt <= maxCreatedAt, + ) + .slice(0, limit), + ), + })), + }; + }, + ), }; } if (table === "packageReleases") { @@ -7764,6 +7798,36 @@ describe("owned package sanction batches", () => { }); }); + it("does not let stale token revocation batches revoke tokens minted after the ban marker", async () => { + const { ctx, patch } = makeOwnedPackageBatchCtx({ + packageTokens: [ + { + _id: "packagePublishTokens:before-ban", + packageId: "packages:demo", + version: "1.0.1", + revokedAt: undefined, + createdAt: 999, + }, + { + _id: "packagePublishTokens:after-unban", + packageId: "packages:demo", + version: "1.0.2", + revokedAt: undefined, + createdAt: 1_001, + }, + ], + }); + + const result = await revokePackagePublishTokensForPackageBatchInternalHandler(ctx as never, { + packageId: "packages:demo", + revokedAt: 1_000, + }); + + expect(result).toMatchObject({ revokedCount: 1, scheduled: false }); + expect(patch).toHaveBeenCalledWith("packagePublishTokens:before-ban", { revokedAt: 1_000 }); + expect(patch).not.toHaveBeenCalledWith("packagePublishTokens:after-unban", expect.anything()); + }); + it("soft-deletes packages owned through the user's personal publisher", async () => { const personalPublisherPackage = makePackageDoc({ _id: "packages:personal-publisher", @@ -8549,4 +8613,25 @@ describe("restorePackageInternal", () => { ).rejects.toThrow("Forbidden"); expect(patch).not.toHaveBeenCalledWith("packages:demo", expect.anything()); }); + + it("does not let moderators directly restore packages hidden by account deletion", async () => { + const { ctx, patch } = makeSoftDeleteCtx({ + actor: { _id: "users:owner", role: "moderator" }, + pkg: makePackageDoc({ + ownerUserId: "users:owner", + ownerPublisherId: "publishers:owner-personal", + softDeletedAt: 1_000, + softDeletedReason: "user.deactivated", + softDeletedByRole: "user", + }), + }); + + await expect( + restorePackageInternalHandler(ctx as never, { + userId: "users:owner", + name: "demo-plugin", + }), + ).rejects.toThrow("Forbidden"); + expect(patch).not.toHaveBeenCalledWith("packages:demo", expect.anything()); + }); }); diff --git a/convex/packages.ts b/convex/packages.ts index e9fb06cd3c..4ed736c001 100644 --- a/convex/packages.ts +++ b/convex/packages.ts @@ -2910,9 +2910,9 @@ async function restorePackageDoc( const now = Date.now(); const actorRole = params.actorRole ?? "user"; const isPrivilegedActor = actorRole === "admin" || actorRole === "moderator"; - const isPrivilegedRestorableDelete = isPrivilegedActor && pkg.softDeletedReason !== "user.banned"; - const isUserRestorableDelete = - pkg.softDeletedByRole === "user" && pkg.softDeletedReason !== "user.banned"; + const isDirectlyRestorableDelete = pkg.softDeletedReason === undefined; + const isPrivilegedRestorableDelete = isPrivilegedActor && isDirectlyRestorableDelete; + const isUserRestorableDelete = pkg.softDeletedByRole === "user" && isDirectlyRestorableDelete; const isUnbanBatchRestore = params.allowBanRestore === true && pkg.softDeletedReason === "user.banned"; if (!isPrivilegedRestorableDelete && !isUserRestorableDelete && !isUnbanBatchRestore) { @@ -3035,7 +3035,7 @@ async function revokePackagePublishTokensForPackage( const tokens = await ctx.db .query("packagePublishTokens") .withIndex("by_package_revoked_created", (q) => - q.eq("packageId", packageId).eq("revokedAt", undefined), + q.eq("packageId", packageId).eq("revokedAt", undefined).lte("createdAt", revokedAt), ) .order("desc") .take(PACKAGE_PUBLISH_TOKEN_REVOKE_BATCH_SIZE + 1); diff --git a/convex/users.ts b/convex/users.ts index ad24732985..4988093d38 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -63,6 +63,19 @@ async function getAutobanPersonalPublisherId( } return undefined; } + +async function isOwnedPersonalAutobanPackage( + ctx: Pick, + pkg: Pick, "ownerPublisherId">, + owner: Pick, "_id" | "personalPublisherId">, +) { + if (!pkg.ownerPublisherId) return true; + if (owner.personalPublisherId && pkg.ownerPublisherId === owner.personalPublisherId) { + return true; + } + const ownerPublisher = await ctx.db.get(pkg.ownerPublisherId); + return ownerPublisher?.kind === "user" && ownerPublisher.linkedUserId === owner._id; +} const autobanRemediationInternalRefs = internal as unknown as { users: { countRestorableAutobanSkillsPageInternal: unknown; @@ -1506,6 +1519,9 @@ export const listRestorableAutobanPackageCandidatesPageInternal = internalQuery( }, handler: async (ctx, args) => { const owner = await ctx.db.get(args.ownerUserId); + if (!owner) { + return { packageIds: [], isDone: true, continueCursor: null }; + } const scope = args.scope ?? "ownerUserId"; const personalPublisherId = await getAutobanPersonalPublisherId(ctx, owner); const packageQuery = @@ -1521,15 +1537,16 @@ export const listRestorableAutobanPackageCandidatesPageInternal = internalQuery( numItems: AUTOBAN_REMEDIATION_COUNT_PAGE_SIZE, }); + const packageIds: Array> = []; + for (const pkg of result.page) { + if (scope === "personalPublisher" && pkg.ownerUserId === args.ownerUserId) continue; + if (pkg.softDeletedAt !== args.bannedAt || pkg.scanStatus === "malicious") continue; + if (!(await isOwnedPersonalAutobanPackage(ctx, pkg, owner))) continue; + packageIds.push(pkg._id); + } + return { - packageIds: result.page - .filter( - (pkg) => - !(scope === "personalPublisher" && pkg.ownerUserId === args.ownerUserId) && - pkg.softDeletedAt === args.bannedAt && - pkg.scanStatus !== "malicious", - ) - .map((pkg) => pkg._id), + packageIds, isDone: result.isDone, continueCursor: result.continueCursor, }; From 70eb0ae7d873571fd7fcf890c33cae3ea44a3e48 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Wed, 27 May 2026 15:50:06 -0500 Subject: [PATCH 16/17] fix: block personal publisher publishes after owner ban --- convex/packages.public.test.ts | 47 ++++++++++++++++++++++++++++++++++ convex/packages.ts | 10 ++++++++ 2 files changed, 57 insertions(+) diff --git a/convex/packages.public.test.ts b/convex/packages.public.test.ts index 4a3699d2ba..ad0c0818a6 100644 --- a/convex/packages.public.test.ts +++ b/convex/packages.public.test.ts @@ -3683,6 +3683,53 @@ describe("packages public queries", () => { expect(ctx.insert).not.toHaveBeenCalledWith("packageReleases", expect.anything()); }); + it("rejects final package publish inserts when a personal publisher owner was banned mid-publish", async () => { + const ctx = makeInsertReleaseCtx( + makePackageDoc({ + ownerUserId: "users:publishing-actor", + ownerPublisherId: "publishers:personal", + }), + [], + { + "users:publishing-actor": { + _id: "users:publishing-actor", + role: "user", + trustedPublisher: false, + }, + "users:owner": { + _id: "users:owner", + role: "user", + trustedPublisher: false, + deletedAt: 1_000, + }, + "publishers:personal": { + _id: "publishers:personal", + kind: "user", + handle: "owner", + linkedUserId: "users:owner", + }, + }, + ); + + await expect( + insertReleaseInternalHandler(ctx, { + actorUserId: "users:publishing-actor", + ownerUserId: "users:publishing-actor", + ownerPublisherId: "publishers:personal", + name: "demo-plugin", + displayName: "Demo Plugin", + family: "code-plugin", + version: "1.0.1", + changelog: "try banned publisher owner", + tags: ["latest"], + summary: "demo", + files: [], + integritySha256: "abc123", + }), + ).rejects.toThrow("Package owner publisher is unavailable"); + expect(ctx.insert).not.toHaveBeenCalledWith("packageReleases", expect.anything()); + }); + it("rejects final user org package publish inserts when membership was removed mid-publish", async () => { const ctx = makeInsertReleaseCtx(null, [], { "users:member": { _id: "users:member", role: "user", trustedPublisher: false }, diff --git a/convex/packages.ts b/convex/packages.ts index 4ed736c001..f368ae6933 100644 --- a/convex/packages.ts +++ b/convex/packages.ts @@ -5937,6 +5937,16 @@ export const insertReleaseInternal = internalMutation({ ) { throw new ConvexError("Package owner publisher is unavailable"); } + if (ownerPublisher?.kind === "user" && ownerPublisher.linkedUserId) { + const linkedPublisherUser = await ctx.db.get(ownerPublisher.linkedUserId); + if ( + !linkedPublisherUser || + linkedPublisherUser.deletedAt || + linkedPublisherUser.deactivatedAt + ) { + throw new ConvexError("Package owner publisher is unavailable"); + } + } if (args.publishActor?.kind === "user" && args.publishActor.userId !== args.actorUserId) { throw new ConvexError("Publish actor must match the authenticated actor"); } From f0960d00ac5afdef36e514512e269ea91aa1dc8f Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Wed, 27 May 2026 16:01:51 -0500 Subject: [PATCH 17/17] fix: allow initial package ban cleanup before commit --- convex/packages.public.test.ts | 20 ++++++++++++++++++++ convex/packages.ts | 5 +++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/convex/packages.public.test.ts b/convex/packages.public.test.ts index ad0c0818a6..5f9de9c2b6 100644 --- a/convex/packages.public.test.ts +++ b/convex/packages.public.test.ts @@ -8006,6 +8006,26 @@ describe("owned package sanction batches", () => { }); }); + it("processes the initial package ban batch before the user ban is visible", async () => { + const { ctx, patch } = makeOwnedPackageBatchCtx({ + owner: { _id: "users:owner", deletedAt: undefined, deactivatedAt: undefined }, + }); + + const result = await applyBanToOwnedPackagesBatchInternalHandler(ctx as never, { + ownerUserId: "users:owner", + bannedAt: 1_000, + deletedBy: "users:moderator", + deletedByRole: "moderator", + }); + + expect(result).toMatchObject({ deletedCount: 1, revokedTokenCount: 1, scheduled: false }); + expect(patch).toHaveBeenCalledWith( + "packages:demo", + expect.objectContaining({ softDeletedAt: 1_000, softDeletedReason: "user.banned" }), + ); + expect(patch).toHaveBeenCalledWith("packagePublishTokens:demo", { revokedAt: 1_000 }); + }); + it("stops stale package ban pages when the owner has already been unbanned", async () => { const { ctx, patch } = makeOwnedPackageBatchCtx(); diff --git a/convex/packages.ts b/convex/packages.ts index f368ae6933..9fe2e0297e 100644 --- a/convex/packages.ts +++ b/convex/packages.ts @@ -3156,8 +3156,10 @@ export const applyBanToOwnedPackagesBatchInternal = internalMutation({ }, handler: async (ctx, args) => { const owner = await ctx.db.get(args.ownerUserId); + const scope = getOwnedPackageScanScope(args); + const isInitialOwnerUserIdBatch = !args.cursor && scope === "ownerUserId"; const ownerMatchesCurrentBan = owner?.deletedAt === args.bannedAt; - if (!owner || owner.deactivatedAt || !ownerMatchesCurrentBan) { + if (!owner || owner.deactivatedAt || (!isInitialOwnerUserIdBatch && !ownerMatchesCurrentBan)) { return { ok: true as const, deletedCount: 0, @@ -3167,7 +3169,6 @@ export const applyBanToOwnedPackagesBatchInternal = internalMutation({ }; } - const scope = getOwnedPackageScanScope(args); const personalPublisherId = await getOwnedPackagePersonalPublisherId(ctx, owner); const packageQuery = scope === "personalPublisher" && personalPublisherId