Compare commits

...
31 changed files with 943 additions and 60 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);
+237 -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,242 @@ 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");
const newPublisher = {
_id: "publishers:alice",
handle: "alice",
displayName: "Alice",
linkedUserId: "users:2",
trustedPublisher: false,
};
const existingMember = {
_id: "publisherMembers:1",
publisherId: "publishers:alice",
userId: "users:2",
role: "owner",
};
const aliases = [
{
_id: "skillSlugAliases:1",
slug: "demo-old",
skillId: "skills:1",
ownerUserId: "users:1",
ownerPublisherId: "publishers:owner",
},
{
_id: "skillSlugAliases:2",
slug: "demo-legacy",
skillId: "skills:1",
ownerUserId: "users:1",
ownerPublisherId: "publishers:owner",
},
];
const result = (await acceptTransferInternalHandler(
{
db: {
normalizeId: vi.fn(),
get: vi.fn(async (id: string) => {
if (id === "users:2") {
return {
_id: "users:2",
handle: "alice",
personalPublisherId: "publishers:alice",
trustedPublisher: false,
};
}
if (id === "skillOwnershipTransfers:1") {
return {
_id: "skillOwnershipTransfers:1",
skillId: "skills:1",
fromUserId: "users:1",
toUserId: "users:2",
status: "pending",
requestedAt: Date.now() - 1_000,
expiresAt: Date.now() + 10_000,
};
}
if (id === "skills:1") {
return {
_id: "skills:1",
slug: "demo",
ownerUserId: "users:1",
ownerPublisherId: "publishers:owner",
};
}
if (id === "publishers:alice") {
return newPublisher;
}
return null;
}),
query: vi.fn((table: string) => {
if (table === "skillSlugAliases") {
return {
withIndex: (indexName: string) => {
expect(indexName).toBe("by_skill");
return {
collect: async () => aliases,
};
},
};
}
if (table === "publishers") {
return {
withIndex: (indexName: string) => {
expect(indexName).toBe("by_handle");
return {
unique: async () => newPublisher,
};
},
};
}
if (table === "publisherMembers") {
return {
withIndex: (indexName: string) => {
expect(indexName).toBe("by_publisher_user");
return {
unique: async () => existingMember,
};
},
};
}
throw new Error(`unexpected table ${table}`);
}),
patch,
insert,
},
} as never,
{
actorUserId: "users:2",
transferId: "skillOwnershipTransfers:1",
} as never,
)) as { ok: boolean; skillSlug: string };
expect(result).toEqual({ ok: true, skillSlug: "demo" });
expect(patch).toHaveBeenCalledWith(
"skills:1",
expect.objectContaining({
ownerUserId: "users:2",
ownerPublisherId: "publishers:alice",
}),
);
expect(patch).toHaveBeenCalledWith(
"skillSlugAliases:1",
expect.objectContaining({
ownerUserId: "users:2",
ownerPublisherId: "publishers:alice",
}),
);
expect(patch).toHaveBeenCalledWith(
"skillSlugAliases:2",
expect.objectContaining({
ownerUserId: "users:2",
ownerPublisherId: "publishers:alice",
}),
);
expect(patch).toHaveBeenCalledWith(
"skillOwnershipTransfers:1",
expect.objectContaining({ status: "accepted" }),
);
});
it("acceptTransferInternal cancels stale transfer when ownership changed", async () => {
const patch = vi.fn(async () => {});
+24 -6
View File
@@ -1,6 +1,10 @@
import { v } from "convex/values";
import type { Doc, Id } from "./_generated/dataModel";
import { internalMutation, internalQuery } from "./functions";
import {
ensurePersonalPublisherForUser,
getActiveUserByHandleOrPersonalPublisher,
} from "./lib/publishers";
const TRANSFER_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
type TransferDoc = Doc<"skillOwnershipTransfers">;
@@ -111,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);
@@ -157,7 +158,7 @@ export const acceptTransferInternal = internalMutation({
},
handler: async (ctx, args) => {
const now = Date.now();
await requireActiveUserById(ctx, args.actorUserId);
const newOwner = await requireActiveUserById(ctx, args.actorUserId);
const transfer = await validatePendingTransferForActor(ctx, {
transferId: args.transferId,
@@ -173,10 +174,27 @@ export const acceptTransferInternal = internalMutation({
throw new Error("Transfer is no longer valid");
}
const newPublisher = await ensurePersonalPublisherForUser(ctx, newOwner);
if (!newPublisher) throw new Error("Failed to resolve publisher for new owner");
await ctx.db.patch(skill._id, {
ownerUserId: args.actorUserId,
ownerPublisherId: newPublisher._id,
updatedAt: now,
});
const aliases = await ctx.db
.query("skillSlugAliases")
.withIndex("by_skill", (q) => q.eq("skillId", skill._id))
.collect();
for (const alias of aliases) {
await ctx.db.patch(alias._id, {
ownerUserId: args.actorUserId,
ownerPublisherId: newPublisher._id,
updatedAt: now,
});
}
await ctx.db.patch(transfer._id, { status: "accepted", respondedAt: now });
await ctx.db.insert("auditLogs", {
+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,
+166
View File
@@ -18,6 +18,7 @@ const { getAuthUserId } = await import("@convex-dev/auth/server");
const { insertStatEvent } = await import("./skillStatEvents");
const {
ensureHandler,
getByHandle,
list,
searchInternal,
banUserInternal,
@@ -32,6 +33,9 @@ type WrappedHandler<TArgs, TResult> = {
};
const meHandler = (me as unknown as WrappedHandler<Record<string, never>, unknown>)._handler;
const getByHandleHandler = (
getByHandle as unknown as WrappedHandler<{ handle: string }, unknown>
)._handler;
function makeCtx() {
const patch = vi.fn();
@@ -500,6 +504,168 @@ describe("me", () => {
});
});
describe("users.getByHandle", () => {
it("normalizes the incoming handle before querying", async () => {
const unique = vi.fn(async () => ({
_id: "users:owner",
_creationTime: 1,
handle: "jaredforreal",
name: "jaredforreal",
displayName: "Jared",
image: undefined,
bio: undefined,
}));
const result = await getByHandleHandler(
{
db: {
query: vi.fn((table: string) => {
if (table !== "users") throw new Error(`Unexpected table ${table}`);
return {
withIndex: (
name: string,
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
) => {
if (name !== "handle") throw new Error(`Unexpected index ${name}`);
let handle = "";
const q = {
eq: (field: string, value: string) => {
if (field === "handle") handle = value;
return q;
},
};
builder?.(q);
expect(handle).toBe("jaredforreal");
return { unique };
},
};
}),
get: vi.fn(),
},
} as never,
{ handle: " @JaredForReal " },
);
expect(unique).toHaveBeenCalledOnce();
expect(result).toMatchObject({
_id: "users:owner",
handle: "jaredforreal",
displayName: "Jared",
});
});
it("falls back to the linked user for a 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",
displayName: "Jared",
}));
const get = vi.fn(async (id: string) =>
id === "users:owner"
? {
_id: "users:owner",
_creationTime: 1,
handle: "jared",
name: "jaredforreal",
displayName: "Jared",
image: undefined,
bio: "Profile",
}
: null,
);
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).toHaveBeenCalledWith("users:owner");
expect(result).toMatchObject({
_id: "users:owner",
handle: "jared",
name: "jaredforreal",
displayName: "Jared",
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", () => {
it("keeps a derived handle unchanged when the new login is reserved", async () => {
const { ctx, get, patch, query } = makeCtx();
+8 -12
View File
@@ -6,7 +6,12 @@ import type { ActionCtx, MutationCtx } from "./_generated/server";
import { internalAction, internalMutation, internalQuery, mutation, query } from "./functions";
import { assertAdmin, assertModerator, requireUser } from "./lib/access";
import { syncGitHubProfile } from "./lib/githubAccount";
import { ensurePersonalPublisherForUser, getPublisherByHandle } from "./lib/publishers";
import {
ensurePersonalPublisherForUser,
getActiveUserByHandleOrPersonalPublisher,
getPublisherByHandle,
getUserByHandleOrPersonalPublisher,
} from "./lib/publishers";
import { toPublicUser } from "./lib/public";
import {
getLatestActiveReservedHandle,
@@ -36,12 +41,7 @@ export const getByIdInternal = internalQuery({
export const getByHandleInternal = internalQuery({
args: { handle: v.string() },
handler: async (ctx, args) => {
const normalizedHandle = normalizeReservedHandle(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);
},
});
@@ -396,11 +396,7 @@ function clampInt(value: number, min: number, max: number) {
export const getByHandle = query({
args: { handle: v.string() },
handler: async (ctx, args) => {
const user = await ctx.db
.query("users")
.withIndex("handle", (q) => q.eq("handle", args.handle))
.unique();
return toPublicUser(user);
return toPublicUser(await getActiveUserByHandleOrPersonalPublisher(ctx, args.handle));
},
});
+2 -4
View File
@@ -1,7 +1,5 @@
import { type inferred } from "arktype";
export declare const PLATFORM_SKILL_LICENSE: "MIT-0";
export declare const PLATFORM_SKILL_LICENSE_NAME: "MIT No Attribution";
export declare const PLATFORM_SKILL_LICENSE_SUMMARY: "Free to use, modify, and redistribute. No attribution required.";
export declare const PLATFORM_SKILL_LICENSE_URL: "https://spdx.org/licenses/MIT-0.html";
import { PLATFORM_SKILL_LICENSE, PLATFORM_SKILL_LICENSE_NAME, PLATFORM_SKILL_LICENSE_SUMMARY, PLATFORM_SKILL_LICENSE_URL } from "./licenseConstants.js";
export { PLATFORM_SKILL_LICENSE, PLATFORM_SKILL_LICENSE_NAME, PLATFORM_SKILL_LICENSE_SUMMARY, PLATFORM_SKILL_LICENSE_URL, };
export declare const SkillPlatformLicenseSchema: import("arktype/internal/variants/string.ts").StringType<"MIT-0", {}>;
export type SkillPlatformLicense = (typeof SkillPlatformLicenseSchema)[inferred];
+2 -4
View File
@@ -1,7 +1,5 @@
import { type } from "arktype";
export const PLATFORM_SKILL_LICENSE = "MIT-0";
export const PLATFORM_SKILL_LICENSE_NAME = "MIT No Attribution";
export const PLATFORM_SKILL_LICENSE_SUMMARY = "Free to use, modify, and redistribute. No attribution required.";
export const PLATFORM_SKILL_LICENSE_URL = "https://spdx.org/licenses/MIT-0.html";
import { PLATFORM_SKILL_LICENSE, PLATFORM_SKILL_LICENSE_NAME, PLATFORM_SKILL_LICENSE_SUMMARY, PLATFORM_SKILL_LICENSE_URL, } from "./licenseConstants.js";
export { PLATFORM_SKILL_LICENSE, PLATFORM_SKILL_LICENSE_NAME, PLATFORM_SKILL_LICENSE_SUMMARY, PLATFORM_SKILL_LICENSE_URL, };
export const SkillPlatformLicenseSchema = type('"MIT-0"');
//# sourceMappingURL=license.js.map
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"license.js","sourceRoot":"","sources":["../src/license.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiB,IAAI,EAAE,MAAM,SAAS,CAAC;AAE9C,MAAM,CAAC,MAAM,sBAAsB,GAAG,OAAgB,CAAC;AACvD,MAAM,CAAC,MAAM,2BAA2B,GAAG,oBAA6B,CAAC;AACzE,MAAM,CAAC,MAAM,8BAA8B,GACzC,iEAA0E,CAAC;AAC7E,MAAM,CAAC,MAAM,0BAA0B,GAAG,sCAA+C,CAAC;AAE1F,MAAM,CAAC,MAAM,0BAA0B,GAAG,IAAI,CAAC,SAAS,CAAC,CAAC"}
{"version":3,"file":"license.js","sourceRoot":"","sources":["../src/license.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiB,IAAI,EAAE,MAAM,SAAS,CAAC;AAC9C,OAAO,EACL,sBAAsB,EACtB,2BAA2B,EAC3B,8BAA8B,EAC9B,0BAA0B,GAC3B,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EACL,sBAAsB,EACtB,2BAA2B,EAC3B,8BAA8B,EAC9B,0BAA0B,GAC3B,CAAC;AAEF,MAAM,CAAC,MAAM,0BAA0B,GAAG,IAAI,CAAC,SAAS,CAAC,CAAC"}
+4
View File
@@ -0,0 +1,4 @@
export declare const PLATFORM_SKILL_LICENSE: "MIT-0";
export declare const PLATFORM_SKILL_LICENSE_NAME: "MIT No Attribution";
export declare const PLATFORM_SKILL_LICENSE_SUMMARY: "Free to use, modify, and redistribute. No attribution required.";
export declare const PLATFORM_SKILL_LICENSE_URL: "https://spdx.org/licenses/MIT-0.html";
+5
View File
@@ -0,0 +1,5 @@
export const PLATFORM_SKILL_LICENSE = 'MIT-0';
export const PLATFORM_SKILL_LICENSE_NAME = 'MIT No Attribution';
export const PLATFORM_SKILL_LICENSE_SUMMARY = 'Free to use, modify, and redistribute. No attribution required.';
export const PLATFORM_SKILL_LICENSE_URL = 'https://spdx.org/licenses/MIT-0.html';
//# sourceMappingURL=licenseConstants.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"licenseConstants.js","sourceRoot":"","sources":["../src/licenseConstants.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,sBAAsB,GAAG,OAAgB,CAAC;AACvD,MAAM,CAAC,MAAM,2BAA2B,GAAG,oBAA6B,CAAC;AACzE,MAAM,CAAC,MAAM,8BAA8B,GACzC,iEAA0E,CAAC;AAC7E,MAAM,CAAC,MAAM,0BAA0B,GAAG,sCAA+C,CAAC"}
+12
View File
@@ -11,6 +11,18 @@
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./licenseConstants": {
"types": "./dist/licenseConstants.d.ts",
"default": "./dist/licenseConstants.js"
},
"./routes": {
"types": "./dist/routes.d.ts",
"default": "./dist/routes.js"
},
"./textFiles": {
"types": "./dist/textFiles.d.ts",
"default": "./dist/textFiles.js"
}
},
"scripts": {
+12 -5
View File
@@ -1,10 +1,17 @@
import { type inferred, type } from "arktype";
import {
PLATFORM_SKILL_LICENSE,
PLATFORM_SKILL_LICENSE_NAME,
PLATFORM_SKILL_LICENSE_SUMMARY,
PLATFORM_SKILL_LICENSE_URL,
} from "./licenseConstants.js";
export const PLATFORM_SKILL_LICENSE = "MIT-0" as const;
export const PLATFORM_SKILL_LICENSE_NAME = "MIT No Attribution" as const;
export const PLATFORM_SKILL_LICENSE_SUMMARY =
"Free to use, modify, and redistribute. No attribution required." as const;
export const PLATFORM_SKILL_LICENSE_URL = "https://spdx.org/licenses/MIT-0.html" as const;
export {
PLATFORM_SKILL_LICENSE,
PLATFORM_SKILL_LICENSE_NAME,
PLATFORM_SKILL_LICENSE_SUMMARY,
PLATFORM_SKILL_LICENSE_URL,
};
export const SkillPlatformLicenseSchema = type('"MIT-0"');
export type SkillPlatformLicense = (typeof SkillPlatformLicenseSchema)[inferred];
+5
View File
@@ -0,0 +1,5 @@
export const PLATFORM_SKILL_LICENSE = 'MIT-0' as const;
export const PLATFORM_SKILL_LICENSE_NAME = 'MIT No Attribution' as const;
export const PLATFORM_SKILL_LICENSE_SUMMARY =
'Free to use, modify, and redistribute. No attribution required.' as const;
export const PLATFORM_SKILL_LICENSE_URL = 'https://spdx.org/licenses/MIT-0.html' as const;
+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,
+65 -1
View File
@@ -2,7 +2,7 @@
import { render, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getAuthErrorSnapshot, clearAuthError } from "../lib/useAuthError";
import { AuthCodeHandler } from "./AppProviders";
import { AuthCodeHandler, AuthErrorHandler } from "./AppProviders";
const signInMock = vi.fn();
@@ -72,3 +72,67 @@ describe("AuthCodeHandler", () => {
});
});
});
describe("AuthErrorHandler", () => {
beforeEach(() => {
signInMock.mockReset();
clearAuthError();
window.history.replaceState(null, "", "/sign-in");
});
afterEach(() => {
clearAuthError();
});
it("does nothing when there is no auth error in the URL", () => {
render(<AuthErrorHandler />);
expect(getAuthErrorSnapshot()).toBeNull();
});
it("surfaces provider errors from the URL and strips them", async () => {
window.history.replaceState(
null,
"",
"/sign-in?error=access_denied&error_description=Account%20banned&next=%2Fdashboard#section",
);
render(<AuthErrorHandler />);
await waitFor(() => {
expect(getAuthErrorSnapshot()).toBe("Account banned");
});
expect(`${window.location.pathname}${window.location.search}${window.location.hash}`).toBe(
"/sign-in?next=%2Fdashboard#section",
);
});
it("falls back to the provider error when there is no description", async () => {
window.history.replaceState(null, "", "/sign-in?error=access_denied");
render(<AuthErrorHandler />);
await waitFor(() => {
expect(getAuthErrorSnapshot()).toBe("access_denied");
});
});
it("falls back to the provider error when the description is blank", async () => {
window.history.replaceState(
null,
"",
"/sign-in?error=access_denied&error_description=%20%20%20",
);
render(<AuthErrorHandler />);
await waitFor(() => {
expect(getAuthErrorSnapshot()).toBe("access_denied");
});
expect(`${window.location.pathname}${window.location.search}${window.location.hash}`).toBe(
"/sign-in",
);
});
});
+30
View File
@@ -48,10 +48,40 @@ export function AuthCodeHandler() {
return null;
}
function getPendingAuthError() {
if (typeof window === "undefined") return null;
const url = new URL(window.location.href);
const description =
url.searchParams.get("error_description")?.trim() || url.searchParams.get("error")?.trim();
if (!description) return null;
url.searchParams.delete("error");
url.searchParams.delete("error_description");
return {
description,
relativeUrl: `${url.pathname}${url.search}${url.hash}`,
};
}
export function AuthErrorHandler() {
const handledErrorRef = useRef<string | null>(null);
useEffect(() => {
const pending = getPendingAuthError();
if (!pending) return;
if (handledErrorRef.current === pending.description) return;
handledErrorRef.current = pending.description;
window.history.replaceState(null, "", pending.relativeUrl);
setAuthError(pending.description);
}, []);
return null;
}
export function AppProviders({ children }: { children: React.ReactNode }) {
return (
<ConvexAuthProvider client={convex} shouldHandleCode={false}>
<AuthCodeHandler />
<AuthErrorHandler />
<UserBootstrap />
{children}
</ConvexAuthProvider>
+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;
+2 -2
View File
@@ -1,9 +1,9 @@
import type { ClawdisSkillMetadata } from "clawhub-schema";
import { Link } from "@tanstack/react-router";
import {
type ClawdisSkillMetadata,
PLATFORM_SKILL_LICENSE,
PLATFORM_SKILL_LICENSE_SUMMARY,
} from "clawhub-schema";
} from "clawhub-schema/licenseConstants";
import { Package } from "lucide-react";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import { getSkillBadges } from "../lib/badges";
+2 -2
View File
@@ -1,9 +1,9 @@
import type { ClawdisSkillMetadata } from "clawhub-schema";
import {
type ClawdisSkillMetadata,
PLATFORM_SKILL_LICENSE,
PLATFORM_SKILL_LICENSE_SUMMARY,
PLATFORM_SKILL_LICENSE_URL,
} from "clawhub-schema";
} from "clawhub-schema/licenseConstants";
import { formatInstallCommand, formatInstallLabel } from "./skillDetailUtils";
type SkillInstallCardProps = {
+1 -1
View File
@@ -3,7 +3,7 @@ import type {
PackageCompatibility,
PackageVerificationSummary,
} from "clawhub-schema";
import { ApiRoutes } from "clawhub-schema";
import { ApiRoutes } from "clawhub-schema/routes";
import { getRequiredRuntimeEnv, getRuntimeEnv } from "./runtimeEnv";
export type PackageListItem = {
+1 -1
View File
@@ -1,4 +1,4 @@
import { TEXT_FILE_EXTENSION_SET } from "clawhub-schema";
import { TEXT_FILE_EXTENSION_SET } from "clawhub-schema/textFiles";
import { gunzipSync, unzipSync } from "fflate";
const TEXT_TYPES = new Map([
+1 -1
View File
@@ -1,4 +1,4 @@
import { isTextContentType, TEXT_FILE_EXTENSION_SET } from "clawhub-schema";
import { isTextContentType, TEXT_FILE_EXTENSION_SET } from "clawhub-schema/textFiles";
import { getUserFacingConvexError } from "./convexError";
export async function uploadFile(uploadUrl: string, file: File) {
+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) {
+1 -1
View File
@@ -3,7 +3,7 @@ import {
PLATFORM_SKILL_LICENSE,
PLATFORM_SKILL_LICENSE_NAME,
PLATFORM_SKILL_LICENSE_SUMMARY,
} from "clawhub-schema";
} from "clawhub-schema/licenseConstants";
import { useAction, useMutation, useQuery } from "convex/react";
import { useEffect, useMemo, useRef, useState } from "react";
import semver from "semver";
+1 -1
View File
@@ -1,4 +1,4 @@
import { isTextContentType, TEXT_FILE_EXTENSION_SET } from "clawhub-schema";
import { isTextContentType, TEXT_FILE_EXTENSION_SET } from "clawhub-schema/textFiles";
import { getUserFacingConvexError } from "../../lib/convexError";
export async function uploadFile(uploadUrl: string, file: File) {
+2
View File
@@ -70,6 +70,8 @@ const config = defineConfig({
viteReact(),
],
build: {
// Keep the shipped client bundle parseable in Safari/WebKit.
target: "safari15",
chunkSizeWarningLimit: 900,
rollupOptions: {
onwarn: handleRollupWarning,