import { TRPCError } from "@trpc/server"; 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 { defaultPlanFeatures } from "@webstudio-is/plans"; import { getPlanInfo, getAuthorizationOwnerId, } from "@webstudio-is/plans/index.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/protocol"; import { apiClient, apiClientHeader, apiClientContractVersionHeader, apiClientVersionHeader, } from "@webstudio-is/trpc-interface/api-compatibility"; export class InvalidAuthTokenError extends TRPCError { constructor() { super({ code: "UNAUTHORIZED", message: "The authorization token is invalid or no longer available", cause: { webstudioCode: "INVALID_AUTH_TOKEN" }, }); } } 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 isServiceCall = isServiceAuthorization( request.headers.get("Authorization") ); // Explicit credentials always win. In particular, an invalid supplied token // must fail during context creation instead of falling back to a potentially // more privileged browser session. const sessionData = authToken === undefined && isServiceCall === false ? isBuilder(request) ? await builderAuthenticator.isAuthenticated(request) : await authenticator.isAuthenticated(request) : undefined; return { authToken, isServiceCall, sessionData, }; }; export const isServiceAuthorization = (authorizationHeader: string | null) => { return ( authorizationHeader != null && env.TRPC_SERVER_API_TOKEN !== undefined && env.TRPC_SERVER_API_TOKEN.length > 0 && authorizationHeader === env.TRPC_SERVER_API_TOKEN ); }; const createTokenAuthorizationContext = async ( authToken: string, postgrest: AppContext["postgrest"] ) => { const projectOwnerIdByToken = await postgrest.client .from("AuthorizationToken") .select("project:Project(id, userId)") .eq("token", authToken) .maybeSingle(); if (projectOwnerIdByToken.error) { throw new Error("Failed to verify authorization token"); } if (projectOwnerIdByToken.data === null) { throw new InvalidAuthTokenError(); } const ownerId = projectOwnerIdByToken.data.project?.userId ?? null; if (ownerId === null) { throw new InvalidAuthTokenError(); } return { type: "token" as const, authToken, ownerId, }; }; const createAuthorizationContext = async ( request: Request, postgrest: AppContext["postgrest"] ): Promise => { 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 createTrpcCache = () => { const proceduresMaxAge = new Map(); 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, }; }; const createApiClientContext = (request: Request): AppContext["apiClient"] => { const client = apiClient.safeParse(request.headers.get(apiClientHeader)); if (client.success === false) { return { type: "unknown", version: undefined, contractVersion: undefined, }; } return { type: client.data, version: request.headers.get(apiClientVersionHeader) ?? undefined, contractVersion: request.headers.get(apiClientContractVersionHeader) ?? undefined, }; }; 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 => { const postgrest = createPostgrestContext(); const authorization = await createAuthorizationContext(request, postgrest); const resolvePlanInfo = async (auth: AppContext["authorization"]) => { const ownerId = getAuthorizationOwnerId(auth); if (ownerId === undefined) { return { planFeatures: defaultPlanFeatures, purchases: [] as AppContext["purchases"], }; } return ( (await getPlanInfo([ownerId], { postgrest })).get(ownerId) ?? { planFeatures: defaultPlanFeatures, purchases: [] as AppContext["purchases"], } ); }; const domain = createDomainContext(); const deployment = createDeploymentContext(getRequestOrigin(request.url)); const entri = createEntriContext(); const { planFeatures, purchases } = await resolvePlanInfo(authorization); const trpcCache = createTrpcCache(); const apiClient = createApiClientContext(request); const getOwnerPlanFeatures = async (userId: string) => { const results = await getPlanInfo([userId], { postgrest }); return results.get(userId)?.planFeatures ?? defaultPlanFeatures; }; const createTokenContext = async (authToken: string) => { const authorization = await createTokenAuthorizationContext( authToken, postgrest ); const { planFeatures, purchases } = await resolvePlanInfo(authorization); return { authorization, domain, deployment, entri, planFeatures, purchases, apiClient, trpcCache, postgrest, createTokenContext, getOwnerPlanFeatures, }; }; return { authorization, domain, deployment, entri, planFeatures, purchases, apiClient, trpcCache, postgrest, createTokenContext, getOwnerPlanFeatures, }; };