From 9c2116ade2fffa3ea98568df2a4e712d12346754 Mon Sep 17 00:00:00 2001 From: Oleg Isonen Date: Sat, 9 May 2026 12:12:47 +0100 Subject: [PATCH] fix: Use project owner plan limits when project is shared via workspace (#5751) --- .../app/builder/features/publish/publish.tsx | 13 +- apps/builder/app/routes/rest.assets.tsx | 1 - .../services/workspace-router.server.test.ts | 77 ++++++++++- .../app/services/workspace-router.server.ts | 39 ++++-- packages/asset-uploader/src/upload.test.ts | 60 ++++++++- packages/asset-uploader/src/upload.ts | 9 +- packages/domain/src/db/domain.test.ts | 63 ++++++++- packages/domain/src/db/domain.ts | 5 +- packages/domain/src/trpc/domain.ts | 61 +++++---- packages/project/src/db/notification.ts | 3 +- packages/project/src/db/project.ts | 3 +- packages/project/src/db/workspace.ts | 30 ++++- .../project/src/trpc/project-router.test.ts | 118 +++++++++++++++++ packages/project/src/trpc/project-router.ts | 75 +++++++---- packages/project/src/trpc/workspace-router.ts | 20 ++- .../src/authorize/project.server.test.ts | 48 +------ .../src/authorize/project.server.ts | 53 ++------ .../src/context/context.server.ts | 4 +- .../src/context/project-plan.server.test.ts | 120 ++++++++++++++++++ .../src/context/project-plan.server.ts | 99 +++++++++++++++ packages/trpc-interface/src/index.server.ts | 5 + 21 files changed, 725 insertions(+), 181 deletions(-) create mode 100644 packages/project/src/trpc/project-router.test.ts create mode 100644 packages/trpc-interface/src/context/project-plan.server.test.ts create mode 100644 packages/trpc-interface/src/context/project-plan.server.ts diff --git a/apps/builder/app/builder/features/publish/publish.tsx b/apps/builder/app/builder/features/publish/publish.tsx index d278ebb4e9..7b209aa7f1 100644 --- a/apps/builder/app/builder/features/publish/publish.tsx +++ b/apps/builder/app/builder/features/publish/publish.tsx @@ -784,8 +784,10 @@ const useCanAddDomain = () => { (domain) => domain.status === "ACTIVE" && domain.verified ).length; useEffect(() => { - load(); - }, [load, activeDomainsCount]); + if (project?.id !== undefined) { + load({ projectId: project.id }); + } + }, [load, activeDomainsCount, project?.id]); const canAddDomain = data ? data.success && data.data < maxDomainsAllowedPerUser : true; @@ -795,9 +797,12 @@ const useCanAddDomain = () => { const useUserPublishCount = () => { const { load, data } = trpcClient.project.userPublishCount.useQuery(); const { maxDailyPublishesPerUser } = useStore($permissions); + const project = useStore($project); useEffect(() => { - load(); - }, [load]); + if (project?.id !== undefined) { + load({ projectId: project.id }); + } + }, [load, project?.id]); return { userPublishCount: data?.success ? data.data : 0, maxDailyPublishesPerUser, diff --git a/apps/builder/app/routes/rest.assets.tsx b/apps/builder/app/routes/rest.assets.tsx index 1e5c25a25a..09daa0d862 100644 --- a/apps/builder/app/routes/rest.assets.tsx +++ b/apps/builder/app/routes/rest.assets.tsx @@ -52,7 +52,6 @@ export const action = async (props: ActionFunctionArgs) => { projectId, type, filename, - maxAssetsPerProject: context.planFeatures.maxAssetsPerProject, }, context ); diff --git a/apps/builder/app/services/workspace-router.server.test.ts b/apps/builder/app/services/workspace-router.server.test.ts index 2494681ee3..70ab497aa8 100644 --- a/apps/builder/app/services/workspace-router.server.test.ts +++ b/apps/builder/app/services/workspace-router.server.test.ts @@ -32,7 +32,11 @@ const teamPlan: PlanFeatures = { }; const createContext = ( - overrides?: Partial<{ planFeatures: PlanFeatures; userId: string }> + overrides?: Partial<{ + planFeatures: PlanFeatures; + ownerPlanFeatures: PlanFeatures; + userId: string; + }> ): AppContext => ({ ...testContext, @@ -43,7 +47,8 @@ const createContext = ( planFeatures: overrides?.planFeatures ?? teamPlan, purchases: [], trpcCache: { setMaxAge: () => {} }, - getOwnerPlanFeatures: async () => overrides?.planFeatures ?? teamPlan, + getOwnerPlanFeatures: async () => + overrides?.ownerPlanFeatures ?? overrides?.planFeatures ?? teamPlan, }) as unknown as AppContext; // --------------------------------------------------------------------------- @@ -134,7 +139,11 @@ const setupAddMemberMocks = (opts?: { describe("addMember", () => { test("rejects when maxWorkspaces <= 1 (free/pro plan)", async () => { - // No DB mocks needed — the check happens before any queries. + server.use( + db.get("Workspace", () => + json({ id: "ws-1", userId: "owner-1", isDeleted: false }) + ) + ); const ctx = createContext({ planFeatures: defaultPlanFeatures }); const caller = createCaller(ctx); @@ -150,6 +159,35 @@ describe("addMember", () => { } }); + test("rejects non-owners before billing sync", async () => { + let paymentWorkerCalled = false; + + server.use( + db.get("Workspace", () => + json({ id: "ws-1", userId: "owner-1", isDeleted: false }) + ), + http.post(`${env.PAYMENT_WORKER_URL}/seats/sync`, () => { + paymentWorkerCalled = true; + return HttpResponse.json({ type: "success", seats: 1 }); + }) + ); + + const ctx = createContext({ userId: "member-1" }); + const caller = createCaller(ctx); + + const result = await caller.addMember({ + workspaceId: "ws-1", + email: "invitee@test.com", + relation: "editors", + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("Only the workspace owner"); + } + expect(paymentWorkerCalled).toBe(false); + }); + test("rejects when workspace seat limit reached", async () => { const plan: PlanFeatures = { ...teamPlan, maxSeatsPerWorkspace: 2 }; const ctx = createContext({ planFeatures: plan }); @@ -320,6 +358,11 @@ describe("removeMember", () => { describe("syncSeats", () => { test("rejects when maxWorkspaces <= 1", async () => { + server.use( + db.get("Workspace", () => + json({ id: "ws-1", userId: "owner-1", isDeleted: false }) + ) + ); const ctx = createContext({ planFeatures: defaultPlanFeatures }); const caller = createCaller(ctx); @@ -335,6 +378,9 @@ describe("syncSeats", () => { let capturedBody: { workspaceId: string; delta?: number } | undefined; server.use( + db.get("Workspace", () => + json({ id: "ws-1", userId: "owner-1", isDeleted: false }) + ), http.post(`${env.PAYMENT_WORKER_URL}/seats/sync`, async ({ request }) => { capturedBody = (await request.json()) as typeof capturedBody; return HttpResponse.json({ type: "success", seats: 3 }); @@ -376,7 +422,13 @@ describe("listMembers", () => { json({ id: "ws-1", userId: "owner-1", isDeleted: false }) ), // listMembers: WorkspaceMember SELECT (members list + access check) - db.get("WorkspaceMember", () => json(members)), + db.get("WorkspaceMember", ({ request }) => { + const url = new URL(request.url); + if (url.searchParams.has("userId")) { + return json({ userId: url.searchParams.get("userId") }); + } + return json(members); + }), // listMembers: Notification GET (pending invites for this workspace) db.get("Notification", () => json([])), // listMembers: User batch lookup @@ -449,6 +501,23 @@ describe("listMembers", () => { } }); + test("maxSeats uses workspace owner's seats for member callers", async () => { + setupListMembersMocks({ transactionLog: null }); + + const ctx = createContext({ + userId: "m-1", + planFeatures: defaultPlanFeatures, + ownerPlanFeatures: teamPlan, + }); + const caller = createCaller(ctx); + const result = await caller.listMembers({ workspaceId: "ws-1" }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.maxSeats).toBe(4); + } + }); + test("returns owner, members, and pendingInvites", async () => { setupListMembersMocks({ memberCount: 2, transactionLog: null }); diff --git a/apps/builder/app/services/workspace-router.server.ts b/apps/builder/app/services/workspace-router.server.ts index 1cffa72218..79120286df 100644 --- a/apps/builder/app/services/workspace-router.server.ts +++ b/apps/builder/app/services/workspace-router.server.ts @@ -3,6 +3,7 @@ import { router, procedure, createErrorResponse, + getPlanFeaturesByOwnerId, } from "@webstudio-is/trpc-interface/index.server"; import { workspace as workspaceApi } from "@webstudio-is/project/index.server"; import { roles } from "@webstudio-is/trpc-interface/authorize"; @@ -118,7 +119,12 @@ export const workspaceRouter = router({ ) .mutation(async ({ input, ctx }) => { try { - if (ctx.planFeatures.maxWorkspaces <= 1) { + const { plan } = await workspaceApi.assertWorkspaceOwnerPlan( + input.workspaceId, + ctx + ); + + if (plan.maxWorkspaces <= 1) { throw new Error("Upgrade your plan to invite members to workspaces."); } @@ -132,7 +138,7 @@ export const workspaceRouter = router({ ); } - const { maxSeatsPerWorkspace } = ctx.planFeatures; + const { maxSeatsPerWorkspace } = plan; if (maxSeatsPerWorkspace > 0) { const [membersResult, pendingResult] = await Promise.all([ ctx.postgrest.client @@ -229,7 +235,12 @@ export const workspaceRouter = router({ ) .mutation(async ({ input, ctx }) => { try { - if (ctx.planFeatures.maxWorkspaces <= 1) { + const { plan } = await workspaceApi.assertWorkspaceOwnerPlan( + input.workspaceId, + ctx + ); + + if (plan.maxWorkspaces <= 1) { throw new Error( "Upgrade your plan to manage workspace member roles." ); @@ -258,7 +269,12 @@ export const workspaceRouter = router({ .input(z.object({ workspaceId: z.string() })) .mutation(async ({ input, ctx }) => { try { - if (ctx.planFeatures.maxWorkspaces <= 1) { + const { plan } = await workspaceApi.assertWorkspaceOwnerPlan( + input.workspaceId, + ctx + ); + + if (plan.maxWorkspaces <= 1) { throw new Error("Upgrade your plan to manage workspace seats."); } await syncSeats(input.workspaceId); @@ -273,10 +289,10 @@ export const workspaceRouter = router({ .query(async ({ input, ctx }) => { try { const members = await workspaceApi.listMembers(input, ctx); - const extraPaidSeats = await getExtraPaidSeats( - members.owner.userId, - ctx - ); + const [ownerPlan, extraPaidSeats] = await Promise.all([ + getPlanFeaturesByOwnerId(members.owner.userId, ctx), + getExtraPaidSeats(members.owner.userId, ctx), + ]); return { success: true as const, data: { @@ -284,7 +300,7 @@ export const workspaceRouter = router({ // seatsIncluded = seats covered by the Team plan. // extraPaidSeats = extra seats from the Seats subscription. // Total capacity = included + extras. - maxSeats: ctx.planFeatures.seatsIncluded + (extraPaidSeats ?? 0), + maxSeats: ownerPlan.seatsIncluded + (extraPaidSeats ?? 0), }, }; } catch (error) { @@ -316,8 +332,9 @@ export const workspaceRouter = router({ throw new Error("Target workspace not found"); } - const ownerPlan = await ctx.getOwnerPlanFeatures( - targetWorkspace.data.userId + const ownerPlan = await getPlanFeaturesByOwnerId( + targetWorkspace.data.userId, + ctx ); if (ownerPlan.maxWorkspaces <= 1) { diff --git a/packages/asset-uploader/src/upload.test.ts b/packages/asset-uploader/src/upload.test.ts index 2bc83d7438..c94ed37cb4 100644 --- a/packages/asset-uploader/src/upload.test.ts +++ b/packages/asset-uploader/src/upload.test.ts @@ -11,11 +11,23 @@ import { createUploadName } from "./upload"; const server = createTestServer(); -const createContext = (): AppContext => +const createContext = ({ + maxAssetsPerProject = 350, + maxWorkspaces = 20, + ownerPlanCalls, +}: { + maxAssetsPerProject?: number; + maxWorkspaces?: number; + ownerPlanCalls?: string[]; +} = {}): AppContext => ({ ...testContext, authorization: { type: "user", userId: "user-1" }, - getOwnerPlanFeatures: () => Promise.resolve({}), + planFeatures: { maxAssetsPerProject: 100 }, + getOwnerPlanFeatures: (userId: string) => { + ownerPlanCalls?.push(userId); + return Promise.resolve({ maxAssetsPerProject, maxWorkspaces }); + }, }) as unknown as AppContext; const ownershipHandler = db.get("Project", ({ request }) => { @@ -23,7 +35,7 @@ const ownershipHandler = db.get("Project", ({ request }) => { if (url.searchParams.has("userId")) { return json({ id: url.searchParams.get("id")?.replace("eq.", "") }); } - return json(null); + return json({ userId: "owner-1", workspaceId: null }); }); describe("createUploadName", () => { @@ -64,7 +76,6 @@ describe("createUploadName", () => { projectId: "project-1", type: "image/png", filename: "photo.png", - maxAssetsPerProject: 350, }, createContext() ); @@ -91,10 +102,49 @@ describe("createUploadName", () => { projectId: "project-2", type: "image/png", filename: "photo.png", - maxAssetsPerProject: 350, }, createContext() ) ).rejects.toThrow("The maximum number of assets per project is 350."); }); + + test("uses project owner's asset limit for shared workspace projects", async () => { + let insertedFile = false; + const ownerPlanCalls: string[] = []; + + server.use( + db.get("Project", ({ request }) => { + const url = new URL(request.url); + if (url.searchParams.has("userId")) { + return json(null); + } + return json({ userId: "team-owner" }); + }), + db.get("WorkspaceProjectAuthorization", () => + json([{ relation: "editors" }]) + ), + db.head("Asset", () => empty({ headers: { "Content-Range": "*/100" } })), + db.head("File", () => empty({ headers: { "Content-Range": "*/0" } })), + db.post("File", () => { + insertedFile = true; + return empty({ status: 201 }); + }), + db.post("Asset", () => empty({ status: 201 })) + ); + + await expect( + createUploadName( + { + assetId: "asset-3", + projectId: "workspace-project", + type: "image/png", + filename: "photo.png", + }, + createContext({ maxAssetsPerProject: 350, ownerPlanCalls }) + ) + ).resolves.toMatch(/^photo_.+\.png$/); + + expect(insertedFile).toBe(true); + expect(ownerPlanCalls).toEqual(["team-owner"]); + }); }); diff --git a/packages/asset-uploader/src/upload.ts b/packages/asset-uploader/src/upload.ts index bb26fde1b7..3cf329f4db 100644 --- a/packages/asset-uploader/src/upload.ts +++ b/packages/asset-uploader/src/upload.ts @@ -2,6 +2,7 @@ import { type AppContext, authorizeProject, AuthorizationError, + getProjectPlanFeatures, } from "@webstudio-is/trpc-interface/index.server"; import type { Asset } from "@webstudio-is/sdk"; import type { AssetClient } from "./client"; @@ -14,7 +15,6 @@ type UploadData = { projectId: string; type: string; filename: string; - maxAssetsPerProject: number; }; const UPLOADING_STALE_TIMEOUT = 1000 * 60 * 30; // 30 minutes @@ -23,7 +23,7 @@ export const createUploadName = async ( data: UploadData, context: AppContext ): Promise => { - const { assetId, projectId, maxAssetsPerProject, type, filename } = data; + const { assetId, projectId, type, filename } = data; const canEdit = await authorizeProject.hasProjectPermit( { projectId, permit: "edit" }, context @@ -34,6 +34,11 @@ export const createUploadName = async ( ); } + const { maxAssetsPerProject } = await getProjectPlanFeatures( + projectId, + context + ); + /** * sometimes for example on request timeout we don't know what happened to the "UPLOADING" asset, * so we don't take into account assets with the "UPLOADING" status that were created more diff --git a/packages/domain/src/db/domain.test.ts b/packages/domain/src/db/domain.test.ts index 068fb4b63e..a8ad09014f 100644 --- a/packages/domain/src/db/domain.test.ts +++ b/packages/domain/src/db/domain.test.ts @@ -15,7 +15,8 @@ const createContext = (overrides: Partial = {}): AppContext => ({ ...testContext, authorization: { type: "user", userId: "owner-1" }, - getOwnerPlanFeatures: () => Promise.resolve({}), + getOwnerPlanFeatures: () => + Promise.resolve({ maxDomainsAllowedPerUser: 5, maxWorkspaces: 20 }), domain: {}, deployment: { deploymentTrpc: { @@ -101,7 +102,6 @@ describe("create — domain limit guard (msw)", () => { { projectId: "proj-1", domain: "example.com", - maxDomainsAllowedPerUser: 5, }, createContext() ); @@ -129,13 +129,70 @@ describe("create — domain limit guard (msw)", () => { { projectId: "proj-1", domain: "example.com", - maxDomainsAllowedPerUser: 5, }, createContext() ); expect(result).toEqual({ success: true }); }); + + test("uses project owner's domain limit for workspace member admins", async () => { + const ownerPlanCalls: string[] = []; + let projectDomainInserted = false; + + server.use( + db.get("Project", ({ request }) => { + const url = new URL(request.url); + if (url.searchParams.has("userId")) { + return json(null); + } + return json({ + id: "proj-1", + userId: "owner-1", + workspaceId: "ws-1", + title: "Test", + domain: "test", + isDeleted: false, + previewImageAsset: null, + latestBuildVirtual: null, + domainsVirtual: [], + latestStaticBuild: [], + }); + }), + db.get("WorkspaceProjectAuthorization", () => + json([{ relation: "administrators" }]) + ), + db.head("Domain", () => empty({ headers: { "Content-Range": "*/1" } })), + db.post("Domain", () => empty({ status: 204 })), + db.get("Domain", () => json({ id: "domain-id-1" })), + db.post("ProjectDomain", () => { + projectDomainInserted = true; + return empty({ status: 201 }); + }) + ); + + const result = await create( + { + projectId: "proj-1", + domain: "example.com", + }, + createContext({ + authorization: { type: "user", userId: "member-1" }, + planFeatures: { maxDomainsAllowedPerUser: 0 }, + getOwnerPlanFeatures: (userId: string) => { + ownerPlanCalls.push(userId); + return Promise.resolve({ + maxDomainsAllowedPerUser: 5, + maxWorkspaces: 20, + }); + }, + } as unknown as Partial) + ); + + expect(result).toEqual({ success: true }); + expect(projectDomainInserted).toBe(true); + expect(ownerPlanCalls).toEqual(["owner-1"]); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/domain/src/db/domain.ts b/packages/domain/src/db/domain.ts index e1d08d5070..261e7a4edf 100644 --- a/packages/domain/src/db/domain.ts +++ b/packages/domain/src/db/domain.ts @@ -3,6 +3,7 @@ import { type AppContext, AuthorizationError, createErrorResponse, + getPlanFeaturesByOwnerId, } from "@webstudio-is/trpc-interface/index.server"; import * as projectApi from "@webstudio-is/project/index.server"; import { unpublishBuild } from "@webstudio-is/project-build/index.server"; @@ -21,7 +22,6 @@ export const create = async ( props: { projectId: Project["id"]; domain: string; - maxDomainsAllowedPerUser: number; }, context: AppContext ): Promise => { @@ -45,9 +45,10 @@ export const create = async ( throw new AuthorizationError("Project must have project userId defined"); } + const ownerPlan = await getPlanFeaturesByOwnerId(ownerId, context); const totalDomainsCount = await countTotalDomains(ownerId, context); - if (totalDomainsCount >= props.maxDomainsAllowedPerUser) { + if (totalDomainsCount >= ownerPlan.maxDomainsAllowedPerUser) { return { success: false, error: diff --git a/packages/domain/src/trpc/domain.ts b/packages/domain/src/trpc/domain.ts index 85123e88ab..97fd20d059 100644 --- a/packages/domain/src/trpc/domain.ts +++ b/packages/domain/src/trpc/domain.ts @@ -9,6 +9,9 @@ import { router, procedure, createErrorResponse, + authorizeProject, + getProjectOwnerId, + AuthorizationError, } from "@webstudio-is/trpc-interface/index.server"; import { Templates } from "@webstudio-is/sdk"; import { db } from "../db"; @@ -240,13 +243,10 @@ export const domainRouter = router({ ) .mutation(async ({ input, ctx }) => { try { - const { planFeatures } = ctx; - return await db.create( { projectId: input.projectId, domain: input.domain, - maxDomainsAllowedPerUser: planFeatures.maxDomainsAllowedPerUser, }, ctx ); @@ -295,26 +295,43 @@ export const domainRouter = router({ return createErrorResponse(error); } }), - countTotalDomains: procedure.query(async ({ ctx }) => { - try { - if ( - ctx.authorization.type !== "user" && - ctx.authorization.type !== "token" - ) { - throw new Error("Not authorized"); + countTotalDomains: procedure + .input(z.object({ projectId: z.string() }).optional()) + .query(async ({ input, ctx }) => { + try { + if ( + ctx.authorization.type !== "user" && + ctx.authorization.type !== "token" + ) { + throw new Error("Not authorized"); + } + + let ownerId = + ctx.authorization.type === "user" + ? ctx.authorization.userId + : ctx.authorization.ownerId; + + if (input?.projectId !== undefined) { + const canView = await authorizeProject.hasProjectPermit( + { projectId: input.projectId, permit: "view" }, + ctx + ); + + if (canView === false) { + throw new AuthorizationError( + "Not authorized to access this project" + ); + } + + ownerId = await getProjectOwnerId(input.projectId, ctx); + } + + const data = await db.countTotalDomains(ownerId, ctx); + return { success: true, data } as const; + } catch (error) { + return createErrorResponse(error); } - - const ownerId = - ctx.authorization.type === "user" - ? ctx.authorization.userId - : ctx.authorization.ownerId; - - const data = await db.countTotalDomains(ownerId, ctx); - return { success: true, data } as const; - } catch (error) { - return createErrorResponse(error); - } - }), + }), updateStatus: procedure .input( diff --git a/packages/project/src/db/notification.ts b/packages/project/src/db/notification.ts index edc59b4ea1..1e63b0929a 100644 --- a/packages/project/src/db/notification.ts +++ b/packages/project/src/db/notification.ts @@ -1,6 +1,7 @@ import { type AppContext, AuthorizationError, + getPlanFeaturesByOwnerId, } from "@webstudio-is/trpc-interface/index.server"; import { type NotificationType, @@ -590,7 +591,7 @@ const performAcceptSideEffect = async ({ } // Check receiver's plan limit before accepting the transfer - const receiverPlan = await context.getOwnerPlanFeatures(userId); + const receiverPlan = await getPlanFeaturesByOwnerId(userId, context); const projectCount = await context.postgrest.client .from("Project") .select("id", { count: "exact", head: true }) diff --git a/packages/project/src/db/project.ts b/packages/project/src/db/project.ts index c84f3dcecb..5d50606c87 100644 --- a/packages/project/src/db/project.ts +++ b/packages/project/src/db/project.ts @@ -3,6 +3,7 @@ import { authorizeProject, type AppContext, AuthorizationError, + getPlanFeaturesByOwnerId, } from "@webstudio-is/trpc-interface/index.server"; import { createBuild } from "@webstudio-is/project-build/index.server"; import { Title } from "../shared/project-schema"; @@ -150,7 +151,7 @@ export const create = async ( } // Enforce the per-user project limit before creating anything - const ownerPlan = await context.getOwnerPlanFeatures(projectOwnerUserId); + const ownerPlan = await getPlanFeaturesByOwnerId(projectOwnerUserId, context); const projectCountResult = await context.postgrest.client .from("Project") .select("id", { count: "exact", head: true }) diff --git a/packages/project/src/db/workspace.ts b/packages/project/src/db/workspace.ts index 5274850412..d1f15f18b3 100644 --- a/packages/project/src/db/workspace.ts +++ b/packages/project/src/db/workspace.ts @@ -2,6 +2,7 @@ import type { Database } from "@webstudio-is/postgrest/index.server"; import { type AppContext, AuthorizationError, + getPlanFeaturesByOwnerId, } from "@webstudio-is/trpc-interface/index.server"; import { softDeleteProject } from "./project"; import { defaultRole, type Role } from "@webstudio-is/trpc-interface/authorize"; @@ -247,7 +248,7 @@ export const findMany = async (userId: string, context: AppContext) => { // break the entire workspace list. Failed checks default to not-downgraded. const checks = await Promise.allSettled( ownerIds.map(async (ownerId) => { - const plan = await context.getOwnerPlanFeatures(ownerId); + const plan = await getPlanFeaturesByOwnerId(ownerId, context); if (plan.maxWorkspaces <= 1) { return { ownerId, isDowngraded: true }; } @@ -281,7 +282,7 @@ export const findMany = async (userId: string, context: AppContext) => { return [...ownedWithRelation, ...memberWithRelation]; }; -const assertOwner = async ( +const assertWorkspaceOwnerByUserId = async ( workspaceId: string, userId: string, context: AppContext @@ -304,6 +305,25 @@ const assertOwner = async ( return workspace.data; }; +export const assertWorkspaceOwner = async ( + workspaceId: string, + context: AppContext +) => { + const userId = assertUser(context); + return await assertWorkspaceOwnerByUserId(workspaceId, userId, context); +}; + +export const assertWorkspaceOwnerPlan = async ( + workspaceId: string, + context: AppContext +) => { + const workspace = await assertWorkspaceOwner(workspaceId, context); + return { + workspace, + plan: await getPlanFeaturesByOwnerId(workspace.userId, context), + }; +}; + /** * Count all current members (non-removed) plus pending invites across all * non-deleted workspaces owned by userId. @@ -368,7 +388,7 @@ export const addMember = async ( context: AppContext ): Promise<{ notificationId: string }> => { const userId = assertUser(context); - await assertOwner(workspaceId, userId, context); + await assertWorkspaceOwnerByUserId(workspaceId, userId, context); // Look up the user by email const user = await context.postgrest.client @@ -433,7 +453,7 @@ export const updateRole = async ( context: AppContext ) => { const userId = assertUser(context); - await assertOwner(workspaceId, userId, context); + await assertWorkspaceOwnerByUserId(workspaceId, userId, context); if (memberUserId === userId) { throw new Error("Cannot change the workspace owner's role"); @@ -484,7 +504,7 @@ export const removeMember = async ( } } else { // Only the owner can remove other members. - await assertOwner(workspaceId, userId, context); + await assertWorkspaceOwnerByUserId(workspaceId, userId, context); } const result = await context.postgrest.client diff --git a/packages/project/src/trpc/project-router.test.ts b/packages/project/src/trpc/project-router.test.ts new file mode 100644 index 0000000000..e5ae9580b0 --- /dev/null +++ b/packages/project/src/trpc/project-router.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from "vitest"; +import { + createTestServer, + db, + json, + testContext, +} from "@webstudio-is/postgrest/testing"; +import { + createCallerFactory, + type AppContext, +} from "@webstudio-is/trpc-interface/index.server"; +import { defaultPlanFeatures } from "@webstudio-is/plans"; +import { projectRouter } from "./project-router"; + +const server = createTestServer(); +const createCaller = createCallerFactory(projectRouter); + +const createContext = ( + overrides: Partial> = {} +): AppContext => + ({ + ...testContext, + authorization: { type: "user", userId: "member-1" }, + planFeatures: defaultPlanFeatures, + getOwnerPlanFeatures: () => + Promise.resolve({ + ...defaultPlanFeatures, + maxWorkspaces: 20, + }), + ...overrides, + }) as unknown as AppContext; + +describe("userPublishCount", () => { + test("counts publishes for the project owner when called by a workspace member", async () => { + server.use( + db.get("Project", ({ request }) => { + const url = new URL(request.url); + if (url.searchParams.has("userId")) { + return json(null); + } + return json({ userId: "owner-1" }); + }), + db.get("WorkspaceProjectAuthorization", () => + json([{ relation: "editors" }]) + ), + db.get("user_publish_count", ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get("user_id")).toBe("eq.owner-1"); + return json({ count: 42 }); + }) + ); + + const caller = createCaller(createContext()); + const result = await caller.userPublishCount({ projectId: "proj-1" }); + + expect(result).toEqual({ success: true, data: 42 }); + }); + + test("counts publishes for the caller when projectId is omitted", async () => { + server.use( + db.get("user_publish_count", ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get("user_id")).toBe("eq.member-1"); + return json({ count: 7 }); + }) + ); + + const caller = createCaller(createContext()); + const result = await caller.userPublishCount(); + + expect(result).toEqual({ success: true, data: 7 }); + }); + + test("uses token owner when projectId is omitted for token auth", async () => { + server.use( + db.get("user_publish_count", ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get("user_id")).toBe("eq.token-owner"); + return json({ count: 9 }); + }) + ); + + const caller = createCaller( + createContext({ + authorization: { + type: "token", + authToken: "token-1", + ownerId: "token-owner", + }, + }) + ); + const result = await caller.userPublishCount(); + + expect(result).toEqual({ success: true, data: 9 }); + }); + + test("does not count publishes when caller cannot view the project", async () => { + let publishCountQueried = false; + + server.use( + db.get("Project", () => json(null)), + db.get("WorkspaceProjectAuthorization", () => json([])), + db.get("user_publish_count", () => { + publishCountQueried = true; + return json({ count: 0 }); + }) + ); + + const caller = createCaller(createContext()); + const result = await caller.userPublishCount({ projectId: "private" }); + + expect(result.success).toBe(false); + if ("error" in result) { + expect(result.error).toContain("Not authorized"); + } + expect(publishCountQueried).toBe(false); + }); +}); diff --git a/packages/project/src/trpc/project-router.ts b/packages/project/src/trpc/project-router.ts index e864aad245..6f4a441815 100644 --- a/packages/project/src/trpc/project-router.ts +++ b/packages/project/src/trpc/project-router.ts @@ -6,6 +6,7 @@ import { AuthorizationError, authorizeProject, createErrorResponse, + getProjectOwnerId, } from "@webstudio-is/trpc-interface/index.server"; import { Title } from "../shared/project-schema"; import { MarketplaceApprovalStatus } from "../shared/marketplace-schema"; @@ -84,34 +85,54 @@ export const projectRouter = router({ return projectIds.map((project) => project.id); }), - userPublishCount: procedure.query(async ({ ctx }) => { - try { - if ( - ctx.authorization.type !== "user" && - ctx.authorization.type !== "token" - ) { - throw new Error("Not authorized"); + userPublishCount: procedure + .input(z.object({ projectId: z.string() }).optional()) + .query(async ({ input, ctx }) => { + try { + if ( + ctx.authorization.type !== "user" && + ctx.authorization.type !== "token" + ) { + throw new Error("Not authorized"); + } + const userId = + ctx.authorization.type === "user" + ? ctx.authorization.userId + : ctx.authorization.ownerId; + + let ownerId = userId; + + if (input?.projectId !== undefined) { + const canView = await authorizeProject.hasProjectPermit( + { projectId: input.projectId, permit: "view" }, + ctx + ); + + if (canView === false) { + throw new AuthorizationError( + "Not authorized to access this project" + ); + } + + ownerId = await getProjectOwnerId(input.projectId, ctx); + } + + const result = await ctx.postgrest.client + .from("user_publish_count") + .select("count") + .eq("user_id", ownerId) + .maybeSingle(); + if (result.error) { + throw result.error; + } + return { + success: true, + data: result.data?.count ?? 0, + }; + } catch (error) { + return createErrorResponse(error); } - const userId = - ctx.authorization.type === "user" - ? ctx.authorization.userId - : ctx.authorization.ownerId; - const result = await ctx.postgrest.client - .from("user_publish_count") - .select("count") - .eq("user_id", userId) - .maybeSingle(); - if (result.error) { - throw result.error; - } - return { - success: true, - data: result.data?.count ?? 0, - }; - } catch (error) { - return createErrorResponse(error); - } - }), + }), publishedBuilds: procedure .input( diff --git a/packages/project/src/trpc/workspace-router.ts b/packages/project/src/trpc/workspace-router.ts index 76d62afc9a..bbe6adc32a 100644 --- a/packages/project/src/trpc/workspace-router.ts +++ b/packages/project/src/trpc/workspace-router.ts @@ -4,6 +4,7 @@ import { router, procedure, createErrorResponse, + getPlanFeaturesByOwnerId, } from "@webstudio-is/trpc-interface/index.server"; import { roles } from "@webstudio-is/trpc-interface/authorize"; @@ -77,7 +78,12 @@ export const workspaceRouter = router({ ) .mutation(async ({ input, ctx }) => { try { - if (ctx.planFeatures.maxWorkspaces <= 1) { + const { plan } = await workspaceApi.assertWorkspaceOwnerPlan( + input.workspaceId, + ctx + ); + + if (plan.maxWorkspaces <= 1) { throw new Error("Upgrade your plan to invite members to workspaces."); } @@ -98,7 +104,12 @@ export const workspaceRouter = router({ ) .mutation(async ({ input, ctx }) => { try { - if (ctx.planFeatures.maxWorkspaces <= 1) { + const { plan } = await workspaceApi.assertWorkspaceOwnerPlan( + input.workspaceId, + ctx + ); + + if (plan.maxWorkspaces <= 1) { throw new Error( "Upgrade your plan to manage workspace member roles." ); @@ -158,8 +169,9 @@ export const workspaceRouter = router({ throw new Error("Target workspace not found"); } - const ownerPlan = await ctx.getOwnerPlanFeatures( - targetWorkspace.data.userId + const ownerPlan = await getPlanFeaturesByOwnerId( + targetWorkspace.data.userId, + ctx ); if (ownerPlan.maxWorkspaces <= 1) { diff --git a/packages/trpc-interface/src/authorize/project.server.test.ts b/packages/trpc-interface/src/authorize/project.server.test.ts index 801dd245fe..c9294a3a15 100644 --- a/packages/trpc-interface/src/authorize/project.server.test.ts +++ b/packages/trpc-interface/src/authorize/project.server.test.ts @@ -14,7 +14,7 @@ import { __testing__, } from "./project.server"; -const { isRolePermitted, getWorkspaceOwnerIdForProject } = __testing__; +const { isRolePermitted } = __testing__; describe("isRolePermitted", () => { describe("workspace owner (own relation)", () => { @@ -175,41 +175,6 @@ const makeUserCtx = (userId = "user-1", maxWorkspaces = 5): AppContext => }), }) as unknown as AppContext; -// --------------------------------------------------------------------------- -// getWorkspaceOwnerIdForProject -// --------------------------------------------------------------------------- - -describe("getWorkspaceOwnerIdForProject (msw)", () => { - test("returns undefined when project has no workspace", async () => { - const projectId = uid(); - server.use( - db.get("Project", () => json({ id: projectId, workspaceId: null })) - ); - const result = await getWorkspaceOwnerIdForProject(projectId, testContext); - expect(result).toBeUndefined(); - }); - - test("returns workspace owner userId when workspace exists", async () => { - const projectId = uid(); - server.use( - db.get("Project", () => json({ id: projectId, workspaceId: "ws-1" })), - db.get("Workspace", () => json({ id: "ws-1", userId: "owner-99" })) - ); - const result = await getWorkspaceOwnerIdForProject(projectId, testContext); - expect(result).toBe("owner-99"); - }); - - test("returns undefined when workspace not found", async () => { - const projectId = uid(); - server.use( - db.get("Project", () => json({ id: projectId, workspaceId: "ws-gone" })), - db.get("Workspace", () => json(null)) - ); - const result = await getWorkspaceOwnerIdForProject(projectId, testContext); - expect(result).toBeUndefined(); - }); -}); - // --------------------------------------------------------------------------- // checkProjectPermit — user auth // --------------------------------------------------------------------------- @@ -354,18 +319,16 @@ describe("hasProjectPermit — workspace downgrade guard (msw)", () => { test("denies workspace member when owner plan has maxWorkspaces <= 1", async () => { const projectId = uid(); server.use( - // Per-call routing: ownership check uses userId param; workspaceId lookup does not db.get("Project", ({ request }) => { const url = new URL(request.url); if (url.searchParams.has("userId")) { return json(null); } - return json({ id: projectId, workspaceId: "ws-1" }); + return json({ id: projectId, userId: "owner-1" }); }), db.get("WorkspaceProjectAuthorization", () => json([{ relation: "editors" }]) - ), - db.get("Workspace", () => json({ id: "ws-1", userId: "owner-1" })) + ) ); const ctx = makeUserCtx("user-2", 1); @@ -381,12 +344,11 @@ describe("hasProjectPermit — workspace downgrade guard (msw)", () => { if (url.searchParams.has("userId")) { return json(null); } - return json({ id: projectId, workspaceId: "ws-1" }); + return json({ id: projectId, userId: "owner-1" }); }), db.get("WorkspaceProjectAuthorization", () => json([{ relation: "editors" }]) - ), - db.get("Workspace", () => json({ id: "ws-1", userId: "owner-1" })) + ) ); const ctx = makeUserCtx("user-2", 5); diff --git a/packages/trpc-interface/src/authorize/project.server.ts b/packages/trpc-interface/src/authorize/project.server.ts index b68bfa225c..7ce703566d 100644 --- a/packages/trpc-interface/src/authorize/project.server.ts +++ b/packages/trpc-interface/src/authorize/project.server.ts @@ -1,6 +1,7 @@ import type { AppContext } from "../context/context.server"; import type { Role } from "./role"; import memoize from "memoize"; +import { getProjectPlanFeatures } from "../context/project-plan.server"; type Relation = Role; @@ -228,34 +229,6 @@ export const checkProjectPermit = async ({ return allowed; }; -/** - * Look up the workspace owner's userId for a given project. - * Returns undefined when the project has no workspace. - */ -const getWorkspaceOwnerIdForProject = async ( - projectId: string, - context: Pick -): Promise => { - const project = await context.postgrest.client - .from("Project") - .select("workspaceId") - .eq("id", projectId) - .maybeSingle(); - - if (project.error || project.data?.workspaceId == null) { - return; - } - - const workspace = await context.postgrest.client - .from("Workspace") - .select("userId") - .eq("id", project.data.workspaceId) - .eq("isDeleted", false) - .maybeSingle(); - - return workspace.data?.userId ?? undefined; -}; - export const hasProjectPermit = async ( props: { projectId: string; @@ -287,11 +260,11 @@ export const hasProjectPermit = async ( } // Workspace downgrade check: when a workspace member accesses a project, - // verify the workspace owner's plan still supports workspace features. - // Direct project owners and workspace owners are not affected. + // verify the project owner's plan still supports workspace features. + // Direct project owners are not affected. if (authorization.type === "user") { - // "own" permit is only granted to direct owners and workspace owners — - // both are unaffected by downgrade. This call is memoized. + // "own" permit is only granted to direct project owners. + // They are unaffected by downgrade. This call is memoized. const isOwner = await checkProjectPermit({ projectId: props.projectId, permit: "own", @@ -300,17 +273,10 @@ export const hasProjectPermit = async ( }); if (isOwner === false) { - // User is a workspace member — verify the owner's plan - const workspaceOwnerId = await getWorkspaceOwnerIdForProject( - props.projectId, - context - ); - - if (workspaceOwnerId !== undefined) { - const ownerPlan = await context.getOwnerPlanFeatures(workspaceOwnerId); - if (ownerPlan.maxWorkspaces <= 1) { - return false; - } + // User is a workspace member — verify the project owner's plan. + const ownerPlan = await getProjectPlanFeatures(props.projectId, context); + if (ownerPlan.maxWorkspaces <= 1) { + return false; } } } @@ -351,5 +317,4 @@ export const getProjectPermit = async ( export const __testing__ = { isRolePermitted, - getWorkspaceOwnerIdForProject, }; diff --git a/packages/trpc-interface/src/context/context.server.ts b/packages/trpc-interface/src/context/context.server.ts index 8ef15b1cfa..7b26783570 100644 --- a/packages/trpc-interface/src/context/context.server.ts +++ b/packages/trpc-interface/src/context/context.server.ts @@ -103,8 +103,8 @@ export type AppContext = { createTokenContext: (token: string) => Promise; /** * Resolves plan features for a given user ID. - * Used by workspace authorization to check whether the workspace owner's plan - * still supports workspace features (maxWorkspaces > 1) after a potential downgrade. + * Use the shared plan helper when possible so repeated lookups are cached + * consistently within the request. */ getOwnerPlanFeatures: (userId: string) => Promise; }; diff --git a/packages/trpc-interface/src/context/project-plan.server.test.ts b/packages/trpc-interface/src/context/project-plan.server.test.ts new file mode 100644 index 0000000000..ee2be092c7 --- /dev/null +++ b/packages/trpc-interface/src/context/project-plan.server.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from "vitest"; +import { + createTestServer, + db, + json, + testContext, +} from "@webstudio-is/postgrest/testing"; +import { defaultPlanFeatures } from "@webstudio-is/plans"; +import type { AppContext } from "./context.server"; +import { + getPlanFeaturesByOwnerId, + getProjectOwnerId, + getProjectPlanFeatures, +} from "./project-plan.server"; + +const server = createTestServer(); + +const createContext = ( + getOwnerPlanFeatures: AppContext["getOwnerPlanFeatures"] = async () => + defaultPlanFeatures +): AppContext => + ({ + ...testContext, + getOwnerPlanFeatures, + }) as unknown as AppContext; + +describe("project plan helpers", () => { + test("resolves project owner id from Project.userId and caches it per context", async () => { + let projectQueries = 0; + server.use( + db.get("Project", () => { + projectQueries += 1; + return json({ userId: "owner-1" }); + }) + ); + + const context = createContext(); + + await expect(getProjectOwnerId("project-1", context)).resolves.toBe( + "owner-1" + ); + await expect(getProjectOwnerId("project-1", context)).resolves.toBe( + "owner-1" + ); + + expect(projectQueries).toBe(1); + }); + + test("resolves project plan features from the project owner and caches owner plan", async () => { + const ownerPlanCalls: string[] = []; + server.use(db.get("Project", () => json({ userId: "owner-1" }))); + + const context = createContext(async (ownerId) => { + ownerPlanCalls.push(ownerId); + return { + ...defaultPlanFeatures, + maxAssetsPerProject: 350, + }; + }); + + await expect( + getProjectPlanFeatures("project-1", context) + ).resolves.toMatchObject({ + maxAssetsPerProject: 350, + }); + await expect( + getProjectPlanFeatures("project-1", context) + ).resolves.toMatchObject({ + maxAssetsPerProject: 350, + }); + + expect(ownerPlanCalls).toEqual(["owner-1"]); + }); + + test("rejects projects without an owner and retries after the failed cache entry is cleared", async () => { + let projectQueries = 0; + server.use( + db.get("Project", () => { + projectQueries += 1; + return json({ + userId: projectQueries === 1 ? null : "owner-2", + }); + }) + ); + + const context = createContext(); + + await expect(getProjectOwnerId("project-2", context)).rejects.toThrow( + "Project must have project userId defined" + ); + await expect(getProjectOwnerId("project-2", context)).resolves.toBe( + "owner-2" + ); + expect(projectQueries).toBe(2); + }); + + test("clears failed owner-plan cache entries before retrying", async () => { + let ownerPlanCalls = 0; + const context = createContext(async () => { + ownerPlanCalls += 1; + if (ownerPlanCalls === 1) { + throw new Error("temporary plan failure"); + } + return { + ...defaultPlanFeatures, + maxDomainsAllowedPerUser: 5, + }; + }); + + await expect(getPlanFeaturesByOwnerId("owner-3", context)).rejects.toThrow( + "temporary plan failure" + ); + await expect( + getPlanFeaturesByOwnerId("owner-3", context) + ).resolves.toMatchObject({ + maxDomainsAllowedPerUser: 5, + }); + expect(ownerPlanCalls).toBe(2); + }); +}); diff --git a/packages/trpc-interface/src/context/project-plan.server.ts b/packages/trpc-interface/src/context/project-plan.server.ts new file mode 100644 index 0000000000..00f338adcd --- /dev/null +++ b/packages/trpc-interface/src/context/project-plan.server.ts @@ -0,0 +1,99 @@ +import type { PlanFeatures } from "@webstudio-is/plans"; +import type { AppContext } from "./context.server"; + +type ProjectOwnerContext = Pick; +type ProjectPlanContext = Pick< + AppContext, + "postgrest" | "getOwnerPlanFeatures" +>; +type OwnerPlanContext = Pick; + +const projectOwnerIdCache = new WeakMap>>(); +const ownerPlanFeaturesCache = new WeakMap< + object, + Map> +>(); + +const getCachedProjectOwnerId = ( + context: ProjectOwnerContext, + projectId: string +) => { + let cache = projectOwnerIdCache.get(context); + if (cache === undefined) { + cache = new Map(); + projectOwnerIdCache.set(context, cache); + } + + let promise = cache.get(projectId); + if (promise !== undefined) { + return promise; + } + + promise = (async () => { + const projectResult = await context.postgrest.client + .from("Project") + .select("userId") + .eq("id", projectId) + .eq("isDeleted", false) + .single(); + + if (projectResult.error) { + throw projectResult.error; + } + + if (projectResult.data.userId === null) { + throw new Error("Project must have project userId defined"); + } + + return projectResult.data.userId; + })(); + + promise.catch(() => { + if (cache.get(projectId) === promise) { + cache.delete(projectId); + } + }); + cache.set(projectId, promise); + return promise; +}; + +export const getProjectOwnerId = async ( + projectId: string, + context: ProjectOwnerContext +): Promise => { + return await getCachedProjectOwnerId(context, projectId); +}; + +export const getPlanFeaturesByOwnerId = async ( + ownerId: string, + context: OwnerPlanContext +): Promise => { + let cache = ownerPlanFeaturesCache.get(context); + if (cache === undefined) { + cache = new Map(); + ownerPlanFeaturesCache.set(context, cache); + } + + let promise = cache.get(ownerId); + if (promise !== undefined) { + return await promise; + } + + promise = context.getOwnerPlanFeatures(ownerId); + + promise.catch(() => { + if (cache.get(ownerId) === promise) { + cache.delete(ownerId); + } + }); + cache.set(ownerId, promise); + return await promise; +}; + +export const getProjectPlanFeatures = async ( + projectId: string, + context: ProjectPlanContext +): Promise => { + const ownerId = await getProjectOwnerId(projectId, context); + return await getPlanFeaturesByOwnerId(ownerId, context); +}; diff --git a/packages/trpc-interface/src/index.server.ts b/packages/trpc-interface/src/index.server.ts index 59fdc0c332..db79e82c17 100644 --- a/packages/trpc-interface/src/index.server.ts +++ b/packages/trpc-interface/src/index.server.ts @@ -2,6 +2,11 @@ export type { SharedRouter } from "./shared/shared-router"; export { createTrpcProxyServiceClient } from "./shared/client"; export type { AppContext } from "./context/context.server"; +export { + getProjectOwnerId, + getProjectPlanFeatures, + getPlanFeaturesByOwnerId, +} from "./context/project-plan.server"; export { AuthorizationError,