Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 98 additions & 3 deletions convex/lib/officialPublishers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,107 @@ 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("treats personal publishers for openclaw org members as official", async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in commit $(git -C /tmp/workspace/openclaw/clawhub rev-parse --short HEAD): two new tests cover the personal-publisher membership path for nvidia:

  • "does not treat personal publisher of unreserved nvidia org member as official" — member of an org that has no reservedHandles record resolves to false.
  • "treats personal publisher of reserved-owner-controlled nvidia org member as official" — member of an org whose rightfulOwnerUserId holds the "owner" role resolves to true.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in commit cb53d4ab: two new tests cover the personal-publisher membership path for nvidia:

  • "does not treat personal publisher of unreserved nvidia org member as official" — member of an org with no reservedHandles record resolves to false.
  • "treats personal publisher of reserved-owner-controlled nvidia org member as official" — member of an org whose rightfulOwnerUserId holds the "owner" role resolves to true.

Expand Down
50 changes: 44 additions & 6 deletions convex/lib/officialPublishers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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">;

Expand All @@ -26,20 +35,49 @@ 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;
if (!(await isOfficialOrgPublisher(ctx, officialOrg))) continue;

const membership = await getPublisherMembership(ctx, officialOrg._id, publisher.linkedUserId);
if (membership) return true;
Comment thread
BunsDev marked this conversation as resolved.
Outdated
Expand Down
11 changes: 5 additions & 6 deletions convex/lib/reservedHandles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand All @@ -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">,
) {
Expand Down
28 changes: 28 additions & 0 deletions convex/publishers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
16 changes: 13 additions & 3 deletions convex/publishers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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])?$/;
Expand Down Expand Up @@ -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", {
Expand Down
16 changes: 11 additions & 5 deletions specs/official-publishers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
Loading