Compare commits

..
28 changed files with 588 additions and 41 deletions
+141
View File
@@ -102,6 +102,147 @@ describe("skillTransfers", () => {
);
});
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 () => {});
+19 -1
View File
@@ -1,6 +1,7 @@
import { v } from "convex/values";
import type { Doc, Id } from "./_generated/dataModel";
import { internalMutation, internalQuery } from "./functions";
import { ensurePersonalPublisherForUser } from "./lib/publishers";
const TRANSFER_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
type TransferDoc = Doc<"skillOwnershipTransfers">;
@@ -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,
+118
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,120 @@ 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",
});
});
});
describe("users.syncGitHubProfileInternal", () => {
it("keeps a derived handle unchanged when the new login is reserved", async () => {
const { ctx, get, patch, query } = makeCtx();
+18 -4
View File
@@ -6,7 +6,11 @@ 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,
getPublisherByHandle,
normalizePublisherHandle,
} from "./lib/publishers";
import { toPublicUser } from "./lib/public";
import {
getLatestActiveReservedHandle,
@@ -36,7 +40,7 @@ export const getByIdInternal = internalQuery({
export const getByHandleInternal = internalQuery({
args: { handle: v.string() },
handler: async (ctx, args) => {
const normalizedHandle = normalizeReservedHandle(args.handle);
const normalizedHandle = normalizePublisherHandle(args.handle);
if (!normalizedHandle) return null;
return await ctx.db
.query("users")
@@ -396,11 +400,21 @@ 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", args.handle))
.withIndex("handle", (q) => q.eq("handle", normalizedHandle))
.unique();
return toPublicUser(user);
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);
},
});
+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,