From 6381d789ab1883011639ca8e2aaec53202f0aad5 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Wed, 5 Aug 2026 12:32:55 -0700 Subject: [PATCH] feat(admin): support org profile updates (#3416) --- .agents/skills/clawhub-moderation/SKILL.md | 6 +- convex/httpApiV1.handlers.test.ts | 99 ++++++++++++ convex/httpApiV1/usersV1.ts | 89 +++++++++++ convex/publishers.test.ts | 147 ++++++++++++++++++ convex/publishers.ts | 89 +++++++++++ packages/clawhub-admin/README.md | 3 + packages/clawhub-admin/src/cli.ts | 16 ++ .../clawhub-admin/src/commands/orgs.test.ts | 70 +++++++++ packages/clawhub-admin/src/commands/orgs.ts | 93 ++++++++++- packages/clawhub/src/schema/schemas.ts | 12 ++ 10 files changed, 621 insertions(+), 3 deletions(-) diff --git a/.agents/skills/clawhub-moderation/SKILL.md b/.agents/skills/clawhub-moderation/SKILL.md index 7376d796..7ef38541 100644 --- a/.agents/skills/clawhub-moderation/SKILL.md +++ b/.agents/skills/clawhub-moderation/SKILL.md @@ -115,6 +115,7 @@ has asked for fuzzy handle resolution or the exact handle is ambiguous. ```text official create +profile update remove-member delete repair-scoped-packages @@ -127,6 +128,8 @@ bun run admin -- org official list bun run admin -- org official add --reason "" --yes bun run admin -- org official remove --reason "" --yes bun run admin -- org create --display-name "" --member --role owner +bun run admin -- org profile update --bio "" --reason "" --yes +bun run admin -- org profile update --logo-file --reason "" --yes bun run admin -- org remove-member bun run admin -- org delete --reason "" # dry-run bun run admin -- org delete --reason "" --apply @@ -136,7 +139,8 @@ bun run admin -- org repair-scoped-packages --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 diff --git a/convex/httpApiV1.handlers.test.ts b/convex/httpApiV1.handlers.test.ts index 1c67a387..c00aba59 100644 --- a/convex/httpApiV1.handlers.test.ts +++ b/convex/httpApiV1.handlers.test.ts @@ -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) => { + 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) => { + 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) => { if (isRateLimitArgs(args)) return okRate(); diff --git a/convex/httpApiV1/usersV1.ts b/convex/httpApiV1/usersV1.ts index 249e6353..e225179b 100644 --- a/convex/httpApiV1/usersV1.ts +++ b/convex/httpApiV1/usersV1.ts @@ -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; + 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; + } 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, diff --git a/convex/publishers.test.ts b/convex/publishers.test.ts index d690d4e1..55b04303 100644 --- a/convex/publishers.test.ts +++ b/convex/publishers.test.ts @@ -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 = [ { diff --git a/convex/publishers.ts b/convex/publishers.ts index 1c8430fd..a191d94e 100644 --- a/convex/publishers.ts +++ b/convex/publishers.ts @@ -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"), diff --git a/packages/clawhub-admin/README.md b/packages/clawhub-admin/README.md index b0a2c893..d46b8d5e 100644 --- a/packages/clawhub-admin/README.md +++ b/packages/clawhub-admin/README.md @@ -94,10 +94,13 @@ Org publisher administration: ```bash bun run admin -- org create --member [--display-name ] [--role owner|admin|publisher] [--trusted] [--json] +bun run admin -- org profile update [--bio ] [--logo-file ] --reason [--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: diff --git a/packages/clawhub-admin/src/cli.ts b/packages/clawhub-admin/src/cli.ts index e62cb2f1..36d4c654 100644 --- a/packages/clawhub-admin/src/cli.ts +++ b/packages/clawhub-admin/src/cli.ts @@ -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("", "Org publisher handle") + .option("--bio ", "Publisher bio") + .option("--logo-file ", "PNG, JPEG, or WebP logo under 2 MB") + .requiredOption("--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") diff --git a/packages/clawhub-admin/src/commands/orgs.test.ts b/packages/clawhub-admin/src/commands/orgs.test.ts index f11be424..52423366 100644 --- a/packages/clawhub-admin/src/commands/orgs.test.ts +++ b/packages/clawhub-admin/src/commands/orgs.test.ts @@ -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({ diff --git a/packages/clawhub-admin/src/commands/orgs.ts b/packages/clawhub-admin/src/commands/orgs.ts index dedd2dca..126f3c7b 100644 --- a/packages/clawhub-admin/src/commands/orgs.ts +++ b/packages/clawhub-admin/src/commands/orgs.ts @@ -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), diff --git a/packages/clawhub/src/schema/schemas.ts b/packages/clawhub/src/schema/schemas.ts index c2c92251..89ef5a84 100644 --- a/packages/clawhub/src/schema/schemas.ts +++ b/packages/clawhub/src/schema/schemas.ts @@ -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",