Files
Oleg Isonen ff06f58e32 feat: workspace downgrade — revoke member access when owner's plan drops
When a workspace owner's plan no longer supports workspaces (maxWorkspaces <= 1),
member access is revoked while preserving workspace data and memberships:

- Add getOwnerPlanFeatures callback to AppContext for cross-user plan lookups
- Check workspace owner plan in hasProjectPermit to deny non-owner access
- Mark member workspaces as isDowngraded in findMany based on owner plan
- Gate updateWorkspaceRelation behind maxWorkspaces check
- Block downgraded workspace members in builder route loader
- Show ProBadge "Suspended" and disable selection for downgraded workspaces
2026-03-16 19:43:51 +00:00

254 lines
6.6 KiB
TypeScript

import { type AppContext } from "@webstudio-is/trpc-interface/index.server";
import env from "~/env/env.server";
import { authenticator } from "~/services/auth.server";
import { trpcSharedClient } from "~/services/trpc.server";
import { entryApi } from "./entri/entri-api.server";
import { defaultUserPlanFeatures } from "@webstudio-is/trpc-interface/user-plan-features";
import { getUserPlanInfo } from "./db/user-plan-features.server";
import { staticEnv } from "~/env/env.static.server";
import { createClient } from "@webstudio-is/postgrest/index.server";
import { builderAuthenticator } from "~/services/builder-auth.server";
import { readLoginSessionBloomFilter } from "~/services/session.server";
import type { BloomFilter } from "~/services/bloom-filter.server";
import { isBuilder, isCanvas } from "./router-utils";
import { parseBuilderUrl } from "@webstudio-is/http-client";
export const extractAuthFromRequest = async (request: Request) => {
if (isCanvas(request)) {
throw new Error("Canvas requests can't have authorization context");
}
const url = new URL(request.url);
const authToken =
url.searchParams.get("authToken") ??
request.headers.get("x-auth-token") ??
undefined;
const sessionData = isBuilder(request)
? await builderAuthenticator.isAuthenticated(request)
: await authenticator.isAuthenticated(request);
const isServiceCall =
request.headers.has("Authorization") &&
request.headers.get("Authorization") === env.TRPC_SERVER_API_TOKEN;
return {
authToken,
sessionData,
isServiceCall,
};
};
const createTokenAuthorizationContext = async (
authToken: string,
postgrest: AppContext["postgrest"]
) => {
const projectOwnerIdByToken = await postgrest.client
.from("AuthorizationToken")
.select("project:Project(id, userId)")
.eq("token", authToken)
.single();
if (projectOwnerIdByToken.error) {
throw new Error(`Project owner can't be found for token ${authToken}`);
}
const ownerId = projectOwnerIdByToken.data.project?.userId ?? null;
if (ownerId === null) {
throw new Error(
`Project ${projectOwnerIdByToken.data.project?.id} has null userId`
);
}
return {
type: "token" as const,
authToken,
ownerId,
};
};
const createAuthorizationContext = async (
request: Request,
postgrest: AppContext["postgrest"]
): Promise<AppContext["authorization"]> => {
const { authToken, isServiceCall, sessionData } =
await extractAuthFromRequest(request);
if (isServiceCall) {
return {
type: "service",
isServiceCall: true,
};
}
if (authToken != null) {
return await createTokenAuthorizationContext(authToken, postgrest);
}
if (sessionData?.userId != null) {
const userId = sessionData.userId;
let loginBloomFilter: BloomFilter | undefined = undefined;
let isLoggedInToBuilder = async (projectId: string) => {
if (loginBloomFilter === undefined) {
loginBloomFilter = await readLoginSessionBloomFilter(request);
}
return loginBloomFilter.has(projectId);
};
if (isBuilder(request)) {
isLoggedInToBuilder = async (projectId: string) => {
const parsedUrl = parseBuilderUrl(request.url);
return parsedUrl.projectId === projectId;
};
}
return {
type: "user",
userId,
sessionCreatedAt: sessionData.createdAt,
isLoggedInToBuilder,
};
}
return { type: "anonymous" };
};
const createDomainContext = () => {
const context: AppContext["domain"] = {
domainTrpc: trpcSharedClient.domain,
};
return context;
};
const getRequestOrigin = (urlStr: string) => {
const url = new URL(urlStr);
return url.origin;
};
const createDeploymentContext = (builderOrigin: string) => {
const context: AppContext["deployment"] = {
deploymentTrpc: trpcSharedClient.deployment,
env: {
BUILDER_ORIGIN: getRequestOrigin(builderOrigin),
GITHUB_REF_NAME: staticEnv.GITHUB_REF_NAME ?? "undefined",
GITHUB_SHA: staticEnv.GITHUB_SHA ?? undefined,
PUBLISHER_HOST: env.PUBLISHER_HOST,
},
};
return context;
};
const createEntriContext = () => {
return {
entryApi,
};
};
const createUserPlanContext = async (
authorization: AppContext["authorization"],
postgrest: AppContext["postgrest"]
): Promise<{
userPlanFeatures: AppContext["userPlanFeatures"];
purchases: AppContext["purchases"];
}> => {
const ownerId =
authorization.type === "token"
? authorization.ownerId
: authorization.type === "user"
? authorization.userId
: undefined;
if (ownerId === undefined) {
return { userPlanFeatures: defaultUserPlanFeatures, purchases: [] };
}
return getUserPlanInfo(ownerId, postgrest);
};
const createTrpcCache = () => {
const proceduresMaxAge = new Map<string, number>();
const setMaxAge = (path: string, value: number) => {
proceduresMaxAge.set(
path,
Math.min(proceduresMaxAge.get(path) ?? Number.MAX_SAFE_INTEGER, value)
);
};
const getMaxAge = (path: string) => proceduresMaxAge.get(path);
return {
setMaxAge,
getMaxAge,
};
};
export const createPostgrestContext = () => {
return { client: createClient(env.POSTGREST_URL, env.POSTGREST_API_KEY) };
};
/**
* argument buildEnv==="prod" only if we are loading project with production build
*/
export const createContext = async (request: Request): Promise<AppContext> => {
const postgrest = createPostgrestContext();
const authorization = await createAuthorizationContext(request, postgrest);
const domain = createDomainContext();
const deployment = createDeploymentContext(getRequestOrigin(request.url));
const entri = createEntriContext();
const { userPlanFeatures, purchases } = await createUserPlanContext(
authorization,
postgrest
);
const trpcCache = createTrpcCache();
const getOwnerPlanFeatures = async (userId: string) => {
const { userPlanFeatures } = await getUserPlanInfo(userId, postgrest);
return userPlanFeatures;
};
const createTokenContext = async (authToken: string) => {
const authorization = await createTokenAuthorizationContext(
authToken,
postgrest
);
const { userPlanFeatures, purchases } = await createUserPlanContext(
authorization,
postgrest
);
return {
authorization,
domain,
deployment,
entri,
userPlanFeatures,
purchases,
trpcCache,
postgrest,
createTokenContext,
getOwnerPlanFeatures,
};
};
return {
authorization,
domain,
deployment,
entri,
userPlanFeatures,
purchases,
trpcCache,
postgrest,
createTokenContext,
getOwnerPlanFeatures,
};
};