diff --git a/CHANGELOG.md b/CHANGELOG.md index 23cd8bf3bb..c76c20754f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,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). - API: block public raw skill files when moderation already blocks downloads and reject skill tags that point at another skill's version (thanks @vyctorbrzezowski). - Web: stop stale unban restore batches from reactivating skills after the owner is banned again or deactivated (thanks @vyctorbrzezowski). - Security/API: reject direct skill owner transfers when the skill is hidden, suspicious, or malicious (thanks @vyctorbrzezowski). diff --git a/convex/autobanRemediation.test.ts b/convex/autobanRemediation.test.ts index 6d6bff8265..82cd564337 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< { @@ -1041,6 +1054,7 @@ describe("autoban remediation package restore", () => { "packages:demo", expect.objectContaining({ softDeletedAt: undefined, + softDeletedReason: undefined, latestReleaseId: "packageReleases:good", tags: { latest: "packageReleases:good" }, }), @@ -1055,6 +1069,323 @@ 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("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( + { + 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 a7166282f1..5f9de9c2b6 100644 --- a/convex/packages.public.test.ts +++ b/convex/packages.public.test.ts @@ -30,6 +30,10 @@ import { listPageForViewerInternal, listVersions, updateReleaseStaticScanInternal, + applyAccountDeletionToOwnedPackagesBatchInternal, + applyBanToOwnedPackagesBatchInternal, + revokePackagePublishTokensForPackageBatchInternal, + restoreOwnedPackagesForUnbanBatchInternal, softDeletePackageInternal, restorePackageInternal, transferPackageOwnerForUserInternal, @@ -549,6 +553,48 @@ const softDeletePackageInternalHandler = ( } > )._handler; +const applyBanToOwnedPackagesBatchInternalHandler = ( + applyBanToOwnedPackagesBatchInternal as unknown as WrappedHandler< + { + ownerUserId: string; + bannedAt: number; + deletedBy: string; + deletedByRole: "admin" | "moderator" | "user"; + cursor?: string; + scope?: "ownerUserId" | "personalPublisher"; + }, + { 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< + { + 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; + scope?: "ownerUserId" | "personalPublisher"; + }, + { deletedCount: number; revokedTokenCount: number; scheduled: boolean } + > +)._handler; const restorePackageInternalHandler = ( restorePackageInternal as unknown as WrappedHandler< { userId: string; name: string }, @@ -3637,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 }, @@ -7413,6 +7506,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. */ @@ -7463,7 +7557,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; }), @@ -7524,73 +7618,952 @@ function makeSoftDeleteCtx(options?: { }; } -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(); +function makeOwnedPackageBatchCtx(options?: { + pkg?: Record; + owner?: Record | null; + packageTokens?: Array>; + releases?: Array>; + publisherPackages?: 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(); - const result = await softDeletePackageInternalHandler(ctx as never, { - userId: "users:owner", - name: "demo-plugin", + return { + patch, + insert, + runAfter, + ctx: { + 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 } + : 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((index: string) => ({ + order: vi.fn(() => ({ + paginate: vi.fn().mockResolvedValue({ + page: + index === "by_owner_publisher" ? (options?.publisherPackages ?? []) : [pkg], + isDone: options?.isDone ?? true, + continueCursor: options?.continueCursor ?? "", + }), + })), + })), + }; + } + 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( + ( + _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") { + 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({ + owner: { _id: "users:owner", deletedAt: 1_000, deactivatedAt: undefined }, }); - expect(result).toMatchObject({ ok: true, alreadyDeleted: false, releaseCount: 1 }); + const result = await applyBanToOwnedPackagesBatchInternalHandler(ctx as never, { + ownerUserId: "users:owner", + bannedAt: 1_000, + deletedBy: "users:moderator", + deletedByRole: "moderator", + }); - // The package doc must be soft-deleted. + expect(result).toMatchObject({ deletedCount: 1, revokedTokenCount: 1, scheduled: false }); expect(patch).toHaveBeenCalledWith( "packages:demo", - expect.objectContaining({ softDeletedAt: expect.any(Number) }), + expect.objectContaining({ + softDeletedAt: 1_000, + softDeletedReason: "user.banned", + softDeletedBy: "users:moderator", + softDeletedByRole: "moderator", + }), ); + expect(patch).toHaveBeenCalledWith("packagePublishTokens:demo", { revokedAt: 1_000 }); + }); - // The packageSearchDigest row must be updated with ownerHandle resolved. - expect(patch).toHaveBeenCalledWith( - "packageSearchDigest:demo", - expect.objectContaining({ ownerHandle: "tongfei11", softDeletedAt: expect.any(Number) }), - ); + 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, + }); - // An audit log must be inserted. - expect(insert).toHaveBeenCalledWith( - "auditLogs", - expect.objectContaining({ action: "package.delete" }), - ); + 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("writes ownerHandle via insert when no packageCapabilitySearchDigest row exists yet", async () => { - const { ctx, insert } = makeSoftDeleteCtx({ noCapabilityDigest: true }); + 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, + }, + ], + }); - await softDeletePackageInternalHandler(ctx as never, { - userId: "users:owner", - name: "demo-plugin", + const result = await revokePackagePublishTokensForPackageBatchInternalHandler(ctx as never, { + packageId: "packages:demo", + revokedAt: 1_000, }); - // A new capability digest row must be inserted with ownerHandle populated. - expect(insert).toHaveBeenCalledWith( - "packageCapabilitySearchDigest", - expect.objectContaining({ - capabilityTag: "read-files", - ownerHandle: "tongfei11", - }), - ); + 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("returns alreadyDeleted:true without touching releases when package is already soft-deleted", async () => { - const { ctx, patch } = makeSoftDeleteCtx({ - pkg: makePackageDoc({ softDeletedAt: 999, softDeletedByRole: "user" }), + 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 softDeletePackageInternalHandler(ctx as never, { - userId: "users:owner", - name: "demo-plugin", + const result = await applyBanToOwnedPackagesBatchInternalHandler(ctx as never, { + ownerUserId: "users:owner", + bannedAt: 1_000, + deletedBy: "users:moderator", + deletedByRole: "moderator", + scope: "personalPublisher", }); - expect(result).toMatchObject({ ok: true, alreadyDeleted: true, releaseCount: 0 }); - // No release patches should have been made. - expect(patch).not.toHaveBeenCalledWith("packageReleases:demo-1", expect.anything()); + 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, + }); }); -}); -describe("restorePackageInternal", () => { - it("restores a soft-deleted package and writes ownerHandle to the search digest", async () => { - const pkg = makePackageDoc({ + 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("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(); + + 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("continues committed package ban pages without a pre-commit bypass", async () => { + const firstPage = makeOwnedPackageBatchCtx({ + owner: { _id: "users:owner", deletedAt: 1_000, 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", + }); + + 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", + }), + ); + + const staleContinuationPage = 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 staleContinuationResult = await applyBanToOwnedPackagesBatchInternalHandler( + staleContinuationPage.ctx as never, + { + ownerUserId: "users:owner", + bannedAt: 1_000, + deletedBy: "users:moderator", + deletedByRole: "moderator", + cursor: "next-page", + }, + ); + + 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(committedContinuationPage.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 }, + pkg: makePackageDoc({ + softDeletedAt: 1_000, + softDeletedReason: "user.banned", + 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, { + ownerUserId: "users:owner", + bannedAt: 2_000, + deletedBy: "users:second-moderator", + deletedByRole: "admin", + }); + + 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({ + 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: true }); + 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, insert } = 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, + }), + makeReleaseDoc({ + _id: "packageReleases:malicious", + softDeletedAt: 500, + distTags: ["malicious"], + version: "0.9.0", + changelog: "", + compatibility: null, + capabilities: null, + verification: null, + }), + ], + }); + + const result = await restoreOwnedPackagesForUnbanBatchInternalHandler(ctx as never, { + actorUserId: "users:admin", + 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, + }), + ); + expect(insert).toHaveBeenCalledWith( + "auditLogs", + expect.objectContaining({ + actorUserId: "users:admin", + action: "package.undelete", + }), + ); + 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 }, + pkg: makePackageDoc({ + softDeletedAt: 1_000, + softDeletedReason: "user.banned", + }), + }); + + const result = await restoreOwnedPackagesForUnbanBatchInternalHandler(ctx as never, { + actorUserId: "users:admin", + 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("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: { + _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: true }); + 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(); + + const result = await softDeletePackageInternalHandler(ctx as never, { + userId: "users:owner", + name: "demo-plugin", + }); + + expect(result).toMatchObject({ ok: true, alreadyDeleted: false, releaseCount: 1 }); + + // The package doc must be soft-deleted. + expect(patch).toHaveBeenCalledWith( + "packages:demo", + expect.objectContaining({ softDeletedAt: expect.any(Number) }), + ); + + // The packageSearchDigest row must be updated with ownerHandle resolved. + expect(patch).toHaveBeenCalledWith( + "packageSearchDigest:demo", + expect.objectContaining({ ownerHandle: "tongfei11", softDeletedAt: expect.any(Number) }), + ); + + // An audit log must be inserted. + expect(insert).toHaveBeenCalledWith( + "auditLogs", + expect.objectContaining({ action: "package.delete" }), + ); + }); + + it("writes ownerHandle via insert when no packageCapabilitySearchDigest row exists yet", async () => { + const { ctx, insert } = makeSoftDeleteCtx({ noCapabilityDigest: true }); + + await softDeletePackageInternalHandler(ctx as never, { + userId: "users:owner", + name: "demo-plugin", + }); + + // A new capability digest row must be inserted with ownerHandle populated. + expect(insert).toHaveBeenCalledWith( + "packageCapabilitySearchDigest", + expect.objectContaining({ + capabilityTag: "read-files", + ownerHandle: "tongfei11", + }), + ); + }); + + it("returns alreadyDeleted:true without touching releases when package is already soft-deleted", async () => { + const { ctx, patch } = makeSoftDeleteCtx({ + pkg: makePackageDoc({ softDeletedAt: 999, softDeletedByRole: "user" }), + }); + + const result = await softDeletePackageInternalHandler(ctx as never, { + userId: "users:owner", + name: "demo-plugin", + }); + + expect(result).toMatchObject({ ok: true, alreadyDeleted: true, releaseCount: 0 }); + // 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", () => { + it("restores a soft-deleted package and writes ownerHandle to the search digest", async () => { + const pkg = makePackageDoc({ ownerUserId: "users:owner", ownerPublisherId: "publishers:owner-personal", softDeletedAt: 500, @@ -7666,4 +8639,66 @@ 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()); + }); + + 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()); + }); + + 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 7f5e45c835..9fe2e0297e 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"; @@ -70,6 +69,7 @@ import { toPublicPublisher } from "./lib/public"; import { assertCanManageOwnedResource, getPublisherByHandle, + getPersonalPublisherForUser, getOwnerPublisher, getPublisherMembership, isPublisherRoleAllowed, @@ -284,6 +284,13 @@ 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")), +); +type OwnedPackageScanScope = "ownerUserId" | "personalPublisher"; type PackagePublishActor = | { kind: "user"; @@ -2731,15 +2738,33 @@ async function softDeletePackageDoc( params: { actorUserId: Id<"users">; actorRole?: Doc<"users">["role"]; + deletedAt?: number; + reason?: PackageSoftDeletedReason; 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 { @@ -2750,7 +2775,6 @@ async function softDeletePackageDoc( }; } - const now = Date.now(); const releases = await ctx.db .query("packageReleases") .withIndex("by_package", (q) => q.eq("packageId", pkg._id)) @@ -2766,6 +2790,7 @@ async function softDeletePackageDoc( const packagePatch: Partial> = { softDeletedAt: now, + softDeletedReason: params.reason, softDeletedBy: params.actorUserId, softDeletedByRole: params.actorRole ?? "user", updatedAt: now, @@ -2792,6 +2817,7 @@ async function softDeletePackageDoc( ownerUserId: pkg.ownerUserId, ownerPublisherId: pkg.ownerPublisherId, actorRole: params.actorRole ?? "user", + softDeletedReason: params.reason ?? null, releaseCount, releaseIds: deletedReleaseIds, source: params.source, @@ -2867,6 +2893,8 @@ async function restorePackageDoc( params: { actorUserId: Id<"users">; actorRole?: Doc<"users">["role"]; + allowBanRestore?: boolean; + releaseSoftDeletedAt?: number; source: "cli" | "dashboard"; }, ) { @@ -2881,7 +2909,13 @@ 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 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) { throw new ConvexError( "Forbidden: This package was hidden by moderation and cannot be restored by the owner. Please contact a moderator.", ); @@ -2896,6 +2930,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; @@ -2921,6 +2961,7 @@ async function restorePackageDoc( const packagePatch: Partial> = { softDeletedAt: undefined, + softDeletedReason: undefined, softDeletedBy: undefined, softDeletedByRole: undefined, tags: nextTags, @@ -2986,6 +3027,359 @@ async function restorePackageDoc( }; } +async function revokePackagePublishTokensForPackage( + ctx: Pick, + packageId: Id<"packages">, + revokedAt: number, +) { + const tokens = await ctx.db + .query("packagePublishTokens") + .withIndex("by_package_revoked_created", (q) => + q.eq("packageId", packageId).eq("revokedAt", undefined).lte("createdAt", revokedAt), + ) + .order("desc") + .take(PACKAGE_PUBLISH_TOKEN_REVOKE_BATCH_SIZE + 1); + let revokedCount = 0; + for (const token of tokens.slice(0, PACKAGE_PUBLISH_TOKEN_REVOKE_BATCH_SIZE)) { + await ctx.db.patch(token._id, { revokedAt }); + revokedCount += 1; + } + 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( + 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; +} + +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"; +} + +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 + >, + personalPublisherId: Id<"publishers"> | undefined, + 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" && personalPublisherId) { + void ctx.scheduler.runAfter( + 0, + fn as never, + { + ...args, + scope: "personalPublisher", + cursor: undefined, + } as never, + ); + return true; + } + 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"), + bannedAt: v.number(), + 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); + const scope = getOwnedPackageScanScope(args); + const isInitialOwnerUserIdBatch = !args.cursor && scope === "ownerUserId"; + const ownerMatchesCurrentBan = owner?.deletedAt === args.bannedAt; + if (!owner || owner.deactivatedAt || (!isInitialOwnerUserIdBatch && !ownerMatchesCurrentBan)) { + return { + ok: true as const, + deletedCount: 0, + revokedTokenCount: 0, + scheduled: false, + stale: true as const, + }; + } + + const personalPublisherId = await getOwnedPackagePersonalPublisherId(ctx, owner); + const packageQuery = + scope === "personalPublisher" && personalPublisherId + ? ctx.db + .query("packages") + .withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", 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; + 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; + 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, + 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; + } + + const scheduled = scheduleNextOwnedPackageScanBatch( + ctx, + internal.packages.applyBanToOwnedPackagesBatchInternal, + args, + personalPublisherId, + isDone, + continueCursor, + ); + + return { ok: true as const, deletedCount, revokedTokenCount, scheduled }; + }, +}); + +export const restoreOwnedPackagesForUnbanBatchInternal = internalMutation({ + args: { + actorUserId: v.id("users"), + 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); + 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 }; + } + + const scope = getOwnedPackageScanScope(args); + const personalPublisherId = await getOwnedPackagePersonalPublisherId(ctx, owner); + const packageQuery = + scope === "personalPublisher" && personalPublisherId + ? ctx.db + .query("packages") + .withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", 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 || + pkg.softDeletedAt !== args.bannedAt || + pkg.softDeletedReason !== "user.banned" + ) { + continue; + } + + await restorePackageDoc(ctx, pkg, { + actorUserId: actor._id, + actorRole: actor.role, + allowBanRestore: true, + releaseSoftDeletedAt: args.bannedAt, + source: "dashboard", + }); + restoredCount += 1; + } + + const scheduled = scheduleNextOwnedPackageScanBatch( + ctx, + internal.packages.restoreOwnedPackagesForUnbanBatchInternal, + args, + personalPublisherId, + isDone, + continueCursor, + ); + + return { ok: true as const, restoredCount, scheduled }; + }, +}); + +export const applyAccountDeletionToOwnedPackagesBatchInternal = internalMutation({ + args: { + 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); + if (!owner) { + return { + ok: true as const, + deletedCount: 0, + revokedTokenCount: 0, + scheduled: false, + stale: true as const, + }; + } + + const scope = getOwnedPackageScanScope(args); + const personalPublisherId = await getOwnedPackagePersonalPublisherId(ctx, owner); + const packageQuery = + scope === "personalPublisher" && personalPublisherId + ? ctx.db + .query("packages") + .withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", 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; + const revokeResult = await revokePackagePublishTokensForPackage(ctx, pkg._id, args.deletedAt); + revokedTokenCount += revokeResult.revokedCount; + if (pkg.softDeletedAt) continue; + + await softDeletePackageDoc(ctx, pkg, { + actorUserId: args.ownerUserId, + actorRole: "user", + deletedAt: args.deletedAt, + reason: "user.deactivated", + source: "dashboard", + }); + deletedCount += 1; + } + + const scheduled = scheduleNextOwnedPackageScanBatch( + ctx, + internal.packages.applyAccountDeletionToOwnedPackagesBatchInternal, + args, + personalPublisherId, + isDone, + continueCursor, + ); + + return { ok: true as const, deletedCount, revokedTokenCount, scheduled }; + }, +}); + export const softDeletePackageInternal = internalMutation({ args: { userId: v.id("users"), @@ -3060,6 +3454,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); @@ -3074,19 +3469,27 @@ 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 personalPublisherId = await getOwnedPackagePersonalPublisherId(ctx, owner); + const packageQuery = + scope === "personalPublisher" && personalPublisherId + ? ctx.db + .query("packages") + .withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", 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; @@ -3140,6 +3543,7 @@ export const restoreOwnedPackagesForAutobanRemediationBatchInternal = internalMu softDeletedAt: undefined, softDeletedBy: undefined, softDeletedByRole: undefined, + softDeletedReason: undefined, tags: nextTags, latestReleaseId: nextLatest?._id, latestVersionSummary: nextLatest @@ -3195,11 +3599,12 @@ export const restoreOwnedPackagesForAutobanRemediationBatchInternal = internalMu restoredCount += 1; } - scheduleNextBatchIfNeeded( - ctx.scheduler, + const scheduled = scheduleNextOwnedPackageScanBatch( + ctx, packageAutobanRemediationInternalRefs.packages .restoreOwnedPackagesForAutobanRemediationBatchInternal, args, + personalPublisherId, isDone, continueCursor, ); @@ -3209,7 +3614,7 @@ export const restoreOwnedPackagesForAutobanRemediationBatchInternal = internalMu restoredCount, restoredReleases, skippedMalicious, - scheduled: !isDone, + scheduled, }; }, }); @@ -5533,6 +5938,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"); } diff --git a/convex/schema.ts b/convex/schema.ts index bc09c09b60..8e66e6c111 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -988,6 +988,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")), @@ -1204,7 +1205,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"), 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 3101584be3..4988093d38 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"; @@ -41,6 +42,40 @@ 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"; + +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; +} + +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; @@ -520,6 +555,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, @@ -1381,24 +1422,33 @@ async function countRestorableAutobanPackages( bannedAt: number, ) { let count = 0; - let cursor: string | null = null; - let isDone = false; - - 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; + const owner = await ctx.db.get(ownerUserId); + const personalPublisherId = await getAutobanPersonalPublisherId(ctx, owner); + const scopes: AutobanPackageScanScope[] = personalPublisherId + ? ["ownerUserId", "personalPublisher"] + : ["ownerUserId"]; + + 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; @@ -1465,21 +1515,38 @@ 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); + if (!owner) { + return { packageIds: [], isDone: true, continueCursor: null }; + } + const scope = args.scope ?? "ownerUserId"; + const personalPublisherId = await getAutobanPersonalPublisherId(ctx, owner); + const packageQuery = + scope === "personalPublisher" && personalPublisherId + ? ctx.db + .query("packages") + .withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", 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, + }); + + 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) => pkg.softDeletedAt === args.bannedAt && pkg.scanStatus !== "malicious") - .map((pkg) => pkg._id), + packageIds, isDone: result.isDone, continueCursor: result.continueCursor, }; @@ -1600,12 +1667,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( @@ -1643,6 +1722,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", { @@ -1652,6 +1745,9 @@ async function banUserWithActor( targetId: targetUserId, metadata: { hiddenSkills: hiddenCount, + deletedPackages: deletedPackageCount, + revokedPackagePublishTokens, + scheduledPackages, deletedSkillComments: deletedComments.skillComments, deletedSoulComments: deletedComments.soulComments, reason: reason || undefined, @@ -1711,12 +1807,29 @@ async function unbanUserWithActor( const restoredCount = restoreSkillsResult.restoredCount ?? 0; const scheduledSkills = restoreSkillsResult.scheduled ?? false; + const restorePackagesResult = ((await ctx.runMutation( + internal.packages.restoreOwnedPackagesForUnbanBatchInternal, + { + actorUserId: actor._id, + ownerUserId: targetUserId, + bannedAt, + cursor: undefined, + }, + )) ?? {}) as { restoredCount?: number; scheduled?: boolean }; + const restoredPackageCount = restorePackagesResult.restoredCount ?? 0; + const scheduledPackages = restorePackagesResult.scheduled ?? false; + await ctx.db.insert("auditLogs", { actorUserId: actor._id, action: "user.unban", targetType: "user", targetId: targetUserId, - metadata: { reason: reason || undefined, restoredSkills: restoredCount }, + metadata: { + reason: reason || undefined, + restoredSkills: restoredCount, + restoredPackages: restoredPackageCount, + scheduledPackages, + }, createdAt: now, }); @@ -2071,6 +2184,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, }); @@ -2079,6 +2206,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 bd11536192..3676fb78f2 100644 --- a/specs/security-moderation.md +++ b/specs/security-moderation.md @@ -218,6 +218,12 @@ 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 - retimestamps already ban-hidden owned skills to the current ban marker so a later matching unban can restore them - soft-deletes all authored skill comments + soul comments @@ -225,6 +231,8 @@ See also: [acceptable-usage.md](./acceptable-usage.md) for the marketplace polic - 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. - Stale unban restore batches must stop if the user was banned again before a later page runs. - Optional ban reason is stored in `users.banReason` and audit logs. diff --git a/src/routes/management.tsx b/src/routes/management.tsx index 715155f1e1..8417e26240 100644 --- a/src/routes/management.tsx +++ b/src/routes/management.tsx @@ -668,7 +668,11 @@ export 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 @@ export 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 @@ export 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);