diff --git a/apps/api/src/tools/organization/delete.test.ts b/apps/api/src/tools/organization/delete.test.ts index 56c09bde12..0afb9faf24 100644 --- a/apps/api/src/tools/organization/delete.test.ts +++ b/apps/api/src/tools/organization/delete.test.ts @@ -31,7 +31,7 @@ describe("ORGANIZATION_DELETE", () => { it("preserves existing metadata when archiving", async () => { const ctx = makeCtx({ description: "an acme org" }); - await ORGANIZATION_DELETE.handler({ id: "org-1" }, ctx); + await ORGANIZATION_DELETE.handler({}, ctx); const call = ctx.update.mock.calls[0]?.[0] as { data: { metadata: Record }; @@ -44,7 +44,7 @@ describe("ORGANIZATION_DELETE", () => { it("archives fine when there's no prior metadata", async () => { const ctx = makeCtx(undefined); - await ORGANIZATION_DELETE.handler({ id: "org-1" }, ctx); + await ORGANIZATION_DELETE.handler({}, ctx); const call = ctx.update.mock.calls[0]?.[0] as { data: { metadata: Record }; @@ -52,14 +52,15 @@ describe("ORGANIZATION_DELETE", () => { expect(call.data.metadata.archived).toBe(true); }); - it("rejects deleting an organization other than the authenticated one", async () => { - const ctx = makeCtx({ description: "an acme org" }); + it("archives the organization resolved from the request context", async () => { + const ctx = makeCtx(undefined); + + await ORGANIZATION_DELETE.handler({}, ctx); - await expect( - ORGANIZATION_DELETE.handler({ id: "org-2" }, ctx), - ).rejects.toThrow( - "Organization ID does not match authenticated organization", - ); - expect(ctx.update.mock.calls.length).toBe(0); + expect(ctx.get).toHaveBeenCalledWith("org-1"); + expect( + (ctx.update.mock.calls[0]?.[0] as { organizationId: string }) + .organizationId, + ).toBe("org-1"); }); }); diff --git a/apps/api/src/tools/organization/delete.ts b/apps/api/src/tools/organization/delete.ts index 96e44880c1..256bc78a81 100644 --- a/apps/api/src/tools/organization/delete.ts +++ b/apps/api/src/tools/organization/delete.ts @@ -7,7 +7,7 @@ import { z } from "zod"; import { defineTool } from "../../core/define-tool"; -import { requireAuth } from "../../core/studio-context"; +import { requireAuth, requireOrganization } from "../../core/studio-context"; export const ORGANIZATION_DELETE = defineTool({ name: "ORGANIZATION_DELETE", @@ -19,31 +19,26 @@ export const ORGANIZATION_DELETE = defineTool({ idempotentHint: true, openWorldHint: false, }, - inputSchema: z.object({ - id: z.string(), - }), + inputSchema: z.object({}), outputSchema: z.object({ success: z.boolean(), id: z.string(), }), - handler: async (input, ctx) => { + handler: async (_input, ctx) => { requireAuth(ctx); await ctx.access.check(); - // Reject a target org the caller isn't authenticated against (see member-remove.ts). - if (input.id !== ctx.organization?.id) { - throw new Error( - "Organization ID does not match authenticated organization", - ); - } + // The target org is the one resolved from the URL path; the input carries + // no id, so there is nothing to compare and nothing to spoof. + const org = requireOrganization(ctx); // Merge into existing metadata — organization.update replaces it wholesale. - const existing = await ctx.boundAuth.organization.get(input.id); + const existing = await ctx.boundAuth.organization.get(org.id); await ctx.boundAuth.organization.update({ - organizationId: input.id, + organizationId: org.id, data: { metadata: { ...existing?.metadata, @@ -55,7 +50,7 @@ export const ORGANIZATION_DELETE = defineTool({ return { success: true, - id: input.id, + id: org.id, }; }, }); diff --git a/apps/api/src/tools/organization/update.test.ts b/apps/api/src/tools/organization/update.test.ts index 366a3b0933..4f1badfffd 100644 --- a/apps/api/src/tools/organization/update.test.ts +++ b/apps/api/src/tools/organization/update.test.ts @@ -25,7 +25,7 @@ describe("ORGANIZATION_UPDATE", () => { it("persists an explicit empty description instead of dropping it", async () => { const ctx = makeCtx(); - await ORGANIZATION_UPDATE.handler({ id: "org-1", description: "" }, ctx); + await ORGANIZATION_UPDATE.handler({ description: "" }, ctx); expect(ctx.update).toHaveBeenCalledWith({ organizationId: "org-1", @@ -36,10 +36,7 @@ describe("ORGANIZATION_UPDATE", () => { it("still writes a non-empty description", async () => { const ctx = makeCtx(); - await ORGANIZATION_UPDATE.handler( - { id: "org-1", description: "hello" }, - ctx, - ); + await ORGANIZATION_UPDATE.handler({ description: "hello" }, ctx); expect(ctx.update).toHaveBeenCalledWith({ organizationId: "org-1", @@ -47,14 +44,14 @@ describe("ORGANIZATION_UPDATE", () => { }); }); - it("rejects updating an organization other than the authenticated one", async () => { + it("always targets the organization resolved from the request context", async () => { const ctx = makeCtx(); - await expect( - ORGANIZATION_UPDATE.handler({ id: "org-2", name: "Evil" }, ctx), - ).rejects.toThrow( - "Organization ID does not match authenticated organization", - ); - expect(ctx.update.mock.calls.length).toBe(0); + await ORGANIZATION_UPDATE.handler({ name: "Renamed" }, ctx); + + expect(ctx.update).toHaveBeenCalledWith({ + organizationId: "org-1", + data: { name: "Renamed" }, + }); }); }); diff --git a/apps/api/src/tools/organization/update.ts b/apps/api/src/tools/organization/update.ts index 99e7c78779..1728b58e10 100644 --- a/apps/api/src/tools/organization/update.ts +++ b/apps/api/src/tools/organization/update.ts @@ -6,7 +6,7 @@ import { z } from "zod"; import { defineTool } from "../../core/define-tool"; -import { requireAuth } from "../../core/studio-context"; +import { requireAuth, requireOrganization } from "../../core/studio-context"; export const ORGANIZATION_UPDATE = defineTool({ name: "ORGANIZATION_UPDATE", @@ -19,7 +19,6 @@ export const ORGANIZATION_UPDATE = defineTool({ openWorldHint: false, }, inputSchema: z.object({ - id: z.string(), name: z.string().min(1).max(255).optional(), description: z.string().optional(), }), @@ -40,12 +39,9 @@ export const ORGANIZATION_UPDATE = defineTool({ // Check authorization await ctx.access.check(); - // Reject a target org the caller isn't authenticated against (see member-remove.ts). - if (input.id !== ctx.organization?.id) { - throw new Error( - "Organization ID does not match authenticated organization", - ); - } + // The target org is the one resolved from the URL path; the input carries + // no id, so there is nothing to compare and nothing to spoof. + const org = requireOrganization(ctx); // Build update data // Slug is intentionally NOT updatable: it anchors org URLs (/api/:org/...) @@ -58,7 +54,7 @@ export const ORGANIZATION_UPDATE = defineTool({ // Update organization via Better Auth const result = await ctx.boundAuth.organization.update({ - organizationId: input.id, + organizationId: org.id, data: updateData, }); diff --git a/apps/web/src/components/settings/delete-organization-section.tsx b/apps/web/src/components/settings/delete-organization-section.tsx index 1973100006..8b77d32152 100644 --- a/apps/web/src/components/settings/delete-organization-section.tsx +++ b/apps/web/src/components/settings/delete-organization-section.tsx @@ -36,7 +36,7 @@ export function DeleteOrganizationSection() { const deleteMutation = useMutation({ mutationFn: async () => { - await studio.call("ORGANIZATION_DELETE", { id: org.id }); + await studio.call("ORGANIZATION_DELETE", {}); }, onSuccess: () => { track("organization_deleted", { organization_id: org.id }); diff --git a/packages/shared/src/tools/tool-io.ts b/packages/shared/src/tools/tool-io.ts index d84f578734..af94688c3b 100644 --- a/packages/shared/src/tools/tool-io.ts +++ b/packages/shared/src/tools/tool-io.ts @@ -46,7 +46,6 @@ export interface StudioToolIO { }; ORGANIZATION_UPDATE: { input: { - id: string; name?: string | undefined; description?: string | undefined; }; @@ -60,7 +59,7 @@ export interface StudioToolIO { }; }; ORGANIZATION_DELETE: { - input: { id: string }; + input: { [x: string]: never }; output: { success: boolean; id: string }; }; ORGANIZATION_SETTINGS_GET: {