mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-16 18:02:09 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79d17a91ed | ||
|
|
2f15202a68 | ||
|
|
342a2b1ca4 |
@@ -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">,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -61,7 +61,7 @@ describe("skillTransfers", () => {
|
||||
if (table === "users") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
first: async () => ({ _id: "users:2", handle: "alice", displayName: "Alice" }),
|
||||
unique: async () => ({ _id: "users:2", handle: "alice", displayName: "Alice" }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -102,6 +102,101 @@ describe("skillTransfers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("requestTransferInternal resolves recipient via personal publisher handle", async () => {
|
||||
const insert = vi.fn(async (table: string) => {
|
||||
if (table === "skillOwnershipTransfers") return "skillOwnershipTransfers:new";
|
||||
return "auditLogs:1";
|
||||
});
|
||||
|
||||
const result = (await requestTransferInternalHandler(
|
||||
{
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "users:1") return { _id: "users:1", handle: "owner" };
|
||||
if (id === "users:2") {
|
||||
return {
|
||||
_id: "users:2",
|
||||
handle: undefined,
|
||||
name: "Alice",
|
||||
displayName: "Alice",
|
||||
};
|
||||
}
|
||||
if (id === "skills:1") {
|
||||
return {
|
||||
_id: "skills:1",
|
||||
slug: "demo",
|
||||
displayName: "Demo",
|
||||
ownerUserId: "users:1",
|
||||
};
|
||||
}
|
||||
if (id === "publishers:alice") {
|
||||
return {
|
||||
_id: "publishers:alice",
|
||||
kind: "user",
|
||||
handle: "alice",
|
||||
displayName: "Alice",
|
||||
linkedUserId: "users:2",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "users") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => ({
|
||||
_id: "publishers:alice",
|
||||
kind: "user",
|
||||
handle: "alice",
|
||||
displayName: "Alice",
|
||||
linkedUserId: "users:2",
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "skillOwnershipTransfers") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
collect: async () => [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
patch: vi.fn(async () => {}),
|
||||
insert,
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
actorUserId: "users:1",
|
||||
skillId: "skills:1",
|
||||
toUserHandle: "@alice",
|
||||
} as never,
|
||||
)) as { ok: boolean; transferId: string };
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
ok: true,
|
||||
transferId: "skillOwnershipTransfers:new",
|
||||
toUserHandle: "alice",
|
||||
}),
|
||||
);
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"skillOwnershipTransfers",
|
||||
expect.objectContaining({
|
||||
toUserId: "users:2",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("acceptTransferInternal updates skill and alias ownership to the recipient publisher", async () => {
|
||||
const patch = vi.fn(async () => {});
|
||||
const insert = vi.fn(async () => "auditLogs:1");
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { v } from "convex/values";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import { internalMutation, internalQuery } from "./functions";
|
||||
import { ensurePersonalPublisherForUser } from "./lib/publishers";
|
||||
import {
|
||||
ensurePersonalPublisherForUser,
|
||||
getActiveUserByHandleOrPersonalPublisher,
|
||||
} from "./lib/publishers";
|
||||
const TRANSFER_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
type TransferDoc = Doc<"skillOwnershipTransfers">;
|
||||
@@ -112,11 +115,8 @@ export const requestTransferInternal = internalMutation({
|
||||
const toHandle = normalizeHandle(args.toUserHandle);
|
||||
if (!toHandle) throw new Error("toUserHandle required");
|
||||
|
||||
const toUser = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("handle", (q) => q.eq("handle", toHandle))
|
||||
.first();
|
||||
if (!toUser || toUser.deletedAt || toUser.deactivatedAt) throw new Error("User not found");
|
||||
const toUser = await getActiveUserByHandleOrPersonalPublisher(ctx, toHandle);
|
||||
if (!toUser) throw new Error("User not found");
|
||||
if (toUser._id === args.actorUserId) throw new Error("Cannot transfer to yourself");
|
||||
|
||||
const activePending = await getActivePendingTransferForSkill(ctx, args.skillId, now);
|
||||
|
||||
@@ -616,6 +616,54 @@ describe("users.getByHandle", () => {
|
||||
bio: "Profile",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not resolve a deleted personal publisher handle", async () => {
|
||||
const userUnique = vi.fn(async () => null);
|
||||
const publisherUnique = vi.fn(async () => ({
|
||||
_id: "publishers:jaredforreal",
|
||||
kind: "user",
|
||||
handle: "jaredforreal",
|
||||
linkedUserId: "users:owner",
|
||||
deletedAt: 1_700_000_000_000,
|
||||
displayName: "Jared",
|
||||
}));
|
||||
const get = vi.fn(async () => {
|
||||
throw new Error("linked user should not be loaded for inactive publishers");
|
||||
});
|
||||
|
||||
const result = await getByHandleHandler(
|
||||
{
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "users") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "handle") throw new Error(`Unexpected users index ${name}`);
|
||||
return { unique: userUnique };
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_handle") throw new Error(`Unexpected publishers index ${name}`);
|
||||
return { unique: publisherUnique };
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
}),
|
||||
get,
|
||||
},
|
||||
} as never,
|
||||
{ handle: "jaredforreal" },
|
||||
);
|
||||
|
||||
expect(userUnique).toHaveBeenCalledOnce();
|
||||
expect(publisherUnique).toHaveBeenCalledOnce();
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("users.syncGitHubProfileInternal", () => {
|
||||
|
||||
+4
-22
@@ -8,8 +8,9 @@ import { assertAdmin, assertModerator, requireUser } from "./lib/access";
|
||||
import { syncGitHubProfile } from "./lib/githubAccount";
|
||||
import {
|
||||
ensurePersonalPublisherForUser,
|
||||
getActiveUserByHandleOrPersonalPublisher,
|
||||
getPublisherByHandle,
|
||||
normalizePublisherHandle,
|
||||
getUserByHandleOrPersonalPublisher,
|
||||
} from "./lib/publishers";
|
||||
import { toPublicUser } from "./lib/public";
|
||||
import {
|
||||
@@ -40,12 +41,7 @@ export const getByIdInternal = internalQuery({
|
||||
export const getByHandleInternal = internalQuery({
|
||||
args: { handle: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const normalizedHandle = normalizePublisherHandle(args.handle);
|
||||
if (!normalizedHandle) return null;
|
||||
return await ctx.db
|
||||
.query("users")
|
||||
.withIndex("handle", (q) => q.eq("handle", normalizedHandle))
|
||||
.unique();
|
||||
return await getUserByHandleOrPersonalPublisher(ctx, args.handle);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -400,21 +396,7 @@ function clampInt(value: number, min: number, max: number) {
|
||||
export const getByHandle = query({
|
||||
args: { handle: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const normalizedHandle = normalizePublisherHandle(args.handle);
|
||||
if (!normalizedHandle) return null;
|
||||
|
||||
const user = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("handle", (q) => q.eq("handle", normalizedHandle))
|
||||
.unique();
|
||||
if (user) return toPublicUser(user);
|
||||
|
||||
const publisher = await getPublisherByHandle(ctx, normalizedHandle);
|
||||
if (!publisher || publisher.kind !== "user" || !publisher.linkedUserId) return null;
|
||||
|
||||
const linkedUser = await ctx.db.get(publisher.linkedUserId);
|
||||
if (!linkedUser) return null;
|
||||
return toPublicUser(linkedUser);
|
||||
return toPublicUser(await getActiveUserByHandleOrPersonalPublisher(ctx, args.handle));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user