Compare commits

...
4 Commits
Author SHA1 Message Date
Val Alexander 4a4ad1634c fix: expand reserved public owner handles to cover all top-level routes
Previously only 'plugins' and 'skills' were protected. Any user could
register @admin, @settings, @dashboard, @search, etc. as a publisher
handle, shadowing those platform routes via the $owner catch-all.

Add all current top-level src/routes/ segments to
RESERVED_PUBLIC_OWNER_HANDLES, grouped by purpose:
- Content browsing: skills, souls, plugins, packages, publishers, orgs
- Profile shortlinks: p, u
- User flows: search, import, upload, publish-skill, publish-plugin,
  stars, dashboard, settings
- Admin/internal: admin, management, audits
- Informational: docs, cli
- Auth/account: user, users

Also add a doc comment noting to update this set when new top-level
routes are added.
2026-06-03 05:12:18 -07:00
copilot-swe-agent[bot] cb53d4ab15 test: add personal-publisher nvidia membership regression tests 2026-06-03 11:50:34 +00:00
Val AlexanderandCopilot Autofix powered by AI 52970a3e3d Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-03 06:44:57 -05:00
Val Alexander 14beb939ba fix: require verified ownership for official org handles 2026-06-03 05:34:33 -05:00
8 changed files with 11528 additions and 26 deletions
+221 -3
View File
@@ -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 () => {
+46 -8
View File
@@ -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;
+50 -1
View File
@@ -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) {
+5 -6
View File
@@ -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">,
) {
+28
View File
@@ -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
View File
@@ -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", {
+11154
View File
File diff suppressed because it is too large Load Diff
+11 -5
View File
@@ -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: