mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
feat(admin): support org profile updates (#3416)
This commit is contained in:
@@ -115,6 +115,7 @@ has asked for fuzzy handle resolution or the exact handle is ambiguous.
|
||||
```text
|
||||
official
|
||||
create <handle>
|
||||
profile update <handle>
|
||||
remove-member <handle> <member>
|
||||
delete <handle>
|
||||
repair-scoped-packages <csv>
|
||||
@@ -127,6 +128,8 @@ bun run admin -- org official list
|
||||
bun run admin -- org official add <handle> --reason "<reason>" --yes
|
||||
bun run admin -- org official remove <handle> --reason "<reason>" --yes
|
||||
bun run admin -- org create <handle> --display-name "<name>" --member <user-handle> --role owner
|
||||
bun run admin -- org profile update <handle> --bio "<description>" --reason "<reason>" --yes
|
||||
bun run admin -- org profile update <handle> --logo-file <path> --reason "<reason>" --yes
|
||||
bun run admin -- org remove-member <handle> <member-handle>
|
||||
bun run admin -- org delete <handle> --reason "<reason>" # dry-run
|
||||
bun run admin -- org delete <handle> --reason "<reason>" --apply
|
||||
@@ -136,7 +139,8 @@ bun run admin -- org repair-scoped-packages <csv> --apply
|
||||
|
||||
`org create` requires `--member`; it must not add the moderator running the
|
||||
command as an implicit owner. `org delete` only works for empty org publishers
|
||||
and defaults to dry-run.
|
||||
and defaults to dry-run. `org profile update` accepts a bio, a PNG/JPEG/WebP
|
||||
logo under 2 MB, or both, and records the required reason in the audit log.
|
||||
|
||||
### Plugin Packages
|
||||
|
||||
|
||||
@@ -1697,6 +1697,105 @@ describe("httpApiV1 handlers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("users/publisher-profile updates an org bio and stores a logo for admin", async () => {
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
return {
|
||||
ok: true,
|
||||
publisherId: "publishers:heygen",
|
||||
handle: "heygen-com",
|
||||
bio: "HeyGen is an AI video platform.",
|
||||
image: "https://storage.example/heygen-logo",
|
||||
bioUpdated: true,
|
||||
logoUpdated: true,
|
||||
};
|
||||
});
|
||||
const store = vi.fn(async () => "storage:heygen-logo");
|
||||
const remove = vi.fn(async () => {});
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:admin",
|
||||
user: { _id: "users:admin", role: "admin" },
|
||||
} as never);
|
||||
const form = new FormData();
|
||||
form.set(
|
||||
"payload",
|
||||
JSON.stringify({
|
||||
handle: "HeyGen-Com",
|
||||
bio: "HeyGen is an AI video platform.",
|
||||
reason: "Refresh official publisher profile",
|
||||
}),
|
||||
);
|
||||
form.set(
|
||||
"logo",
|
||||
new File([new Uint8Array([137, 80, 78, 71])], "heygen.png", { type: "image/png" }),
|
||||
);
|
||||
|
||||
const response = await __handlers.usersPostRouterV1Handler(
|
||||
makeCtx({
|
||||
runQuery: vi.fn(),
|
||||
runAction: vi.fn(),
|
||||
runMutation,
|
||||
storage: { store, delete: remove },
|
||||
}),
|
||||
new Request("https://example.com/api/v1/users/publisher-profile", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
}),
|
||||
);
|
||||
if (response.status !== 200) throw new Error(await response.text());
|
||||
|
||||
expect(store).toHaveBeenCalledOnce();
|
||||
expect(remove).not.toHaveBeenCalled();
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
actorUserId: "users:admin",
|
||||
handle: "heygen-com",
|
||||
bio: "HeyGen is an AI video platform.",
|
||||
imageStorageId: "storage:heygen-logo",
|
||||
reason: "Refresh official publisher profile",
|
||||
}),
|
||||
);
|
||||
expect(await response.json()).toMatchObject({
|
||||
ok: true,
|
||||
handle: "heygen-com",
|
||||
bioUpdated: true,
|
||||
logoUpdated: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("users/publisher-profile forbids non-admin api tokens before storing files", async () => {
|
||||
const store = vi.fn(async () => "storage:unused");
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
throw new Error(`unexpected mutation ${JSON.stringify(args)}`);
|
||||
});
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:member",
|
||||
user: { _id: "users:member", role: "user" },
|
||||
} as never);
|
||||
const form = new FormData();
|
||||
form.set(
|
||||
"payload",
|
||||
JSON.stringify({
|
||||
handle: "opik",
|
||||
bio: "Opik is an AI observability platform.",
|
||||
reason: "Refresh official publisher profile",
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await __handlers.usersPostRouterV1Handler(
|
||||
makeCtx({ runQuery: vi.fn(), runAction: vi.fn(), runMutation, storage: { store } }),
|
||||
new Request("https://example.com/api/v1/users/publisher-profile", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(store).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("users/publisher-recovery plans personal publisher recovery for admin", async () => {
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
|
||||
@@ -24,6 +24,7 @@ const usersV1InternalRefs = internal as unknown as {
|
||||
removeOrgPublisherMemberInternal: unknown;
|
||||
removeOfficialPublisherInternal: unknown;
|
||||
recoverPersonalPublisherInternal: unknown;
|
||||
updateOrgPublisherProfileInternal: unknown;
|
||||
};
|
||||
users: {
|
||||
getBanAppealContextByGitHubProviderAccountIdInternal: unknown;
|
||||
@@ -98,12 +99,21 @@ export async function usersPostRouterV1Handler(ctx: ActionCtx, request: Request)
|
||||
action !== "publisher-delete" &&
|
||||
action !== "publisher-official" &&
|
||||
action !== "publisher-member" &&
|
||||
action !== "publisher-profile" &&
|
||||
action !== "publisher-reclaim" &&
|
||||
action !== "publisher-recovery"
|
||||
) {
|
||||
return text("Not found", 404, rate.headers);
|
||||
}
|
||||
|
||||
if (action === "publisher-profile") {
|
||||
const authResult = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
|
||||
if (!authResult.ok) return authResult.response;
|
||||
const admin = requireAdminOrResponse(authResult.user, rate.headers);
|
||||
if (!admin.ok) return admin.response;
|
||||
return handleAdminUpdatePublisherProfile(ctx, request, authResult.userId, rate.headers);
|
||||
}
|
||||
|
||||
const payloadResult = await parseJsonPayload(request, rate.headers);
|
||||
if (!payloadResult.ok) return payloadResult.response;
|
||||
const payload = payloadResult.payload;
|
||||
@@ -930,6 +940,85 @@ async function handleAdminEnsurePublisher(
|
||||
}
|
||||
}
|
||||
|
||||
const PUBLISHER_PROFILE_IMAGE_MAX_BYTES = 2 * 1024 * 1024;
|
||||
const PUBLISHER_PROFILE_IMAGE_CONTENT_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]);
|
||||
|
||||
async function handleAdminUpdatePublisherProfile(
|
||||
ctx: ActionCtx,
|
||||
request: Request,
|
||||
actorUserId: Id<"users">,
|
||||
headers: HeadersInit,
|
||||
) {
|
||||
let form: FormData;
|
||||
try {
|
||||
form = await request.formData();
|
||||
} catch {
|
||||
return text("Invalid multipart form", 400, headers);
|
||||
}
|
||||
const payloadRaw = form.get("payload");
|
||||
if (typeof payloadRaw !== "string") return text("Missing payload", 400, headers);
|
||||
let payload: Record<string, unknown>;
|
||||
try {
|
||||
const parsed = JSON.parse(payloadRaw) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return text("JSON payload must be an object", 400, headers);
|
||||
}
|
||||
payload = parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return text("Invalid JSON payload", 400, headers);
|
||||
}
|
||||
|
||||
const handle = typeof payload.handle === "string" ? payload.handle.trim().toLowerCase() : "";
|
||||
const reason = typeof payload.reason === "string" ? payload.reason.trim() : "";
|
||||
const hasBio = Object.prototype.hasOwnProperty.call(payload, "bio");
|
||||
const bio = typeof payload.bio === "string" ? payload.bio.trim() : undefined;
|
||||
if (!handle) return text("Missing handle", 400, headers);
|
||||
if (!reason) return text("Missing reason", 400, headers);
|
||||
if (reason.length > 500) return text("Reason too long (max 500 chars)", 400, headers);
|
||||
if (hasBio && typeof payload.bio !== "string") return text("bio must be a string", 400, headers);
|
||||
|
||||
const logoParts = form.getAll("logo");
|
||||
if (logoParts.length > 1) return text("Upload one logo", 400, headers);
|
||||
const logo = logoParts[0];
|
||||
if (typeof logo === "string") return text("logo must be a file", 400, headers);
|
||||
if (!hasBio && !logo) return text("bio or logo required", 400, headers);
|
||||
if (
|
||||
logo &&
|
||||
(logo.size <= 0 ||
|
||||
logo.size > PUBLISHER_PROFILE_IMAGE_MAX_BYTES ||
|
||||
!PUBLISHER_PROFILE_IMAGE_CONTENT_TYPES.has(logo.type))
|
||||
) {
|
||||
return text("Logo must be a PNG, JPEG, or WebP image smaller than 2 MB", 400, headers);
|
||||
}
|
||||
|
||||
let imageStorageId: Id<"_storage"> | undefined;
|
||||
try {
|
||||
if (logo) imageStorageId = await ctx.storage.store(logo);
|
||||
const result = await runUsersV1MutationRef<{
|
||||
ok: true;
|
||||
publisherId: Id<"publishers">;
|
||||
handle: string;
|
||||
bio: string | null;
|
||||
image: string | null;
|
||||
bioUpdated: boolean;
|
||||
logoUpdated: boolean;
|
||||
}>(ctx, usersV1InternalRefs.publishers.updateOrgPublisherProfileInternal, {
|
||||
actorUserId,
|
||||
handle,
|
||||
...(hasBio ? { bio: bio ?? "" } : {}),
|
||||
...(imageStorageId ? { imageStorageId } : {}),
|
||||
reason,
|
||||
});
|
||||
return json(result, 200, headers);
|
||||
} catch (error) {
|
||||
if (imageStorageId) await ctx.storage.delete(imageStorageId);
|
||||
const message = error instanceof Error ? error.message : "Publisher profile update failed";
|
||||
if (/not found/i.test(message)) return text(message, 404, headers);
|
||||
if (/unauthorized|forbidden/i.test(message)) return text("Forbidden", 403, headers);
|
||||
return text(message, 400, headers);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBanAppealUnban(
|
||||
ctx: ActionCtx,
|
||||
request: Request,
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
resolvePublishTargetForUserInternal,
|
||||
setTrustedPublisherInternal,
|
||||
updateProfile,
|
||||
updateOrgPublisherProfileInternal,
|
||||
} from "./publishers";
|
||||
|
||||
vi.mock("@convex-dev/auth/server", () => ({
|
||||
@@ -139,6 +140,27 @@ const ensureOrgPublisherHandleInternalHandler = (
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const updateOrgPublisherProfileInternalHandler = (
|
||||
updateOrgPublisherProfileInternal as unknown as WrappedHandler<
|
||||
{
|
||||
actorUserId: string;
|
||||
handle: string;
|
||||
bio?: string;
|
||||
imageStorageId?: string;
|
||||
reason: string;
|
||||
},
|
||||
{
|
||||
ok: true;
|
||||
publisherId: string;
|
||||
handle: string;
|
||||
bio: string | null;
|
||||
image: string | null;
|
||||
bioUpdated: boolean;
|
||||
logoUpdated: boolean;
|
||||
}
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const removeOrgPublisherMemberInternalHandler = (
|
||||
removeOrgPublisherMemberInternal as unknown as WrappedHandler<
|
||||
{
|
||||
@@ -9255,6 +9277,131 @@ describe("legacy publisher migration", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("lets admins update an org bio while preserving its logo and recording the reason", async () => {
|
||||
const publisher = {
|
||||
_id: "publishers:opik",
|
||||
kind: "org",
|
||||
handle: "opik",
|
||||
displayName: "Opik",
|
||||
bio: undefined,
|
||||
image: "https://storage.example/opik-logo",
|
||||
imageStorageId: "storage:opik-logo",
|
||||
};
|
||||
const patch = vi.fn(async () => {});
|
||||
const insert = vi.fn(async () => "auditLogs:1");
|
||||
const deleteStorage = vi.fn(async () => {});
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "users:admin") return { _id: id, role: "admin" };
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn(() => ({
|
||||
withIndex: vi.fn(() => ({ unique: vi.fn(async () => publisher) })),
|
||||
})),
|
||||
patch,
|
||||
insert,
|
||||
delete: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
normalizeId: vi.fn(),
|
||||
},
|
||||
storage: { delete: deleteStorage },
|
||||
};
|
||||
|
||||
await expect(
|
||||
updateOrgPublisherProfileInternalHandler(ctx as never, {
|
||||
actorUserId: "users:admin",
|
||||
handle: "OPIK",
|
||||
bio: "Open-source AI observability and evaluation platform.",
|
||||
reason: "Replace placeholder copy with verified official publisher description",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
handle: "opik",
|
||||
bioUpdated: true,
|
||||
logoUpdated: false,
|
||||
image: "https://storage.example/opik-logo",
|
||||
});
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"publishers:opik",
|
||||
expect.objectContaining({
|
||||
bio: "Open-source AI observability and evaluation platform.",
|
||||
}),
|
||||
);
|
||||
expect(deleteStorage).not.toHaveBeenCalled();
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"auditLogs",
|
||||
expect.objectContaining({
|
||||
actorUserId: "users:admin",
|
||||
action: "publisher.profile.update",
|
||||
targetId: "publishers:opik",
|
||||
metadata: expect.objectContaining({
|
||||
source: "publisher.org.admin",
|
||||
reason: "Replace placeholder copy with verified official publisher description",
|
||||
bioUpdated: true,
|
||||
logoUpdated: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces a stored org logo only after validating the uploaded file", async () => {
|
||||
const publisher = {
|
||||
_id: "publishers:heygen",
|
||||
kind: "org",
|
||||
handle: "heygen-com",
|
||||
displayName: "HeyGen",
|
||||
image: "https://storage.example/old-logo",
|
||||
imageStorageId: "storage:old-logo",
|
||||
};
|
||||
const patch = vi.fn(async () => {});
|
||||
const deleteStorage = vi.fn(async () => {});
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "users:admin") return { _id: id, role: "admin" };
|
||||
return null;
|
||||
}),
|
||||
system: {
|
||||
get: vi.fn(async () => ({ contentType: "image/png", size: 1024 })),
|
||||
},
|
||||
query: vi.fn(() => ({
|
||||
withIndex: vi.fn(() => ({ unique: vi.fn(async () => publisher) })),
|
||||
})),
|
||||
patch,
|
||||
insert: vi.fn(async () => "auditLogs:1"),
|
||||
delete: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
normalizeId: vi.fn(),
|
||||
},
|
||||
storage: {
|
||||
getUrl: vi.fn(async () => "https://storage.example/new-logo"),
|
||||
delete: deleteStorage,
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
updateOrgPublisherProfileInternalHandler(ctx as never, {
|
||||
actorUserId: "users:admin",
|
||||
handle: "heygen-com",
|
||||
imageStorageId: "storage:new-logo",
|
||||
reason: "Replace personal avatar with the official HeyGen brand symbol",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
bioUpdated: false,
|
||||
logoUpdated: true,
|
||||
image: "https://storage.example/new-logo",
|
||||
});
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"publishers:heygen",
|
||||
expect.objectContaining({
|
||||
image: "https://storage.example/new-logo",
|
||||
imageStorageId: "storage:new-logo",
|
||||
}),
|
||||
);
|
||||
expect(deleteStorage).toHaveBeenCalledWith("storage:old-logo");
|
||||
});
|
||||
|
||||
it("lets an admin remove one org owner when another owner remains", async () => {
|
||||
const publisherMembers = [
|
||||
{
|
||||
|
||||
@@ -3295,6 +3295,95 @@ export const ensureOrgPublisherHandleInternal = internalMutation({
|
||||
handler: async (ctx, args) => await ensureOrgPublisherHandleWithActor(ctx, args),
|
||||
});
|
||||
|
||||
export const updateOrgPublisherProfileInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
handle: v.string(),
|
||||
bio: v.optional(v.string()),
|
||||
imageStorageId: v.optional(v.id("_storage")),
|
||||
reason: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const actor = await ctx.db.get(args.actorUserId);
|
||||
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
||||
assertAdmin(actor);
|
||||
|
||||
const handle = normalizePublisherHandle(args.handle);
|
||||
if (!handle || !PUBLISHER_HANDLE_PATTERN.test(handle)) {
|
||||
throw new ConvexError(PUBLISHER_HANDLE_REQUIREMENTS_MESSAGE);
|
||||
}
|
||||
const reason = args.reason.trim();
|
||||
if (!reason) throw new ConvexError("Reason required");
|
||||
if (reason.length > 500) throw new ConvexError("Reason too long (max 500 chars)");
|
||||
if (args.bio === undefined && args.imageStorageId === undefined) {
|
||||
throw new ConvexError("Bio or logo required");
|
||||
}
|
||||
|
||||
const publisher = await getPublisherByHandle(ctx, handle);
|
||||
if (!publisher || publisher.kind !== "org" || publisher.deletedAt || publisher.deactivatedAt) {
|
||||
throw new ConvexError("Publisher not found");
|
||||
}
|
||||
|
||||
const bioUpdated = args.bio !== undefined;
|
||||
const nextBio = bioUpdated ? args.bio?.trim() || undefined : publisher.bio;
|
||||
let nextImage = publisher.image;
|
||||
if (args.imageStorageId) {
|
||||
const metadata = await ctx.db.system.get("_storage", args.imageStorageId);
|
||||
if (
|
||||
!metadata ||
|
||||
metadata.size <= 0 ||
|
||||
metadata.size > PUBLISHER_IMAGE_MAX_BYTES ||
|
||||
!metadata.contentType ||
|
||||
!PUBLISHER_IMAGE_CONTENT_TYPES.has(metadata.contentType)
|
||||
) {
|
||||
throw new ConvexError("Logo must be a PNG, JPEG, or WebP image smaller than 2 MB");
|
||||
}
|
||||
const uploadedImageUrl = await ctx.storage.getUrl(args.imageStorageId);
|
||||
if (!uploadedImageUrl) throw new ConvexError("Uploaded logo is no longer available");
|
||||
nextImage = uploadedImageUrl;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
await ctx.db.patch(publisher._id, {
|
||||
...(bioUpdated ? { bio: nextBio } : {}),
|
||||
...(args.imageStorageId ? { image: nextImage, imageStorageId: args.imageStorageId } : {}),
|
||||
updatedAt: now,
|
||||
});
|
||||
if (
|
||||
args.imageStorageId &&
|
||||
publisher.imageStorageId &&
|
||||
publisher.imageStorageId !== args.imageStorageId
|
||||
) {
|
||||
await ctx.storage.delete(publisher.imageStorageId);
|
||||
}
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: args.actorUserId,
|
||||
action: "publisher.profile.update",
|
||||
targetType: "publisher",
|
||||
targetId: publisher._id,
|
||||
metadata: {
|
||||
source: "publisher.org.admin",
|
||||
reason,
|
||||
bioUpdated,
|
||||
logoUpdated: Boolean(args.imageStorageId),
|
||||
...(bioUpdated ? { bio: nextBio ?? null } : {}),
|
||||
...(args.imageStorageId ? { imageStorageId: args.imageStorageId } : {}),
|
||||
},
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
publisherId: publisher._id,
|
||||
handle,
|
||||
bio: nextBio ?? null,
|
||||
image: nextImage ?? null,
|
||||
bioUpdated,
|
||||
logoUpdated: Boolean(args.imageStorageId),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const removeOrgPublisherMemberInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
|
||||
@@ -94,10 +94,13 @@ Org publisher administration:
|
||||
|
||||
```bash
|
||||
bun run admin -- org create <handle> --member <handle> [--display-name <name>] [--role owner|admin|publisher] [--trusted] [--json]
|
||||
bun run admin -- org profile update <handle> [--bio <text>] [--logo-file <path>] --reason <text> [--yes] [--json]
|
||||
```
|
||||
|
||||
`org create` requires `--member` and defaults that member to `owner`; it does
|
||||
not add the admin running the command as an org member.
|
||||
`org profile update` requires a bio, a PNG/JPEG/WebP logo under 2 MB, or both,
|
||||
and records the supplied audit reason.
|
||||
|
||||
Publisher administration:
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
cmdRemoveOfficialOrg,
|
||||
cmdRemoveOrgMember,
|
||||
cmdRepairScopedPackages,
|
||||
cmdUpdateOrgProfile,
|
||||
} from "./commands/orgs.js";
|
||||
import {
|
||||
cmdDeletePackageTrustedPublisher,
|
||||
@@ -527,6 +528,21 @@ function registerOfficialPublisherCommands(command: Command) {
|
||||
function registerOrgCommands(command: Command) {
|
||||
registerOfficialPublisherCommands(command);
|
||||
|
||||
const profile = command.command("profile").description("Manage org publisher profiles");
|
||||
profile
|
||||
.command("update")
|
||||
.description("Update an org publisher bio or logo")
|
||||
.argument("<handle>", "Org publisher handle")
|
||||
.option("--bio <text>", "Publisher bio")
|
||||
.option("--logo-file <path>", "PNG, JPEG, or WebP logo under 2 MB")
|
||||
.requiredOption("--reason <reason>", "Audit reason")
|
||||
.option("--yes", "Skip confirmation")
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (handle, options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdUpdateOrgProfile(opts, handle, options, isInputAllowed());
|
||||
});
|
||||
|
||||
command
|
||||
.command("create")
|
||||
.description("Create or update an org publisher")
|
||||
|
||||
@@ -31,6 +31,7 @@ const {
|
||||
cmdRemoveOfficialOrg,
|
||||
cmdRemoveOrgMember,
|
||||
cmdRepairScopedPackages,
|
||||
cmdUpdateOrgProfile,
|
||||
} = await import("./orgs");
|
||||
|
||||
afterEach(() => {
|
||||
@@ -169,6 +170,75 @@ describe("cmdCreateOrg", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdUpdateOrgProfile", () => {
|
||||
it("updates an org bio and uploads a validated logo through the staff profile endpoint", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "clawhub-admin-org-profile-"));
|
||||
const logoFile = join(dir, "heygen-logo.png");
|
||||
await writeFile(logoFile, new Uint8Array([137, 80, 78, 71]));
|
||||
httpMocks.apiRequestForm.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
publisherId: "publishers:heygen",
|
||||
handle: "heygen-com",
|
||||
bio: "HeyGen is an AI video platform.",
|
||||
image: "https://storage.example/heygen-logo",
|
||||
bioUpdated: true,
|
||||
logoUpdated: true,
|
||||
});
|
||||
|
||||
try {
|
||||
await cmdUpdateOrgProfile(
|
||||
makeGlobalOpts(),
|
||||
"@HeyGen-Com",
|
||||
{
|
||||
bio: " HeyGen is an AI video platform. ",
|
||||
logoFile,
|
||||
reason: "Refresh official publisher profile",
|
||||
yes: true,
|
||||
json: true,
|
||||
},
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
await rm(dir, { force: true, recursive: true });
|
||||
}
|
||||
|
||||
expect(httpMocks.apiRequestForm).toHaveBeenCalledWith(
|
||||
"https://clawhub.ai",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
path: "/api/v1/users/publisher-profile",
|
||||
token: "tkn",
|
||||
retryCount: 0,
|
||||
form: expect.any(FormData),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
const call = httpMocks.apiRequestForm.mock.calls[0]?.[1] as { form: FormData };
|
||||
const payload = call.form.get("payload");
|
||||
expect(typeof payload).toBe("string");
|
||||
expect(JSON.parse(payload as string)).toEqual({
|
||||
handle: "heygen-com",
|
||||
bio: "HeyGen is an AI video platform.",
|
||||
reason: "Refresh official publisher profile",
|
||||
});
|
||||
const logo = call.form.get("logo") as File;
|
||||
expect(logo.name).toBe("heygen-logo.png");
|
||||
expect(logo.type).toBe("image/png");
|
||||
});
|
||||
|
||||
it("requires at least one profile field", async () => {
|
||||
await expect(
|
||||
cmdUpdateOrgProfile(
|
||||
makeGlobalOpts(),
|
||||
"opik",
|
||||
{ reason: "Refresh official publisher profile", yes: true },
|
||||
false,
|
||||
),
|
||||
).rejects.toThrow(/--bio or --logo-file required/i);
|
||||
expect(httpMocks.apiRequestForm).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdRemoveOrgMember", () => {
|
||||
it("removes a user from an org publisher by handle", async () => {
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { readFile, stat, writeFile } from "node:fs/promises";
|
||||
import { basename, extname } from "node:path";
|
||||
import { requireAuthToken } from "../../../clawhub/src/cli/authToken.js";
|
||||
import { getRegistry } from "../../../clawhub/src/cli/registry.js";
|
||||
import type { GlobalOpts } from "../../../clawhub/src/cli/types.js";
|
||||
@@ -9,7 +10,7 @@ import {
|
||||
isInteractive,
|
||||
promptConfirm,
|
||||
} from "../../../clawhub/src/cli/ui.js";
|
||||
import { apiRequest } from "../../../clawhub/src/http.js";
|
||||
import { apiRequest, apiRequestForm } from "../../../clawhub/src/http.js";
|
||||
import type { ApiV1PackageRepairNameResponse } from "../../../clawhub/src/schema/index.js";
|
||||
import {
|
||||
ApiV1OfficialPublisherListResponseSchema,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
ApiRoutes,
|
||||
ApiV1PublisherDeleteResponseSchema,
|
||||
ApiV1PublisherEnsureResponseSchema,
|
||||
ApiV1PublisherProfileUpdateResponseSchema,
|
||||
ApiV1PublisherReclaimResponseSchema,
|
||||
ApiV1PublisherRemoveMemberResponseSchema,
|
||||
} from "../../../clawhub/src/schema/index.js";
|
||||
@@ -36,6 +38,14 @@ type OrgRemoveMemberOptions = {
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type OrgProfileUpdateOptions = {
|
||||
bio?: string;
|
||||
logoFile?: string;
|
||||
reason?: string;
|
||||
yes?: boolean;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type OrgDeleteOptions = {
|
||||
apply?: boolean;
|
||||
reason?: string;
|
||||
@@ -107,6 +117,14 @@ function normalizeRoleOrFail(role: string | undefined): OrgMemberRole {
|
||||
return fail("--role must be owner, admin, or publisher");
|
||||
}
|
||||
|
||||
function publisherLogoContentType(path: string) {
|
||||
const extension = extname(path).toLowerCase();
|
||||
if (extension === ".png") return "image/png";
|
||||
if (extension === ".jpg" || extension === ".jpeg") return "image/jpeg";
|
||||
if (extension === ".webp") return "image/webp";
|
||||
return fail("--logo-file must be a PNG, JPEG, or WebP image");
|
||||
}
|
||||
|
||||
export async function cmdCreateOrg(opts: GlobalOpts, handle: string, options: OrgCreateOptions) {
|
||||
const orgHandle = normalizeHandleOrFail(handle, "Org handle");
|
||||
const displayName = options.displayName?.trim();
|
||||
@@ -195,6 +213,63 @@ export async function cmdRemoveOrgMember(
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdUpdateOrgProfile(
|
||||
opts: GlobalOpts,
|
||||
handle: string,
|
||||
options: OrgProfileUpdateOptions,
|
||||
inputAllowed: boolean,
|
||||
) {
|
||||
const orgHandle = normalizeHandleOrFail(handle, "Org handle");
|
||||
const bio = options.bio?.trim();
|
||||
const logoFile = options.logoFile?.trim();
|
||||
const reason = normalizeReasonOrFail(options.reason);
|
||||
if (!bio && !logoFile) fail("--bio or --logo-file required");
|
||||
await confirmProfileUpdate(orgHandle, options, inputAllowed);
|
||||
|
||||
const form = new FormData();
|
||||
form.set(
|
||||
"payload",
|
||||
JSON.stringify({
|
||||
handle: orgHandle,
|
||||
...(bio ? { bio } : {}),
|
||||
reason,
|
||||
}),
|
||||
);
|
||||
if (logoFile) {
|
||||
const metadata = await stat(logoFile);
|
||||
if (!metadata.isFile()) fail("--logo-file must point to a file");
|
||||
if (metadata.size <= 0 || metadata.size > 2 * 1024 * 1024) {
|
||||
fail("--logo-file must be smaller than 2 MB");
|
||||
}
|
||||
const contentType = publisherLogoContentType(logoFile);
|
||||
const bytes = await readFile(logoFile);
|
||||
form.set("logo", new File([new Uint8Array(bytes)], basename(logoFile), { type: contentType }));
|
||||
}
|
||||
|
||||
const token = await requireAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
const spinner = options.json ? null : createCrabLoader(`Updating @${orgHandle} profile`);
|
||||
try {
|
||||
const result = await apiRequestForm(
|
||||
registry,
|
||||
{
|
||||
method: "POST",
|
||||
path: `${ApiRoutes.users}/publisher-profile`,
|
||||
token,
|
||||
form,
|
||||
retryCount: 0,
|
||||
},
|
||||
ApiV1PublisherProfileUpdateResponseSchema,
|
||||
);
|
||||
spinner?.succeed(`Updated @${result.handle} profile`);
|
||||
if (options.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
spinner?.fail(formatError(error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdDeleteOrg(
|
||||
opts: GlobalOpts,
|
||||
handle: string,
|
||||
@@ -572,6 +647,20 @@ async function confirmOfficialOrgUpdate(
|
||||
if (!confirmed) fail("Canceled");
|
||||
}
|
||||
|
||||
async function confirmProfileUpdate(
|
||||
handle: string,
|
||||
options: OrgProfileUpdateOptions,
|
||||
inputAllowed: boolean,
|
||||
) {
|
||||
if (options.yes) return;
|
||||
if (!isInteractive() || inputAllowed === false) fail("Pass --yes (no input)");
|
||||
const fields = [options.bio?.trim() ? "bio" : "", options.logoFile?.trim() ? "logo" : ""]
|
||||
.filter(Boolean)
|
||||
.join(" and ");
|
||||
const confirmed = await promptConfirm(`Update @${handle} ${fields}? (admin only)`);
|
||||
if (!confirmed) fail("Canceled");
|
||||
}
|
||||
|
||||
function parseScopedPackageRepairCsv(content: string): ScopedPackageRepairRow[] {
|
||||
const records = parseCsvRecords(content).filter((record) =>
|
||||
record.some((cell) => cell.trim().length > 0),
|
||||
|
||||
@@ -317,6 +317,18 @@ export const ApiV1PublisherRemoveMemberResponseSchema = type({
|
||||
export type ApiV1PublisherRemoveMemberResponse =
|
||||
(typeof ApiV1PublisherRemoveMemberResponseSchema)[inferred];
|
||||
|
||||
export const ApiV1PublisherProfileUpdateResponseSchema = type({
|
||||
ok: "true",
|
||||
publisherId: "string",
|
||||
handle: "string",
|
||||
bio: "string|null",
|
||||
image: "string|null",
|
||||
bioUpdated: "boolean",
|
||||
logoUpdated: "boolean",
|
||||
});
|
||||
export type ApiV1PublisherProfileUpdateResponse =
|
||||
(typeof ApiV1PublisherProfileUpdateResponseSchema)[inferred];
|
||||
|
||||
export const ApiV1PublisherDeleteResponseSchema = type({
|
||||
ok: "true",
|
||||
publisherId: "string",
|
||||
|
||||
Reference in New Issue
Block a user