mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a4ad1634c | ||
|
|
cb53d4ab15 | ||
|
|
52970a3e3d | ||
|
|
14beb939ba |
@@ -26,12 +26,230 @@ describe("isOfficialPublisher", () => {
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("treats the nvidia org publisher as official", async () => {
|
||||
const ctx = { db: { query: vi.fn() } };
|
||||
it("does not treat an unreserved nvidia org publisher as official", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table !== "reservedHandles") throw new Error(`Unexpected table ${table}`);
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
order: vi.fn(() => ({
|
||||
take: vi.fn(async () => []),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
isOfficialPublisher(ctx as never, makePublisher({ handle: "nvidia" })),
|
||||
).resolves.toBe(true);
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("treats the reserved-owner-controlled nvidia org publisher as official", async () => {
|
||||
const nvidia = makePublisher({ _id: "publishers:nvidia", handle: "nvidia" });
|
||||
const ctx = {
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "reservedHandles") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
order: vi.fn(() => ({
|
||||
take: vi.fn(async () => [
|
||||
{
|
||||
_id: "reservedHandles:nvidia",
|
||||
handle: "nvidia",
|
||||
rightfulOwnerUserId: "users:nvidia",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
]),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn(async () => ({
|
||||
_id: "publisherMembers:nvidia",
|
||||
publisherId: nvidia._id,
|
||||
userId: "users:nvidia",
|
||||
role: "owner",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(isOfficialPublisher(ctx as never, nvidia)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat nvidia as official when the reserved owner does not own the org", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "reservedHandles") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
order: vi.fn(() => ({
|
||||
take: vi.fn(async () => [
|
||||
{
|
||||
_id: "reservedHandles:nvidia",
|
||||
handle: "nvidia",
|
||||
rightfulOwnerUserId: "users:nvidia",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
]),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn(async () => null),
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
isOfficialPublisher(ctx as never, makePublisher({ handle: "nvidia" })),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("does not treat personal publisher of unreserved nvidia org member as official", async () => {
|
||||
const nvidia = makePublisher({ _id: "publishers:nvidia", handle: "nvidia" });
|
||||
const personal = makePublisher({
|
||||
_id: "publishers:alice",
|
||||
kind: "user",
|
||||
handle: "alice",
|
||||
linkedUserId: "users:alice",
|
||||
});
|
||||
const ctx = {
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: vi.fn((_index: string, fn: (q: any) => any) => {
|
||||
let capturedHandle: string | undefined;
|
||||
fn({ eq: (_: string, v: string) => { capturedHandle = v; return { eq: () => ({}) }; } });
|
||||
return { unique: vi.fn(async () => (capturedHandle === "nvidia" ? nvidia : null)) };
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn(async () => ({
|
||||
_id: "publisherMembers:alice-nvidia",
|
||||
publisherId: nvidia._id,
|
||||
userId: "users:alice",
|
||||
role: "publisher",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (table === "reservedHandles") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
order: vi.fn(() => ({
|
||||
take: vi.fn(async () => []),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(isOfficialPublisher(ctx as never, personal)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("treats personal publisher of reserved-owner-controlled nvidia org member as official", async () => {
|
||||
const nvidia = makePublisher({ _id: "publishers:nvidia", handle: "nvidia" });
|
||||
const personal = makePublisher({
|
||||
_id: "publishers:alice",
|
||||
kind: "user",
|
||||
handle: "alice",
|
||||
linkedUserId: "users:alice",
|
||||
});
|
||||
const ctx = {
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: vi.fn((_index: string, fn: (q: any) => any) => {
|
||||
let capturedHandle: string | undefined;
|
||||
fn({ eq: (_: string, v: string) => { capturedHandle = v; return { eq: () => ({}) }; } });
|
||||
return { unique: vi.fn(async () => (capturedHandle === "nvidia" ? nvidia : null)) };
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: vi.fn((_index: string, fn: (q: any) => any) => {
|
||||
let capturedUserId: string | undefined;
|
||||
fn({ eq: (_: string, _v: any) => ({ eq: (_2: string, v2: string) => { capturedUserId = v2; return {}; } }) });
|
||||
const record =
|
||||
capturedUserId === "users:nvidia-owner"
|
||||
? {
|
||||
_id: "publisherMembers:nvidia-owner",
|
||||
publisherId: nvidia._id,
|
||||
userId: "users:nvidia-owner",
|
||||
role: "owner",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
}
|
||||
: {
|
||||
_id: "publisherMembers:alice-nvidia",
|
||||
publisherId: nvidia._id,
|
||||
userId: "users:alice",
|
||||
role: "publisher",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
return { unique: vi.fn(async () => record) };
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "reservedHandles") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
order: vi.fn(() => ({
|
||||
take: vi.fn(async () => [
|
||||
{
|
||||
_id: "reservedHandles:nvidia",
|
||||
handle: "nvidia",
|
||||
rightfulOwnerUserId: "users:nvidia-owner",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
]),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(isOfficialPublisher(ctx as never, personal)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("treats personal publishers for openclaw org members as official", async () => {
|
||||
|
||||
@@ -6,9 +6,18 @@ import {
|
||||
getPublisherMembership,
|
||||
normalizePublisherHandle,
|
||||
} from "./publishers";
|
||||
import { getLatestActiveReservedHandle } from "./reservedHandles";
|
||||
|
||||
const OFFICIAL_ORG_HANDLES = ["openclaw", "nvidia"] as const;
|
||||
const OFFICIAL_ORG_HANDLE_SET = new Set<string>(OFFICIAL_ORG_HANDLES);
|
||||
const LEGACY_OFFICIAL_ORG_HANDLES = ["openclaw"] as const;
|
||||
const RESERVED_OWNER_VERIFIED_OFFICIAL_ORG_HANDLES = ["nvidia"] as const;
|
||||
const OFFICIAL_ORG_HANDLES = [
|
||||
...LEGACY_OFFICIAL_ORG_HANDLES,
|
||||
...RESERVED_OWNER_VERIFIED_OFFICIAL_ORG_HANDLES,
|
||||
] as const;
|
||||
const LEGACY_OFFICIAL_ORG_HANDLE_SET = new Set<string>(LEGACY_OFFICIAL_ORG_HANDLES);
|
||||
const RESERVED_OWNER_VERIFIED_OFFICIAL_ORG_HANDLE_SET = new Set<string>(
|
||||
RESERVED_OWNER_VERIFIED_OFFICIAL_ORG_HANDLES,
|
||||
);
|
||||
|
||||
type DbCtx = Pick<QueryCtx | MutationCtx, "db">;
|
||||
|
||||
@@ -26,23 +35,52 @@ type OfficialPublisherCandidate = Pick<
|
||||
| "deactivatedAt"
|
||||
>;
|
||||
|
||||
export function isReservedOwnerVerifiedOfficialOrgHandle(
|
||||
handle: string | undefined | null,
|
||||
): boolean {
|
||||
const normalizedHandle = normalizePublisherHandle(handle);
|
||||
return Boolean(
|
||||
normalizedHandle && RESERVED_OWNER_VERIFIED_OFFICIAL_ORG_HANDLE_SET.has(normalizedHandle),
|
||||
);
|
||||
}
|
||||
|
||||
async function isOfficialOrgPublisher(
|
||||
ctx: DbCtx,
|
||||
publisher: OfficialPublisherCandidate,
|
||||
): Promise<boolean> {
|
||||
const handle = normalizePublisherHandle(publisher.handle);
|
||||
if (!handle) return false;
|
||||
if (LEGACY_OFFICIAL_ORG_HANDLE_SET.has(handle)) return true;
|
||||
if (!RESERVED_OWNER_VERIFIED_OFFICIAL_ORG_HANDLE_SET.has(handle)) return false;
|
||||
|
||||
const reservation = await getLatestActiveReservedHandle(ctx, handle);
|
||||
if (!reservation) return false;
|
||||
|
||||
// Security-sensitive: newly official handles must be bound to an admin-created
|
||||
// reservation, not just any public org that claimed the handle first.
|
||||
const ownerMembership = await getPublisherMembership(
|
||||
ctx,
|
||||
publisher._id,
|
||||
reservation.rightfulOwnerUserId,
|
||||
);
|
||||
return ownerMembership?.role === "owner";
|
||||
}
|
||||
|
||||
export async function isOfficialPublisher(
|
||||
ctx: DbCtx,
|
||||
publisher: OfficialPublisherCandidate | null | undefined,
|
||||
): Promise<boolean> {
|
||||
if (!publisher || publisher.deletedAt || publisher.deactivatedAt) return false;
|
||||
if (publisher.kind === "org") {
|
||||
const handle = normalizePublisherHandle(publisher.handle);
|
||||
return Boolean(handle && OFFICIAL_ORG_HANDLE_SET.has(handle));
|
||||
}
|
||||
if (publisher.kind === "org") return await isOfficialOrgPublisher(ctx, publisher);
|
||||
if (!publisher.linkedUserId) return false;
|
||||
|
||||
for (const officialOrgHandle of OFFICIAL_ORG_HANDLES) {
|
||||
const officialOrg = await getPublisherByHandle(ctx, officialOrgHandle);
|
||||
if (!officialOrg || officialOrg.deletedAt || officialOrg.deactivatedAt) continue;
|
||||
|
||||
const membership = await getPublisherMembership(ctx, officialOrg._id, publisher.linkedUserId);
|
||||
if (membership) return true;
|
||||
if (!membership) continue;
|
||||
if (!(await isOfficialOrgPublisher(ctx, officialOrg))) continue;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
@@ -1,4 +1,53 @@
|
||||
const RESERVED_PUBLIC_OWNER_HANDLES = new Set(["plugins", "skills"]);
|
||||
/**
|
||||
* Handles and package names that are reserved for ClawHub platform routes.
|
||||
*
|
||||
* RESERVED_PUBLIC_OWNER_HANDLES: every top-level path segment that exists as
|
||||
* a real app route and would shadow the `/$owner` dynamic catch-all if a user
|
||||
* were able to register it as a publisher handle.
|
||||
*
|
||||
* Add entries here whenever a new top-level route is added to src/routes/.
|
||||
*/
|
||||
const RESERVED_PUBLIC_OWNER_HANDLES = new Set([
|
||||
// Content browsing
|
||||
"skills",
|
||||
"souls",
|
||||
"plugins",
|
||||
"packages",
|
||||
"publishers",
|
||||
"orgs",
|
||||
|
||||
// Publisher / user profile shortlinks
|
||||
"p",
|
||||
"u",
|
||||
|
||||
// User-facing flows
|
||||
"search",
|
||||
"import",
|
||||
"upload",
|
||||
"publish-skill",
|
||||
"publish-plugin",
|
||||
"stars",
|
||||
"dashboard",
|
||||
"settings",
|
||||
|
||||
// Admin / platform-internal
|
||||
"admin",
|
||||
"management",
|
||||
"audits",
|
||||
|
||||
// Informational / static
|
||||
"docs",
|
||||
"cli",
|
||||
|
||||
// Auth / user account
|
||||
"user",
|
||||
"users",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Unscoped package names that are reserved for ClawHub routes or CLI commands.
|
||||
* Scoped packages (e.g. @scope/publish) are not affected.
|
||||
*/
|
||||
const RESERVED_UNSCOPED_PACKAGE_NAMES = new Set(["publish"]);
|
||||
|
||||
export function isReservedPublicOwnerHandle(handle: string | undefined | null) {
|
||||
|
||||
@@ -6,7 +6,9 @@ export function normalizeReservedHandle(handle: string | undefined | null) {
|
||||
return normalized ? normalized : undefined;
|
||||
}
|
||||
|
||||
function reservedHandleQuery(ctx: QueryCtx | MutationCtx, handle: string) {
|
||||
type DbCtx = Pick<QueryCtx | MutationCtx, "db">;
|
||||
|
||||
function reservedHandleQuery(ctx: DbCtx, handle: string) {
|
||||
return ctx.db
|
||||
.query("reservedHandles")
|
||||
.withIndex("by_handle_active_updatedAt", (q) =>
|
||||
@@ -15,17 +17,14 @@ function reservedHandleQuery(ctx: QueryCtx | MutationCtx, handle: string) {
|
||||
.order("desc");
|
||||
}
|
||||
|
||||
export async function getLatestActiveReservedHandle(
|
||||
ctx: QueryCtx | MutationCtx,
|
||||
handle: string | undefined | null,
|
||||
) {
|
||||
export async function getLatestActiveReservedHandle(ctx: DbCtx, handle: string | undefined | null) {
|
||||
const normalized = normalizeReservedHandle(handle);
|
||||
if (!normalized) return null;
|
||||
return (await reservedHandleQuery(ctx, normalized).take(1))[0] ?? null;
|
||||
}
|
||||
|
||||
export async function isHandleReservedForAnotherUser(
|
||||
ctx: QueryCtx | MutationCtx,
|
||||
ctx: DbCtx,
|
||||
handle: string | undefined | null,
|
||||
userId: Id<"users">,
|
||||
) {
|
||||
|
||||
@@ -2563,6 +2563,34 @@ describe("self-serve org publisher creation", () => {
|
||||
).resolves.toMatchObject({ ok: true, handle: "opik" });
|
||||
});
|
||||
|
||||
it("rejects unreserved official org handles", async () => {
|
||||
const { ctx } = makeCreateOrgPublisherCtx({});
|
||||
|
||||
await expect(
|
||||
createOrgPublisherForUserInternalHandler(ctx as never, {
|
||||
actorUserId: "users:vincent",
|
||||
handle: "NVIDIA",
|
||||
}),
|
||||
).rejects.toThrow('Handle "@nvidia" is reserved for verified official publisher ownership');
|
||||
});
|
||||
|
||||
it("lets the rightful owner create a reserved official org handle", async () => {
|
||||
const { ctx } = makeCreateOrgPublisherCtx({
|
||||
reservedHandle: {
|
||||
_id: "reservedHandles:nvidia",
|
||||
handle: "nvidia",
|
||||
rightfulOwnerUserId: "users:vincent",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
createOrgPublisherForUserInternalHandler(ctx as never, {
|
||||
actorUserId: "users:vincent",
|
||||
handle: "NVIDIA",
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true, handle: "nvidia" });
|
||||
});
|
||||
|
||||
function makeSettingsCreateOrgCtx(options: {
|
||||
reservedHandle?: Record<string, unknown> | null;
|
||||
existingOrgPublisher?: Record<string, unknown> | null;
|
||||
|
||||
+13
-3
@@ -4,7 +4,11 @@ import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
||||
import { internalMutation, internalQuery, mutation, query } from "./functions";
|
||||
import { assertAdmin, getOptionalActiveAuthUserId, requireUser } from "./lib/access";
|
||||
import { isOfficialPublisher, toPublicPublisherWithOfficial } from "./lib/officialPublishers";
|
||||
import {
|
||||
isOfficialPublisher,
|
||||
isReservedOwnerVerifiedOfficialOrgHandle,
|
||||
toPublicPublisherWithOfficial,
|
||||
} from "./lib/officialPublishers";
|
||||
import { toPublicPublisher } from "./lib/public";
|
||||
import {
|
||||
formatReservedPublicOwnerHandleMessage,
|
||||
@@ -21,7 +25,7 @@ import {
|
||||
isPublisherRoleAllowed,
|
||||
normalizePublisherHandle,
|
||||
} from "./lib/publishers";
|
||||
import { isHandleReservedForAnotherUser } from "./lib/reservedHandles";
|
||||
import { getLatestActiveReservedHandle } from "./lib/reservedHandles";
|
||||
import { readCanonicalStat } from "./lib/skillStats";
|
||||
|
||||
const PUBLISHER_HANDLE_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
|
||||
@@ -840,9 +844,15 @@ async function createOrgPublisherForUser(
|
||||
if (existingUser) {
|
||||
throw new ConvexError(`Handle "@${handle}" is already used by a user or personal publisher`);
|
||||
}
|
||||
if (await isHandleReservedForAnotherUser(ctx, handle, args.actorUserId)) {
|
||||
const reservedHandle = await getLatestActiveReservedHandle(ctx, handle);
|
||||
if (reservedHandle && reservedHandle.rightfulOwnerUserId !== args.actorUserId) {
|
||||
throw new ConvexError(`Handle "@${handle}" is reserved for another user`);
|
||||
}
|
||||
if (isReservedOwnerVerifiedOfficialOrgHandle(handle) && !reservedHandle) {
|
||||
throw new ConvexError(
|
||||
`Handle "@${handle}" is reserved for verified official publisher ownership`,
|
||||
);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const publisherId = await ctx.db.insert("publishers", {
|
||||
|
||||
Generated
+11154
File diff suppressed because it is too large
Load Diff
@@ -5,13 +5,19 @@ official organization allowlist.
|
||||
|
||||
For now, Official means:
|
||||
|
||||
- org publishers on the official allowlist are Official
|
||||
- legacy org publishers on the official allowlist are Official
|
||||
- reserved-owner-verified org handles on the official allowlist are Official
|
||||
only after the handle has an active reservation for the rightful owner and
|
||||
that reserved owner owns the org publisher
|
||||
- personal publishers for current members of an official org are Official
|
||||
|
||||
Official must not be accepted from uploaded skill or package metadata.
|
||||
Membership in any org outside the official allowlist does not make a personal
|
||||
publisher Official. There is no generic admin endpoint for marking arbitrary
|
||||
publishers Official.
|
||||
Official must not be accepted from uploaded skill or package metadata, and it
|
||||
must not be derived solely from a user-claimable handle. New official org
|
||||
handles must either be blocked from public unreserved creation or require an
|
||||
active reservation/ownership check before the org or its members receive
|
||||
Official status. Membership in any org outside the official allowlist does not
|
||||
make a personal publisher Official. There is no generic admin endpoint for
|
||||
marking arbitrary publishers Official.
|
||||
|
||||
The same policy signal appears in two places:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user