Compare commits

...
12 changed files with 512 additions and 46 deletions
+30
View File
@@ -97,6 +97,36 @@ export async function getPublisherByHandle(
}
}
export async function getUserByHandleOrPersonalPublisher(
ctx: DbCtx,
handle: string | undefined | null,
) {
const normalized = normalizePublisherHandle(handle);
if (!normalized) return null;
const user = await ctx.db
.query("users")
.withIndex("handle", (q) => q.eq("handle", normalized))
.unique();
if (user) return user;
const publisher = await getPublisherByHandle(ctx, normalized);
if (!publisher || !isPublisherActive(publisher) || publisher.kind !== "user" || !publisher.linkedUserId) {
return null;
}
return await ctx.db.get(publisher.linkedUserId);
}
export async function getActiveUserByHandleOrPersonalPublisher(
ctx: DbCtx,
handle: string | undefined | null,
) {
const user = await getUserByHandleOrPersonalPublisher(ctx, handle);
if (!user || user.deletedAt || user.deactivatedAt) return null;
return user;
}
export async function getPersonalPublisherForUser(
ctx: DbCtx,
userId: Id<"users">,
+183
View File
@@ -169,6 +169,189 @@ describe("publishers membership controls", () => {
),
).rejects.toThrow("Publisher must have at least one owner");
});
it("adds a member when the requested handle resolves via a personal publisher", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
const publisherMembers: Array<Record<string, unknown>> = [
{
_id: "publisherMembers:owner",
publisherId: "publishers:org",
userId: "users:owner",
role: "owner",
},
];
const insert = vi.fn(async (table: string, value: Record<string, unknown>) => {
if (table === "publisherMembers") {
const row = { _id: "publisherMembers:new", ...value };
publisherMembers.push(row);
return row._id;
}
if (table === "auditLogs") return "auditLogs:1";
if (table === "publishers") return "publishers:jaredforreal";
throw new Error(`unexpected insert ${table}`);
});
const ctx = {
db: {
get: vi.fn(async (id: string) => {
if (id === "users:owner") return { _id: id };
if (id === "users:jared") {
return {
_id: id,
_creationTime: 1,
handle: undefined,
name: "JaredForReal",
displayName: "Jared",
trustedPublisher: false,
createdAt: 1,
updatedAt: 1,
};
}
if (id === "publishers:org") {
return {
_id: id,
kind: "org",
handle: "zai-org",
displayName: "ZAI Org",
};
}
if (id === "publishers:jaredforreal") {
return {
_id: id,
_creationTime: 1,
kind: "user",
handle: "jaredforreal",
displayName: "Jared",
linkedUserId: "users:jared",
trustedPublisher: false,
createdAt: 1,
updatedAt: 1,
};
}
return null;
}),
query: vi.fn((table: string) => {
if (table === "publisherMembers") {
return {
withIndex: vi.fn((indexName: string, builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown) => {
if (indexName !== "by_publisher_user") {
throw new Error(`unexpected index ${indexName}`);
}
let publisherId = "";
let userId = "";
const q = {
eq: (field: string, value: string) => {
if (field === "publisherId") publisherId = value;
if (field === "userId") userId = value;
return q;
},
};
builder?.(q);
return {
unique: vi.fn(async () =>
publisherMembers.find(
(member) => member.publisherId === publisherId && member.userId === userId,
) ?? null,
),
};
}),
};
}
if (table === "users") {
return {
withIndex: vi.fn((indexName: string, builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown) => {
if (indexName !== "handle") {
throw new Error(`unexpected index ${indexName}`);
}
let handle = "";
const q = {
eq: (field: string, value: string) => {
if (field === "handle") handle = value;
return q;
},
};
builder?.(q);
return {
unique: vi.fn(async () => {
if (handle === "owner") return { _id: "users:owner", handle: "owner" };
return null;
}),
};
}),
};
}
if (table === "publishers") {
return {
withIndex: vi.fn((indexName: string, builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown) => {
let handle = "";
let linkedUserId = "";
const q = {
eq: (field: string, value: string) => {
if (field === "handle") handle = value;
if (field === "linkedUserId") linkedUserId = value;
return q;
},
};
builder?.(q);
return {
unique: vi.fn(async () => {
if (indexName === "by_handle" && handle === "jaredforreal") {
return {
_id: "publishers:jaredforreal",
_creationTime: 1,
kind: "user",
handle: "jaredforreal",
displayName: "Jared",
linkedUserId: "users:jared",
trustedPublisher: false,
createdAt: 1,
updatedAt: 1,
};
}
if (indexName === "by_linked_user" && linkedUserId === "users:jared") {
return {
_id: "publishers:jaredforreal",
_creationTime: 1,
kind: "user",
handle: "jaredforreal",
displayName: "Jared",
linkedUserId: "users:jared",
trustedPublisher: false,
createdAt: 1,
updatedAt: 1,
};
}
return null;
}),
};
}),
};
}
throw new Error(`unexpected table ${table}`);
}),
insert,
patch: vi.fn(),
delete: vi.fn(),
replace: vi.fn(),
normalizeId: vi.fn(),
},
};
await expect(
addMemberHandler(
ctx as never,
{ publisherId: "publishers:org", userHandle: "jaredforreal", role: "admin" } as never,
),
).resolves.toEqual({ ok: true });
expect(insert).toHaveBeenCalledWith(
"publisherMembers",
expect.objectContaining({
publisherId: "publishers:org",
userId: "users:jared",
role: "admin",
}),
);
});
});
describe("publisher bootstrap", () => {
+3 -5
View File
@@ -6,6 +6,7 @@ import { internalMutation, internalQuery, mutation, query } from "./functions";
import { assertAdmin, requireUser } from "./lib/access";
import {
ensurePersonalPublisherForUser,
getActiveUserByHandleOrPersonalPublisher,
getPublisherByHandle,
getPublisherMembership,
getPersonalPublisherForUserOrFallback,
@@ -584,11 +585,8 @@ export const addMember = mutation({
}
const handle = normalizePublisherHandle(args.userHandle);
if (!handle) throw new ConvexError("User handle is required");
const targetUser = await ctx.db
.query("users")
.withIndex("handle", (q) => q.eq("handle", handle))
.unique();
if (!targetUser || targetUser.deletedAt || targetUser.deactivatedAt) {
const targetUser = await getActiveUserByHandleOrPersonalPublisher(ctx, handle);
if (!targetUser) {
throw new ConvexError(`User "@${handle}" not found`);
}
await ensurePersonalPublisherForUser(ctx, targetUser);
+96 -1
View File
@@ -61,7 +61,7 @@ describe("skillTransfers", () => {
if (table === "users") {
return {
withIndex: () => ({
first: async () => ({ _id: "users:2", handle: "alice", displayName: "Alice" }),
unique: async () => ({ _id: "users:2", handle: "alice", displayName: "Alice" }),
}),
};
}
@@ -102,6 +102,101 @@ describe("skillTransfers", () => {
);
});
it("requestTransferInternal resolves recipient via personal publisher handle", async () => {
const insert = vi.fn(async (table: string) => {
if (table === "skillOwnershipTransfers") return "skillOwnershipTransfers:new";
return "auditLogs:1";
});
const result = (await requestTransferInternalHandler(
{
db: {
normalizeId: vi.fn(),
get: vi.fn(async (id: string) => {
if (id === "users:1") return { _id: "users:1", handle: "owner" };
if (id === "users:2") {
return {
_id: "users:2",
handle: undefined,
name: "Alice",
displayName: "Alice",
};
}
if (id === "skills:1") {
return {
_id: "skills:1",
slug: "demo",
displayName: "Demo",
ownerUserId: "users:1",
};
}
if (id === "publishers:alice") {
return {
_id: "publishers:alice",
kind: "user",
handle: "alice",
displayName: "Alice",
linkedUserId: "users:2",
};
}
return null;
}),
query: vi.fn((table: string) => {
if (table === "users") {
return {
withIndex: () => ({
unique: async () => null,
}),
};
}
if (table === "publishers") {
return {
withIndex: () => ({
unique: async () => ({
_id: "publishers:alice",
kind: "user",
handle: "alice",
displayName: "Alice",
linkedUserId: "users:2",
}),
}),
};
}
if (table === "skillOwnershipTransfers") {
return {
withIndex: () => ({
collect: async () => [],
}),
};
}
throw new Error(`unexpected table ${table}`);
}),
patch: vi.fn(async () => {}),
insert,
},
} as never,
{
actorUserId: "users:1",
skillId: "skills:1",
toUserHandle: "@alice",
} as never,
)) as { ok: boolean; transferId: string };
expect(result).toEqual(
expect.objectContaining({
ok: true,
transferId: "skillOwnershipTransfers:new",
toUserHandle: "alice",
}),
);
expect(insert).toHaveBeenCalledWith(
"skillOwnershipTransfers",
expect.objectContaining({
toUserId: "users:2",
}),
);
});
it("acceptTransferInternal updates skill and alias ownership to the recipient publisher", async () => {
const patch = vi.fn(async () => {});
const insert = vi.fn(async () => "auditLogs:1");
+6 -6
View File
@@ -1,7 +1,10 @@
import { v } from "convex/values";
import type { Doc, Id } from "./_generated/dataModel";
import { internalMutation, internalQuery } from "./functions";
import { ensurePersonalPublisherForUser } from "./lib/publishers";
import {
ensurePersonalPublisherForUser,
getActiveUserByHandleOrPersonalPublisher,
} from "./lib/publishers";
const TRANSFER_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
type TransferDoc = Doc<"skillOwnershipTransfers">;
@@ -112,11 +115,8 @@ export const requestTransferInternal = internalMutation({
const toHandle = normalizeHandle(args.toUserHandle);
if (!toHandle) throw new Error("toUserHandle required");
const toUser = await ctx.db
.query("users")
.withIndex("handle", (q) => q.eq("handle", toHandle))
.first();
if (!toUser || toUser.deletedAt || toUser.deactivatedAt) throw new Error("User not found");
const toUser = await getActiveUserByHandleOrPersonalPublisher(ctx, toHandle);
if (!toUser) throw new Error("User not found");
if (toUser._id === args.actorUserId) throw new Error("Cannot transfer to yourself");
const activePending = await getActivePendingTransferForSkill(ctx, args.skillId, now);
+13 -1
View File
@@ -37,6 +37,7 @@ function makeCtx() {
slug: "padel",
displayName: "Padel",
ownerUserId: "users:owner",
ownerPublisherId: "publishers:local",
latestVersionId: "skillVersions:1",
manualOverride: {
verdict: "clean",
@@ -103,6 +104,15 @@ function makeCtx() {
switch (id) {
case "skillVersions:1":
return latestVersion;
case "publishers:local":
return {
_id: "publishers:local",
_creationTime: 1,
kind: "user",
handle: "local-publisher",
displayName: "Local Dev",
linkedUserId: "users:owner",
};
case "users:owner":
return {
_id: "users:owner",
@@ -150,7 +160,7 @@ describe("getBySlugForStaff audit logs", () => {
vi.mocked(requireUser).mockReset();
});
it("returns reviewer info and recent audit logs with actor handles", async () => {
it("returns publisher-backed owner info plus recent audit logs with actor handles", async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: "users:moderator",
user: { _id: "users:moderator", role: "moderator" },
@@ -162,6 +172,7 @@ describe("getBySlugForStaff audit logs", () => {
slug: "padel",
auditLogLimit: 5,
})) as {
owner: { handle?: string | null } | null;
overrideReviewer: { handle?: string | null } | null;
auditLogs: Array<{
actor: { handle?: string | null } | null;
@@ -171,6 +182,7 @@ describe("getBySlugForStaff audit logs", () => {
expect(getSkillBadgeMap).toHaveBeenCalled();
expect(auditTake).toHaveBeenCalledWith(5);
expect(result.owner?.handle).toBe("local-publisher");
expect(result.overrideReviewer?.handle).toBe("moddy");
expect(result.auditLogs).toHaveLength(2);
expect(result.auditLogs[0]?.action).toBe("skill.manual_override.set");
+21 -7
View File
@@ -1632,7 +1632,11 @@ export const getBySlugForStaff = query({
if (!skill) return null;
const latestVersion = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null;
const owner = toPublicUser(await ctx.db.get(skill.ownerUserId));
const ownerPublisher = await getOwnerPublisher(ctx, {
ownerPublisherId: skill.ownerPublisherId,
ownerUserId: skill.ownerUserId,
});
const owner = toPublicPublisher(ownerPublisher);
const badges = await getSkillBadgeMap(ctx, skill._id);
const rawAuditLogs = await ctx.db
.query("auditLogs")
@@ -1659,10 +1663,20 @@ export const getBySlugForStaff = query({
}));
const forkOfSkill = skill.forkOf?.skillId ? await ctx.db.get(skill.forkOf.skillId) : null;
const forkOfOwner = forkOfSkill ? await ctx.db.get(forkOfSkill.ownerUserId) : null;
const forkOfOwner = forkOfSkill
? await getOwnerPublisher(ctx, {
ownerPublisherId: forkOfSkill.ownerPublisherId,
ownerUserId: forkOfSkill.ownerUserId,
})
: null;
const canonicalSkill = skill.canonicalSkillId ? await ctx.db.get(skill.canonicalSkillId) : null;
const canonicalOwner = canonicalSkill ? await ctx.db.get(canonicalSkill.ownerUserId) : null;
const canonicalOwner = canonicalSkill
? await getOwnerPublisher(ctx, {
ownerPublisherId: canonicalSkill.ownerPublisherId,
ownerUserId: canonicalSkill.ownerUserId,
})
: null;
return {
requestedSlug: resolved.requestedSlug,
@@ -1681,8 +1695,8 @@ export const getBySlugForStaff = query({
displayName: forkOfSkill.displayName,
},
owner: {
handle: forkOfOwner?.handle ?? forkOfOwner?.name ?? null,
userId: forkOfOwner?._id ?? null,
handle: forkOfOwner?.handle ?? null,
userId: forkOfOwner?.linkedUserId ?? null,
},
}
: null,
@@ -1693,8 +1707,8 @@ export const getBySlugForStaff = query({
displayName: canonicalSkill.displayName,
},
owner: {
handle: canonicalOwner?.handle ?? canonicalOwner?.name ?? null,
userId: canonicalOwner?._id ?? null,
handle: canonicalOwner?.handle ?? null,
userId: canonicalOwner?.linkedUserId ?? null,
},
}
: null,
+48
View File
@@ -616,6 +616,54 @@ describe("users.getByHandle", () => {
bio: "Profile",
});
});
it("does not resolve a deleted personal publisher handle", async () => {
const userUnique = vi.fn(async () => null);
const publisherUnique = vi.fn(async () => ({
_id: "publishers:jaredforreal",
kind: "user",
handle: "jaredforreal",
linkedUserId: "users:owner",
deletedAt: 1_700_000_000_000,
displayName: "Jared",
}));
const get = vi.fn(async () => {
throw new Error("linked user should not be loaded for inactive publishers");
});
const result = await getByHandleHandler(
{
db: {
query: vi.fn((table: string) => {
if (table === "users") {
return {
withIndex: (name: string) => {
if (name !== "handle") throw new Error(`Unexpected users index ${name}`);
return { unique: userUnique };
},
};
}
if (table === "publishers") {
return {
withIndex: (name: string) => {
if (name !== "by_handle") throw new Error(`Unexpected publishers index ${name}`);
return { unique: publisherUnique };
},
};
}
throw new Error(`Unexpected table ${table}`);
}),
get,
},
} as never,
{ handle: "jaredforreal" },
);
expect(userUnique).toHaveBeenCalledOnce();
expect(publisherUnique).toHaveBeenCalledOnce();
expect(get).not.toHaveBeenCalled();
expect(result).toBeNull();
});
});
describe("users.syncGitHubProfileInternal", () => {
+4 -22
View File
@@ -8,8 +8,9 @@ import { assertAdmin, assertModerator, requireUser } from "./lib/access";
import { syncGitHubProfile } from "./lib/githubAccount";
import {
ensurePersonalPublisherForUser,
getActiveUserByHandleOrPersonalPublisher,
getPublisherByHandle,
normalizePublisherHandle,
getUserByHandleOrPersonalPublisher,
} from "./lib/publishers";
import { toPublicUser } from "./lib/public";
import {
@@ -40,12 +41,7 @@ export const getByIdInternal = internalQuery({
export const getByHandleInternal = internalQuery({
args: { handle: v.string() },
handler: async (ctx, args) => {
const normalizedHandle = normalizePublisherHandle(args.handle);
if (!normalizedHandle) return null;
return await ctx.db
.query("users")
.withIndex("handle", (q) => q.eq("handle", normalizedHandle))
.unique();
return await getUserByHandleOrPersonalPublisher(ctx, args.handle);
},
});
@@ -400,21 +396,7 @@ function clampInt(value: number, min: number, max: number) {
export const getByHandle = query({
args: { handle: v.string() },
handler: async (ctx, args) => {
const normalizedHandle = normalizePublisherHandle(args.handle);
if (!normalizedHandle) return null;
const user = await ctx.db
.query("users")
.withIndex("handle", (q) => q.eq("handle", normalizedHandle))
.unique();
if (user) return toPublicUser(user);
const publisher = await getPublisherByHandle(ctx, normalizedHandle);
if (!publisher || publisher.kind !== "user" || !publisher.linkedUserId) return null;
const linkedUser = await ctx.db.get(publisher.linkedUserId);
if (!linkedUser) return null;
return toPublicUser(linkedUser);
return toPublicUser(await getActiveUserByHandleOrPersonalPublisher(ctx, args.handle));
},
});
+99
View File
@@ -7,6 +7,7 @@ const navigateMock = vi.fn();
const useAuthStatusMock = vi.fn();
vi.mock("@tanstack/react-router", () => ({
Link: ({ children }: { children: unknown }) => children,
useNavigate: () => navigateMock,
}));
@@ -258,6 +259,104 @@ describe("SkillDetailPage", () => {
});
});
it("does not redirect when a staff owner handle only differs by case", async () => {
useAuthStatusMock.mockReturnValue({
isAuthenticated: true,
isLoading: false,
me: { _id: "users:staff", role: "moderator" },
});
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
if (args === "skip") return undefined;
if (args && typeof args === "object" && "skillId" in args) return [];
if (args && typeof args === "object" && "slug" in args) {
return {
skill: {
_id: "skills:1",
slug: "weather",
displayName: "Weather",
summary: "Get current weather.",
ownerUserId: "users:1",
ownerPublisherId: "publishers:steipete",
tags: {},
stats: { stars: 0, downloads: 0 },
},
owner: {
_id: "publishers:steipete",
_creationTime: 0,
kind: "user",
handle: "SteiPete",
displayName: "Peter",
linkedUserId: "users:1",
},
latestVersion: { _id: "skillVersions:1", version: "1.0.0", parsed: {}, files: [] },
forkOf: null,
canonical: null,
};
}
return undefined;
});
render(
<SkillDetailPage
slug="weather"
canonicalOwner="steipete"
initialData={{
result: {
skill: {
_id: skillId,
_creationTime: 0,
slug: "weather",
displayName: "Weather",
summary: "Get current weather.",
ownerUserId: ownerId,
ownerPublisherId,
tags: {},
badges: {},
stats: {
stars: 12,
downloads: 34,
installsCurrent: 5,
installsAllTime: 8,
versions: 1,
comments: 0,
},
createdAt: 0,
updatedAt: 0,
},
owner: {
_id: ownerPublisherId,
_creationTime: 0,
kind: "user",
handle: "steipete",
displayName: "Peter",
linkedUserId: ownerId,
},
latestVersion: {
_id: versionId,
_creationTime: 0,
skillId,
version: "1.0.0",
fingerprint: "abc",
changelog: "Initial release",
parsed: { license: "MIT-0", frontmatter: {} },
files: [],
createdBy: ownerId,
createdAt: 0,
},
forkOf: null,
canonical: null,
},
readme: "# Weather",
readmeError: null,
}}
/>,
);
expect(screen.queryByText(/Loading skill/i)).toBeNull();
expect(screen.getAllByText("Weather").length).toBeGreaterThan(0);
expect(navigateMock).not.toHaveBeenCalled();
});
it("opens report dialog for authenticated users", async () => {
useAuthStatusMock.mockReturnValue({
isAuthenticated: true,
+4 -2
View File
@@ -144,12 +144,14 @@ export function SkillDetailPage({
) as Array<{ _id: Id<"skills">; slug: string; displayName: string }> | undefined;
const ownerHandle = owner?.handle ?? null;
const ownerParam = ownerHandle ?? (owner?._id ? String(owner._id) : null);
const ownerParam = ownerHandle?.trim().toLowerCase() || (owner?._id ? String(owner._id) : null);
const canonicalOwnerParam =
typeof canonicalOwner === "string" ? canonicalOwner.trim().toLowerCase() : null;
const wantsCanonicalRedirect = Boolean(
ownerParam &&
((result?.resolvedSlug && result.resolvedSlug !== slug) ||
redirectToCanonical ||
(typeof canonicalOwner === "string" && canonicalOwner && canonicalOwner !== ownerParam)),
(canonicalOwnerParam && canonicalOwnerParam !== ownerParam)),
);
const forkOf = result?.forkOf ?? null;
+5 -2
View File
@@ -72,8 +72,11 @@ type SkillBySlugResult = {
} | null;
} | null;
function resolveOwnerParam(handle: string | null | undefined, ownerId?: Id<"users">) {
return handle?.trim() || (ownerId ? String(ownerId) : "unknown");
function resolveOwnerParam(
handle: string | null | undefined,
ownerId?: Id<"users"> | Id<"publishers">,
) {
return handle?.trim().toLowerCase() || (ownerId ? String(ownerId) : "unknown");
}
function promptBanReason(label: string) {