mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
fix(skills): require current owner provenance for slug release
This commit is contained in:
@@ -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 () => {});
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user