mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 17:02:11 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba699a114a | ||
|
|
75915cd2b5 | ||
|
|
d8db9b99a2 | ||
|
|
d8e1f0daa1 |
@@ -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 () => {});
|
||||
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Vendored
+2
-4
@@ -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];
|
||||
|
||||
Vendored
+2
-4
@@ -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
|
||||
Vendored
+1
-1
@@ -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
@@ -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
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,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,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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user