fix: Use project owner plan limits when project is shared via workspace (#5751)

This commit is contained in:
Oleg Isonen authored and GitHub committed 2026-05-09 13:12:47 +02:00
1 parent 7046cdcee9
commit 9c2116ade2
21 files changed
+725 -181

No files matched your search

@@ -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,
-1
View File
@@ -52,7 +52,6 @@ export const action = async (props: ActionFunctionArgs) => {
projectId,
type,
filename,
maxAssetsPerProject: context.planFeatures.maxAssetsPerProject,
},
context
);
@@ -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 });
@@ -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) {
+55 -5
View File
@@ -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"]);
});
});
+7 -2
View File
@@ -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<string> => {
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
+60 -3
View File
@@ -15,7 +15,8 @@ const createContext = (overrides: Partial<AppContext> = {}): 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<AppContext>)
);
expect(result).toEqual({ success: true });
expect(projectDomainInserted).toBe(true);
expect(ownerPlanCalls).toEqual(["owner-1"]);
});
});
// ---------------------------------------------------------------------------
+3 -2
View File
@@ -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<Result> => {
@@ -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:
+39 -22
View File
@@ -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(
+2 -1
View File
@@ -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 })
+2 -1
View File
@@ -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 })
+25 -5
View File
@@ -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
@@ -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<Pick<AppContext, "authorization" | "planFeatures">> = {}
): 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);
});
});
+48 -27
View File
@@ -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(
+16 -4
View File
@@ -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) {
@@ -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);
@@ -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<AppContext, "postgrest">
): Promise<string | undefined> => {
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,
};
@@ -103,8 +103,8 @@ export type AppContext = {
createTokenContext: (token: string) => Promise<AppContext>;
/**
* 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<PlanFeatures>;
};
@@ -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);
});
});
@@ -0,0 +1,99 @@
import type { PlanFeatures } from "@webstudio-is/plans";
import type { AppContext } from "./context.server";
type ProjectOwnerContext = Pick<AppContext, "postgrest">;
type ProjectPlanContext = Pick<
AppContext,
"postgrest" | "getOwnerPlanFeatures"
>;
type OwnerPlanContext = Pick<AppContext, "getOwnerPlanFeatures">;
const projectOwnerIdCache = new WeakMap<object, Map<string, Promise<string>>>();
const ownerPlanFeaturesCache = new WeakMap<
object,
Map<string, Promise<PlanFeatures>>
>();
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<string> => {
return await getCachedProjectOwnerId(context, projectId);
};
export const getPlanFeaturesByOwnerId = async (
ownerId: string,
context: OwnerPlanContext
): Promise<PlanFeatures> => {
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<PlanFeatures> => {
const ownerId = await getProjectOwnerId(projectId, context);
return await getPlanFeaturesByOwnerId(ownerId, context);
};
@@ -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,