fix(skills): require current owner provenance for slug release

This commit is contained in:
Peter Steinberger
2026-05-09 09:52:48 +01:00
parent c451ede0b3
commit 2d0564d1f9
4 changed files with 279 additions and 5 deletions
+218
View File
@@ -448,6 +448,17 @@ describe("skills anti-spam guards", () => {
trustedPublisher: true,
role: "user",
handle: "caller",
personalPublisherId: "publishers:caller",
};
}
if (id === "publishers:caller") {
return {
_id: "publishers:caller",
kind: "user",
handle: "caller",
linkedUserId: "users:caller",
deletedAt: undefined,
deactivatedAt: undefined,
};
}
return null;
@@ -608,6 +619,213 @@ describe("skills anti-spam guards", () => {
);
});
it("does not release a stale owner reservation after moderation owns the current hide", async () => {
const now = Date.now();
const storedSkills = new Map<string, Record<string, unknown>>([
[
"skills:stale",
{
_id: "skills:stale",
slug: "moderated-demo",
displayName: "Moderated Demo",
ownerUserId: "users:previous",
ownerPublisherId: "publishers:previous",
softDeletedAt: now - 31 * 24 * 60 * 60 * 1000,
hiddenBy: undefined,
unpublishedSlugReservedUntil: now - 1_000,
moderationStatus: "hidden",
moderationFlags: ["blocked.malware"],
moderationVerdict: "malicious",
tags: {},
stats: {
downloads: 0,
installsCurrent: 0,
installsAllTime: 0,
stars: 0,
versions: 1,
comments: 0,
},
createdAt: now - 40 * 24 * 60 * 60 * 1000,
updatedAt: now - 31 * 24 * 60 * 60 * 1000,
},
],
]);
const patch = vi.fn(async () => {});
const insert = vi.fn(async () => "unexpected");
const db = {
get: vi.fn(async (tableOrId: string, maybeId?: string) => {
const id = maybeId ?? tableOrId;
if (storedSkills.has(id)) return storedSkills.get(id);
if (id === "users:caller") {
return {
_id: "users:caller",
_creationTime: now - 60 * 24 * 60 * 60 * 1000,
createdAt: now - 60 * 24 * 60 * 60 * 1000,
deletedAt: undefined,
deactivatedAt: undefined,
trustedPublisher: true,
role: "user",
handle: "caller",
personalPublisherId: "publishers:caller",
};
}
if (id === "publishers:caller") {
return {
_id: "publishers:caller",
kind: "user",
handle: "caller",
linkedUserId: "users:caller",
deletedAt: undefined,
deactivatedAt: undefined,
};
}
if (id === "publishers:previous") {
return {
_id: "publishers:previous",
kind: "user",
handle: "previous",
linkedUserId: "users:previous",
deletedAt: undefined,
deactivatedAt: undefined,
};
}
if (id === "users:previous") {
return {
_id: "users:previous",
deletedAt: undefined,
deactivatedAt: undefined,
handle: "previous",
};
}
return null;
}),
query: vi.fn((table: string) => {
const globalStatsQuery = buildGlobalStatsQuery(table);
if (globalStatsQuery) return globalStatsQuery;
const digestQuery = buildDigestQuery(table);
if (digestQuery) return digestQuery;
if (table === "skills") {
return {
withIndex: (name: string, build?: (q: ReturnType<typeof chainEq>) => unknown) => {
const constraints: Record<string, unknown> = {};
build?.(chainEq(constraints));
if (name === "by_slug") {
return {
unique: async () =>
Array.from(storedSkills.values()).find(
(skill) => skill.slug === constraints.slug,
) ?? null,
};
}
if (name === "by_owner") {
return {
order: () => ({
take: async () => [],
}),
};
}
throw new Error(`unexpected skills index ${name}`);
},
};
}
if (table === "reservedSlugs") {
return {
withIndex: (name: string) => {
if (name === "by_slug_active_deletedAt") {
return { order: () => ({ take: async () => [] }) };
}
throw new Error(`unexpected reservedSlugs index ${name}`);
},
};
}
if (table === "skillSlugAliases") {
return {
withIndex: (name: string) => {
if (name !== "by_slug") throw new Error(`unexpected skillSlugAliases index ${name}`);
return { unique: async () => null };
},
};
}
if (table === "authAccounts") {
return {
withIndex: (name: string) => {
if (name !== "userIdAndProvider") throw new Error(`unexpected auth index ${name}`);
return { unique: async () => null };
},
};
}
if (table === "publishers") {
return {
withIndex: (name: string, build?: (q: ReturnType<typeof chainEq>) => unknown) => {
const constraints: Record<string, unknown> = {};
build?.(chainEq(constraints));
if (name === "by_handle") return { unique: async () => null };
if (name === "by_linked_user") {
return {
unique: async () =>
constraints.linkedUserId === "users:caller"
? {
_id: "publishers:caller",
kind: "user",
handle: "caller",
linkedUserId: "users:caller",
deletedAt: undefined,
deactivatedAt: undefined,
}
: null,
};
}
throw new Error(`unexpected publishers index ${name}`);
},
};
}
if (table === "publisherMembers") {
return {
withIndex: (name: string) => {
if (name !== "by_publisher_user") {
throw new Error(`unexpected publisherMembers index ${name}`);
}
return {
unique: async () => ({
_id: "publisherMembers:caller",
publisherId: "publishers:caller",
userId: "users:caller",
role: "owner",
}),
};
},
};
}
throw new Error(`unexpected table ${table}`);
}),
patch,
insert,
normalizeId: vi.fn((tableName: string, id: string) =>
id.startsWith(`${tableName}:`) ? id : null,
),
};
await expect(
insertVersionHandler(
{ db, scheduler: { runAfter: vi.fn() } } as never,
createPublishArgs({
userId: "users:caller",
slug: "moderated-demo",
bypassNewSkillRateLimit: true,
}) as never,
),
).rejects.toThrow(/Slug is already taken/);
expect(patch).not.toHaveBeenCalledWith(
"skills",
"skills:stale",
expect.objectContaining({
slug: expect.stringMatching(/^__unpublished_/),
}),
);
expect(insert).not.toHaveBeenCalledWith("skills", expect.anything());
});
it("heals ownership when conflicting owner is deleted but GitHub identity matches", async () => {
let authAccountLookupCount = 0;
const patch = vi.fn(async () => {});
+38
View File
@@ -246,6 +246,44 @@ describe("skills.checkSlugAvailability", () => {
});
});
it("returns taken when a stale owner reservation remains on a moderation hide", async () => {
const now = 1_700_000_000_000;
vi.spyOn(Date, "now").mockReturnValue(now);
vi.mocked(getAuthUserId).mockResolvedValue("users:caller" as never);
const result = (await checkSlugAvailabilityHandler(
createCtx({
skill: {
_id: "skills:1",
slug: "moderated-skill",
ownerUserId: "users:owner",
softDeletedAt: now - 120_000,
hiddenBy: undefined,
unpublishedSlugReservedUntil: now - 60_000,
moderationStatus: "hidden",
moderationFlags: ["blocked.malware"],
},
owner: {
_id: "users:owner",
handle: "owner",
},
}) as never,
{ slug: "moderated-skill" } as never,
)) as {
available: boolean;
reason: string;
message: string;
url: string | null;
};
expect(result).toEqual({
available: false,
reason: "taken",
message: "Slug is already taken. Choose a different slug.",
url: null,
});
});
it("returns taken with URL for public collisions", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:caller" as never);
+20 -4
View File
@@ -606,13 +606,11 @@ function getUnpublishedSlugReservationExpiresAt(
>,
) {
if (!skill.softDeletedAt) return null;
if (skill.hiddenBy !== skill.ownerUserId) return null;
if (typeof skill.unpublishedSlugReservedUntil === "number") {
return skill.unpublishedSlugReservedUntil;
}
if (skill.hiddenBy === skill.ownerUserId) {
return skill.softDeletedAt + UNPUBLISHED_SLUG_RESERVATION_MS;
}
return null;
return skill.softDeletedAt + UNPUBLISHED_SLUG_RESERVATION_MS;
}
function buildReleasedUnpublishedSkillSlug(skill: Pick<Doc<"skills">, "_id">, attempt = 0) {
@@ -2971,6 +2969,9 @@ export const report = mutation({
}),
hiddenAt: now,
lastReviewedAt: now,
unpublishedSlugReservedUntil: undefined,
unpublishedSlugReleasedAt: undefined,
unpublishedOriginalSlug: undefined,
});
}
@@ -3279,6 +3280,9 @@ async function applySkillReportFinalAction(
moderationNotes: trimManualOverrideNote(params.note),
hiddenAt: params.now,
hiddenBy: params.actorUserId,
unpublishedSlugReservedUntil: undefined,
unpublishedSlugReleasedAt: undefined,
unpublishedOriginalSlug: undefined,
lastReviewedAt: params.now,
updatedAt: params.now,
};
@@ -5571,6 +5575,9 @@ export const escalateSkillByIdInternal = internalMutation({
}),
hiddenAt: moderationStatus === "hidden" ? now : undefined,
hiddenBy: undefined,
unpublishedSlugReservedUntil: undefined,
unpublishedSlugReleasedAt: undefined,
unpublishedOriginalSlug: undefined,
lastReviewedAt: moderationStatus === "hidden" ? now : undefined,
updatedAt: now,
};
@@ -6238,6 +6245,9 @@ export const approveSkillByHashInternal = internalMutation({
}),
hiddenAt: nextModerationStatus === "hidden" ? now : undefined,
hiddenBy: undefined,
unpublishedSlugReservedUntil: undefined,
unpublishedSlugReleasedAt: undefined,
unpublishedOriginalSlug: undefined,
lastReviewedAt: nextModerationStatus === "hidden" ? now : undefined,
updatedAt: now,
};
@@ -6356,6 +6366,9 @@ export const escalateByVtInternal = internalMutation({
// "malicious", both of which the undelete gate also enforces.
basePatch.hiddenAt = now;
basePatch.hiddenBy = undefined;
basePatch.unpublishedSlugReservedUntil = undefined;
basePatch.unpublishedSlugReleasedAt = undefined;
basePatch.unpublishedOriginalSlug = undefined;
basePatch.lastReviewedAt = now;
} else if (nextVerdict === "clean" && !alreadyBlocked) {
basePatch.moderationStatus = "active";
@@ -8833,6 +8846,9 @@ export const hideSkillForSecurityRedactionInternal = internalMutation({
moderationNotes: note,
hiddenAt: now,
hiddenBy: actor._id,
unpublishedSlugReservedUntil: undefined,
unpublishedSlugReleasedAt: undefined,
unpublishedOriginalSlug: undefined,
lastReviewedAt: now,
updatedAt: now,
};
+3 -1
View File
@@ -122,7 +122,9 @@ skill. That internal namespace must remain outside the public slug validator and
the release path must collision-check both skill slugs and historical aliases
before patching the hidden row. The audit actor for a lazy release is the caller
who triggered the post-expiry claim; the previous owner is preserved in audit
metadata. Moderator/security hides are not owner unpublishes and do not expire.
metadata. The release path may honor a stored reservation timestamp only while
the current hide provenance is still owner-initiated (`hiddenBy === ownerUserId`).
Moderator/security hides are not owner unpublishes and do not expire.
## Adding an official extension