mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-23 03:27:27 +09:00
Invalid or revoked share links now return an actionable 401 response instead of an unexpected server error. Missing project and workspace records receive distinct responses at their lookup sites. Token, project, and workspace lookups use `.maybeSingle()` and rely on database uniqueness. Missing projects have an explicit `ProjectNotFoundError`; the loader no longer interprets `PGRST116`. The API preserves `UNAUTHORIZED` and `INVALID_AUTH_TOKEN` when authorization fails during context creation, so CLI and MCP clients can offer relinking guidance without inspecting error text. Tokens without API access carry `API_ACCESS_DISABLED`. A repository audit replaces error-message matching with typed errors, stable codes, or structured diagnostics in CLI argument/configuration handling, session conflicts, preview cleanup, import retries, domain unpublishing, asset validation, JSX/expression diagnostics, CSS validation, and publish diagnostics. `AGENTS.md` now explicitly prohibits message-based error classification and recovery. The existing build-permission preflight now advertises structured import-error support. Clients upload assets before importing into older servers that do not advertise it; newer servers keep selective retries using structured missing-file details. The internal template-validation error class remains outside public JSX bindings and input descriptions. Shared Builder `parseError` normalizes tRPC and local validation errors, so UI components only add display context instead of decoding transport payloads. Simplification removes three redundant error classes, catch-and-ignore handlers, an unused formatter, and nested loader error handling. Unpublishing an already unpublished domain succeeds after authorization. CLI configuration failures use the existing shared error handling, and plan parsing catches only JSON parsing failures. Validation: affected suites passed (271 tests across 8 suites in the latest simplification checks), affected package typechecks passed, and lint, package-boundary checks, and `git diff --check` passed. The audit replaced synthetic parser branches and private-method assertions with actual tRPC round trips, PostgREST responses, and public VM behavior. Controlled regressions confirmed that removing serialized validation details and exposing the internal JSX error class both fail the revised tests. Astra reviewed the production changes at medium effort; its compatibility finding and the GitHub JSX-binding comment are fixed, and follow-up review found no remaining substantive issues.
302 lines
8.0 KiB
TypeScript
302 lines
8.0 KiB
TypeScript
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<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 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,
|
|
};
|
|
};
|
|
|
|
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<AppContext> => {
|
|
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,
|
|
};
|
|
};
|