Files
2026-07-13 13:32:57 +08:00

1517 lines
51 KiB
TypeScript

import type { z } from "zod";
import type { ApiAuthenticationResultSuccess } from "../apiAuth.server";
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { fromZodError } from "zod-validation-error";
import { apiCors } from "~/utils/apiCors";
import { logger } from "../logger.server";
import { rbac } from "../rbac.server";
import type { RbacAbility, RbacResource } from "@trigger.dev/rbac";
import { isUserActorToken } from "@trigger.dev/rbac";
import type { PersonalAccessTokenAuthenticationResult } from "../personalAccessToken.server";
import { updateLastAccessedAtIfStale } from "../personalAccessToken.server";
import { safeJsonParse } from "~/utils/json";
import type { AuthenticatedWorkerInstance } from "~/v3/services/worker/workerGroupTokenService.server";
import { WorkerGroupTokenService } from "~/v3/services/worker/workerGroupTokenService.server";
import type { API_VERSIONS } from "~/api/versions";
import { getApiVersion } from "~/api/versions";
import { WORKER_HEADERS } from "@trigger.dev/core/v3/runEngineWorker";
import { ServiceValidationError } from "~/v3/services/common.server";
import { EngineServiceValidationError } from "@internal/run-engine";
import { tenantContext, tenantContextFromAuthEnvironment } from "~/services/tenantContext.server";
// Client aborts and service-level validation errors aren't bugs — they're
// expected at API boundaries. Log them at `warn` so they stay in stdout
// without flowing to Sentry via Logger.onError.
function logBoundaryError(
message: "Error in loader" | "Error in action",
error: unknown,
url: string
) {
const formatted =
error instanceof Error
? { name: error.name, message: error.message, stack: error.stack }
: String(error);
const isExpected =
error instanceof Error &&
(error.name === "AbortError" ||
error instanceof ServiceValidationError ||
error instanceof EngineServiceValidationError);
if (isExpected) {
logger.warn(message, { error: formatted, url });
} else {
logger.error(message, { error: formatted, url });
}
}
// Bridges the RBAC plugin (source of truth for auth + abilities) to the legacy
// ApiAuthenticationResultSuccess shape route handlers still expect. All three
// apiBuilder call sites funnel through this helper — no handler-level changes
// needed.
async function authenticateRequestForApiBuilder(
request: Request,
{ allowJWT }: { allowJWT: boolean }
): Promise<
| { ok: false; status: 401 | 403; error: string }
| { ok: true; authentication: ApiAuthenticationResultSuccess; ability: RbacAbility }
> {
const result = await rbac.authenticateBearer(request, { allowJWT });
if (!result.ok) {
// Plugin auth distinguishes 401 (who are you?) from 403 (you're not
// allowed) — e.g. a suspended account or IP block returns 403.
// Forwarding the status preserves that semantic for client retry logic.
return { ok: false, status: result.status, error: result.error };
}
// Plugins return the full AuthenticatedEnvironment shape directly — no
// follow-up DB lookup. The fallback fetches via Prisma, the cloud plugin
// via Drizzle; both produce the same slim contract type.
const authentication: ApiAuthenticationResultSuccess = {
ok: true,
apiKey: result.environment.apiKey,
type: result.subject.type === "publicJWT" ? "PUBLIC_JWT" : "PRIVATE",
environment: result.environment,
realtime: result.jwt?.realtime,
oneTimeUse: result.jwt?.oneTimeUse,
// Surface the delegation actor (PAT/UAT-exchanged JWT) so handlers can
// attribute writes to the acting user.
actor: result.jwt?.act,
};
return { ok: true, authentication, ability: result.ability };
}
type AnyZodSchema = z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>;
// A multi-resource auth check has two possible directions, and route authors
// have to pick one explicitly:
//
// - `anyResource(...)` — succeed if *any* element passes. Used when a single
// record carries multiple identifiers (a run is addressable by friendlyId /
// batch / tags / task) so a JWT scoped to *any* of them grants access.
//
// - `everyResource(...)` — succeed only if *every* element passes. Used for
// batch operations where each element is a *distinct* resource and a JWT
// scoped to one element must not authorize the others.
//
// Bare `RbacResource[]` is intentionally *not* part of `AuthResource` — the
// type system forces every multi-resource site to disambiguate. The original
// pre-RBAC apiBuilder had a separate `superScopes: [...]` whitelist for
// "broader-than-this-resource" access; post-RBAC that's expressed via the JWT
// ability's wildcard branches (`*:all` and `admin*` — see
// `internal-packages/rbac/src/ability.ts`) plus a collection-level shape
// `{ type: "<subject>" }` (no id) in the `anyResource` array so a
// `<action>:<subject>` JWT matches it. No code knob needed.
//
// Markers are Symbols so they can't collide with arbitrary RbacResource fields.
const ANY_RESOURCE_MARKER = Symbol.for("@trigger.dev/rbac.anyResource");
const EVERY_RESOURCE_MARKER = Symbol.for("@trigger.dev/rbac.everyResource");
type AnyResourceAuth = {
readonly [ANY_RESOURCE_MARKER]: true;
readonly resources: readonly RbacResource[];
};
type EveryResourceAuth = {
readonly [EVERY_RESOURCE_MARKER]: true;
readonly resources: readonly RbacResource[];
};
export function anyResource(resources: RbacResource[]): AnyResourceAuth {
return { [ANY_RESOURCE_MARKER]: true, resources };
}
export function everyResource(resources: RbacResource[]): EveryResourceAuth {
return { [EVERY_RESOURCE_MARKER]: true, resources };
}
function isAnyResource(value: unknown): value is AnyResourceAuth {
return (
typeof value === "object" &&
value !== null &&
(value as Record<symbol, unknown>)[ANY_RESOURCE_MARKER] === true
);
}
function isEveryResource(value: unknown): value is EveryResourceAuth {
return (
typeof value === "object" &&
value !== null &&
(value as Record<symbol, unknown>)[EVERY_RESOURCE_MARKER] === true
);
}
type AuthResource = RbacResource | AnyResourceAuth | EveryResourceAuth;
function checkAuth(ability: RbacAbility, action: string, resource: AuthResource): boolean {
if (isEveryResource(resource)) {
// Empty array via [].every() is vacuously true — would let any token
// pass auth. Routes building everyResource() from request bodies
// (e.g. batch trigger items) should never produce zero elements
// because body validation rejects empty arrays first, but defending
// here anyway since the auth layer should never grant on no input.
if (resource.resources.length === 0) return false;
return resource.resources.every((r) => ability.can(action, r));
}
if (isAnyResource(resource)) {
// Symmetric guard: anyResource([]) is benign for most abilities
// (.some() is false on empty), but the permissive ability would
// still grant. Treat empty as "no resource declared" → deny.
if (resource.resources.length === 0) return false;
return ability.can(action, [...resource.resources]);
}
return ability.can(action, resource);
}
type ApiKeyRouteBuilderOptions<
TParamsSchema extends AnyZodSchema | undefined = undefined,
TSearchParamsSchema extends AnyZodSchema | undefined = undefined,
THeadersSchema extends AnyZodSchema | undefined = undefined,
TResource = never,
> = {
params?: TParamsSchema;
searchParams?: TSearchParamsSchema;
headers?: THeadersSchema;
allowJWT?: boolean;
corsStrategy?: "all" | "none";
findResource: (
params: TParamsSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
? z.infer<TParamsSchema>
: undefined,
authentication: ApiAuthenticationResultSuccess,
searchParams: TSearchParamsSchema extends
| z.ZodFirstPartySchemaTypes
| z.ZodDiscriminatedUnion<any, any>
? z.infer<TSearchParamsSchema>
: undefined
) => Promise<TResource | undefined>;
shouldRetryNotFound?: boolean;
authorization?: {
action: string;
resource: (
resource: NonNullable<TResource>,
params: TParamsSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
? z.infer<TParamsSchema>
: undefined,
searchParams: TSearchParamsSchema extends
| z.ZodFirstPartySchemaTypes
| z.ZodDiscriminatedUnion<any, any>
? z.infer<TSearchParamsSchema>
: undefined,
headers: THeadersSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
? z.infer<THeadersSchema>
: undefined
) => AuthResource;
};
};
type ApiKeyHandlerFunction<
TParamsSchema extends AnyZodSchema | undefined,
TSearchParamsSchema extends AnyZodSchema | undefined,
THeadersSchema extends AnyZodSchema | undefined = undefined,
TResource = never,
> = (args: {
params: TParamsSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
? z.infer<TParamsSchema>
: undefined;
searchParams: TSearchParamsSchema extends
| z.ZodFirstPartySchemaTypes
| z.ZodDiscriminatedUnion<any, any>
? z.infer<TSearchParamsSchema>
: undefined;
headers: THeadersSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
? z.infer<THeadersSchema>
: undefined;
authentication: ApiAuthenticationResultSuccess;
request: Request;
resource: NonNullable<TResource>;
apiVersion: API_VERSIONS;
}) => Promise<Response>;
export function createLoaderApiRoute<
TParamsSchema extends AnyZodSchema | undefined = undefined,
TSearchParamsSchema extends AnyZodSchema | undefined = undefined,
THeadersSchema extends AnyZodSchema | undefined = undefined,
TResource = never,
>(
options: ApiKeyRouteBuilderOptions<TParamsSchema, TSearchParamsSchema, THeadersSchema, TResource>,
handler: ApiKeyHandlerFunction<TParamsSchema, TSearchParamsSchema, THeadersSchema, TResource>
) {
return async function loader({ request, params }: LoaderFunctionArgs) {
const {
params: paramsSchema,
searchParams: searchParamsSchema,
headers: headersSchema,
allowJWT = false,
corsStrategy = "none",
authorization,
findResource,
shouldRetryNotFound,
} = options;
if (corsStrategy !== "none" && request.method.toUpperCase() === "OPTIONS") {
return apiCors(request, json({}));
}
try {
const authResult = await authenticateRequestForApiBuilder(request, { allowJWT });
if (!authResult.ok) {
return await wrapResponse(
request,
json({ error: authResult.error }, { status: authResult.status }),
corsStrategy !== "none"
);
}
const { authentication: authenticationResult, ability } = authResult;
let parsedParams: any = undefined;
if (paramsSchema) {
const parsed = paramsSchema.safeParse(params);
if (!parsed.success) {
return await wrapResponse(
request,
json(
{ error: "Params Error", details: fromZodError(parsed.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedParams = parsed.data;
}
let parsedSearchParams: any = undefined;
if (searchParamsSchema) {
const searchParams = Object.fromEntries(new URL(request.url).searchParams);
const parsed = searchParamsSchema.safeParse(searchParams);
if (!parsed.success) {
return await wrapResponse(
request,
json(
{ error: "Query Error", details: fromZodError(parsed.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedSearchParams = parsed.data;
}
let parsedHeaders: any = undefined;
if (headersSchema) {
const rawHeaders = Object.fromEntries(request.headers);
const headers = headersSchema.safeParse(rawHeaders);
if (!headers.success) {
return await wrapResponse(
request,
json(
{ error: "Headers Error", details: fromZodError(headers.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedHeaders = headers.data;
}
// Find the resource
const resource = await findResource(parsedParams, authenticationResult, parsedSearchParams);
if (!resource) {
return await wrapResponse(
request,
json(
{ error: "Not found" },
{ status: 404, headers: { "x-should-retry": shouldRetryNotFound ? "true" : "false" } }
),
corsStrategy !== "none"
);
}
if (authorization) {
const { action, resource: authResource } = authorization;
const $authResource = authResource(
resource,
parsedParams,
parsedSearchParams,
parsedHeaders
);
if (!checkAuth(ability, action, $authResource)) {
return await wrapResponse(
request,
json(
{
error: "Unauthorized",
code: "unauthorized",
param: "access_token",
type: "authorization",
},
{ status: 403 }
),
corsStrategy !== "none"
);
}
}
const apiVersion = getApiVersion(request);
const result = await tenantContext.run(
tenantContextFromAuthEnvironment(authenticationResult.environment),
() =>
handler({
params: parsedParams,
searchParams: parsedSearchParams,
headers: parsedHeaders,
authentication: authenticationResult,
request,
resource,
apiVersion,
})
);
return await wrapResponse(request, result, corsStrategy !== "none");
} catch (error) {
try {
if (error instanceof Response) {
return await wrapResponse(request, error, corsStrategy !== "none");
}
logBoundaryError("Error in loader", error, request.url);
return await wrapResponse(
request,
json({ error: "Internal Server Error" }, { status: 500 }),
corsStrategy !== "none"
);
} catch (innerError) {
logger.error("[apiBuilder] Failed to handle error", { error, innerError });
return json({ error: "Internal Server Error" }, { status: 500 });
}
}
};
}
type PATRouteBuilderOptions<
TParamsSchema extends AnyZodSchema | undefined = undefined,
TSearchParamsSchema extends AnyZodSchema | undefined = undefined,
THeadersSchema extends AnyZodSchema | undefined = undefined,
> = {
params?: TParamsSchema;
searchParams?: TSearchParamsSchema;
headers?: THeadersSchema;
corsStrategy?: "all" | "none";
// Resolves the target org/project for the request. Fed to
// `rbac.authenticatePat` so the plugin can compute the user's role
// floor (their authority in that org) for the cap intersection.
// When omitted, the PAT runs in identity-only mode — no role floor,
// no per-route ability gating beyond what authorization (if any)
// declares against a permissive baseline. Routes added before TRI-9087
// run in this mode by default.
context?: (
params: TParamsSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
? z.infer<TParamsSchema>
: undefined,
request: Request
) =>
| { organizationId?: string; projectId?: string }
| Promise<{ organizationId?: string; projectId?: string }>;
authorization?: {
action: string;
resource: (
params: TParamsSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
? z.infer<TParamsSchema>
: undefined,
searchParams: TSearchParamsSchema extends
| z.ZodFirstPartySchemaTypes
| z.ZodDiscriminatedUnion<any, any>
? z.infer<TSearchParamsSchema>
: undefined,
headers: THeadersSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
? z.infer<THeadersSchema>
: undefined
) => AuthResource;
};
};
type PATHandlerFunction<
TParamsSchema extends AnyZodSchema | undefined,
TSearchParamsSchema extends AnyZodSchema | undefined,
THeadersSchema extends AnyZodSchema | undefined = undefined,
> = (args: {
params: TParamsSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
? z.infer<TParamsSchema>
: undefined;
searchParams: TSearchParamsSchema extends
| z.ZodFirstPartySchemaTypes
| z.ZodDiscriminatedUnion<any, any>
? z.infer<TSearchParamsSchema>
: undefined;
headers: THeadersSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
? z.infer<THeadersSchema>
: undefined;
authentication: PersonalAccessTokenAuthenticationResult;
ability: RbacAbility;
request: Request;
apiVersion: API_VERSIONS;
}) => Promise<Response>;
export function createLoaderPATApiRoute<
TParamsSchema extends AnyZodSchema | undefined = undefined,
TSearchParamsSchema extends AnyZodSchema | undefined = undefined,
THeadersSchema extends AnyZodSchema | undefined = undefined,
>(
options: PATRouteBuilderOptions<TParamsSchema, TSearchParamsSchema, THeadersSchema>,
handler: PATHandlerFunction<TParamsSchema, TSearchParamsSchema, THeadersSchema>
) {
return async function loader({ request, params }: LoaderFunctionArgs) {
const {
params: paramsSchema,
searchParams: searchParamsSchema,
headers: headersSchema,
corsStrategy = "none",
context: contextFn,
authorization,
} = options;
if (corsStrategy !== "none" && request.method.toUpperCase() === "OPTIONS") {
return apiCors(request, json({}));
}
try {
let parsedParams: any = undefined;
if (paramsSchema) {
const parsed = paramsSchema.safeParse(params);
if (!parsed.success) {
return await wrapResponse(
request,
json(
{ error: "Params Error", details: fromZodError(parsed.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedParams = parsed.data;
}
let parsedSearchParams: any = undefined;
if (searchParamsSchema) {
const searchParams = Object.fromEntries(new URL(request.url).searchParams);
const parsed = searchParamsSchema.safeParse(searchParams);
if (!parsed.success) {
return await wrapResponse(
request,
json(
{ error: "Query Error", details: fromZodError(parsed.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedSearchParams = parsed.data;
}
let parsedHeaders: any = undefined;
if (headersSchema) {
const rawHeaders = Object.fromEntries(request.headers);
const headers = headersSchema.safeParse(rawHeaders);
if (!headers.success) {
return await wrapResponse(
request,
json(
{ error: "Headers Error", details: fromZodError(headers.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedHeaders = headers.data;
}
const apiVersion = getApiVersion(request);
// Single PAT auth roundtrip. `rbac.authenticatePat` validates the
// token AND computes the cap-and-floor ability in one DB query
// (the OSS fallback does the validation only and returns a
// permissive ability; the cloud plugin returns the joined
// cap/floor result). We previously called
// `authenticateApiRequestWithPersonalAccessToken` here first as
// belt-and-braces, but that meant two PAT lookups per request
// for routes with `context`/`authorization` declared. Routes
// without those still get a working `authentication` object —
// we pass an empty ctx and the fallback validates fine.
//
// `lastAccessedAt` is plumbed through the plugin result so the
// host can decide whether to fire the update (smart-skip in
// `updateLastAccessedAtIfStale` — no DB roundtrip when the
// cached timestamp is fresher than the throttle window).
const ctx = contextFn ? await contextFn(parsedParams, request) : {};
let authenticationResult: PersonalAccessTokenAuthenticationResult;
let ability: RbacAbility;
const bearer = request.headers
.get("Authorization")
?.replace(/^Bearer /, "")
.trim();
if (bearer && isUserActorToken(bearer)) {
// A user-actor token validates + computes the cap-and-floor ability
// in one call, same shape as a PAT.
const uatAuth = await rbac.authenticateUserActor(request, ctx);
if (!uatAuth.ok) {
return await wrapResponse(
request,
json({ error: uatAuth.error }, { status: uatAuth.status }),
corsStrategy !== "none"
);
}
authenticationResult = { userId: uatAuth.userId };
ability = uatAuth.ability;
} else {
// PAT: validate + compute the cap-and-floor ability in one query.
const patAuth = await rbac.authenticatePat(request, ctx);
if (!patAuth.ok) {
return await wrapResponse(
request,
json({ error: patAuth.error }, { status: patAuth.status }),
corsStrategy !== "none"
);
}
authenticationResult = { userId: patAuth.userId };
ability = patAuth.ability;
// Throttled in the helper (no DB write when the cached value is fresh).
await updateLastAccessedAtIfStale(patAuth.tokenId, patAuth.lastAccessedAt);
}
if (authorization) {
const $resource = authorization.resource(parsedParams, parsedSearchParams, parsedHeaders);
if (!checkAuth(ability, authorization.action, $resource)) {
return await wrapResponse(
request,
json(
{
error: "Unauthorized",
code: "unauthorized",
param: "access_token",
type: "authorization",
},
{ status: 403 }
),
corsStrategy !== "none"
);
}
}
// PAT auth carries `userId` but no environment — enrich the scope
// the Express middleware established with the authenticated user so
// Sentry events from this handler get user-level attribution.
tenantContext.enrich({ userId: authenticationResult.userId });
const result = await handler({
params: parsedParams,
searchParams: parsedSearchParams,
headers: parsedHeaders,
authentication: authenticationResult,
ability,
request,
apiVersion,
});
return await wrapResponse(request, result, corsStrategy !== "none");
} catch (error) {
try {
if (error instanceof Response) {
return await wrapResponse(request, error, corsStrategy !== "none");
}
return await wrapResponse(
request,
json({ error: "Internal Server Error" }, { status: 500 }),
corsStrategy !== "none"
);
} catch (innerError) {
logger.error("[apiBuilder] Failed to handle error", { error, innerError });
return json({ error: "Internal Server Error" }, { status: 500 });
}
}
};
}
type ApiKeyActionRouteBuilderOptions<
TParamsSchema extends AnyZodSchema | undefined = undefined,
TSearchParamsSchema extends AnyZodSchema | undefined = undefined,
THeadersSchema extends AnyZodSchema | undefined = undefined,
TBodySchema extends AnyZodSchema | undefined = undefined,
TResource = never,
> = {
params?: TParamsSchema;
searchParams?: TSearchParamsSchema;
headers?: THeadersSchema;
allowJWT?: boolean;
corsStrategy?: "all" | "none";
method?: "POST" | "PUT" | "DELETE" | "PATCH";
findResource?: (
params: TParamsSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
? z.infer<TParamsSchema>
: undefined,
authentication: ApiAuthenticationResultSuccess,
searchParams: TSearchParamsSchema extends
| z.ZodFirstPartySchemaTypes
| z.ZodDiscriminatedUnion<any, any>
? z.infer<TSearchParamsSchema>
: undefined
) => Promise<TResource | undefined>;
authorization?: {
action: string;
resource: (
params: TParamsSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
? z.infer<TParamsSchema>
: undefined,
searchParams: TSearchParamsSchema extends
| z.ZodFirstPartySchemaTypes
| z.ZodDiscriminatedUnion<any, any>
? z.infer<TSearchParamsSchema>
: undefined,
headers: THeadersSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
? z.infer<THeadersSchema>
: undefined,
body: TBodySchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
? z.infer<TBodySchema>
: undefined,
// The resolved resource from `findResource`. `undefined` when the route
// doesn't declare `findResource`. Routes that need to expand the auth
// scope to alternate identifiers of the same row (e.g. friendlyId +
// externalId for sessions) read it here so a JWT minted for either form
// authorizes both URL forms.
resource: TResource | undefined
) => AuthResource;
};
maxContentLength?: number;
body?: TBodySchema;
};
type ApiKeyActionHandlerFunction<
TParamsSchema extends AnyZodSchema | undefined,
TSearchParamsSchema extends AnyZodSchema | undefined,
THeadersSchema extends AnyZodSchema | undefined = undefined,
TBodySchema extends AnyZodSchema | undefined = undefined,
TResource = never,
> = (args: {
params: TParamsSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
? z.infer<TParamsSchema>
: undefined;
searchParams: TSearchParamsSchema extends
| z.ZodFirstPartySchemaTypes
| z.ZodDiscriminatedUnion<any, any>
? z.infer<TSearchParamsSchema>
: undefined;
headers: THeadersSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
? z.infer<THeadersSchema>
: undefined;
body: TBodySchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
? z.infer<TBodySchema>
: undefined;
authentication: ApiAuthenticationResultSuccess;
request: Request;
resource?: TResource;
}) => Promise<Response>;
export function createActionApiRoute<
TParamsSchema extends AnyZodSchema | undefined = undefined,
TSearchParamsSchema extends AnyZodSchema | undefined = undefined,
THeadersSchema extends AnyZodSchema | undefined = undefined,
TBodySchema extends AnyZodSchema | undefined = undefined,
TResource = never,
>(
options: ApiKeyActionRouteBuilderOptions<
TParamsSchema,
TSearchParamsSchema,
THeadersSchema,
TBodySchema,
TResource
>,
handler: ApiKeyActionHandlerFunction<
TParamsSchema,
TSearchParamsSchema,
THeadersSchema,
TBodySchema,
TResource
>
) {
const {
params: paramsSchema,
searchParams: searchParamsSchema,
headers: headersSchema,
body: bodySchema,
allowJWT = false,
corsStrategy = "none",
authorization,
maxContentLength,
} = options;
async function loader({ request, params }: LoaderFunctionArgs) {
if (corsStrategy !== "none" && request.method.toUpperCase() === "OPTIONS") {
return apiCors(request, json({}));
}
return new Response(null, { status: 405 });
}
async function action({ request, params }: ActionFunctionArgs) {
if (options.method) {
if (request.method.toUpperCase() !== options.method) {
return await wrapResponse(
request,
json(
{ error: "Method not allowed" },
{ status: 405, headers: { Allow: options.method } }
),
corsStrategy !== "none"
);
}
}
try {
const authResult = await authenticateRequestForApiBuilder(request, { allowJWT });
if (!authResult.ok) {
return await wrapResponse(
request,
json({ error: authResult.error }, { status: authResult.status }),
corsStrategy !== "none"
);
}
const { authentication: authenticationResult, ability } = authResult;
if (maxContentLength) {
const contentLength = request.headers.get("content-length");
if (!contentLength || parseInt(contentLength) > maxContentLength) {
return await wrapResponse(
request,
json({ error: "Request body too large" }, { status: 413 }),
corsStrategy !== "none"
);
}
}
let parsedParams: any = undefined;
if (paramsSchema) {
const parsed = paramsSchema.safeParse(params);
if (!parsed.success) {
return await wrapResponse(
request,
json(
{ error: "Params Error", details: fromZodError(parsed.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedParams = parsed.data;
}
let parsedSearchParams: any = undefined;
if (searchParamsSchema) {
const searchParams = Object.fromEntries(new URL(request.url).searchParams);
const parsed = searchParamsSchema.safeParse(searchParams);
if (!parsed.success) {
return await wrapResponse(
request,
json(
{ error: "Query Error", details: fromZodError(parsed.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedSearchParams = parsed.data;
}
let parsedHeaders: any = undefined;
if (headersSchema) {
const rawHeaders = Object.fromEntries(request.headers);
const headers = headersSchema.safeParse(rawHeaders);
if (!headers.success) {
return await wrapResponse(
request,
json(
{ error: "Headers Error", details: fromZodError(headers.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedHeaders = headers.data;
}
let parsedBody: any = undefined;
if (bodySchema) {
const rawBody = await request.text();
if (rawBody.length === 0) {
return await wrapResponse(
request,
json({ error: "Request body is empty" }, { status: 400 }),
corsStrategy !== "none"
);
}
const rawParsedJson = safeJsonParse(rawBody);
if (!rawParsedJson) {
return await wrapResponse(
request,
json({ error: "Invalid JSON" }, { status: 400 }),
corsStrategy !== "none"
);
}
const body = bodySchema.safeParse(rawParsedJson);
if (!body.success) {
return await wrapResponse(
request,
json({ error: fromZodError(body.error).toString() }, { status: 400 }),
corsStrategy !== "none"
);
}
parsedBody = body.data;
}
// Resolve the resource before authorization so the auth scope check
// can expand to alternate identifiers of the same row (e.g. a Session
// is addressable by both `friendlyId` and `externalId` and a JWT minted
// for either form should authorize both URL forms). Mirrors the
// ordering in `createLoaderApiRoute`.
const resource = options.findResource
? await options.findResource(parsedParams, authenticationResult, parsedSearchParams)
: undefined;
// Run authorization first — but with the resolved resource available
// as the 5th arg so the auth scope check can expand to alternate
// identifiers of the same row (e.g. a Session is addressable by both
// `friendlyId` and `externalId`). Resource-null is checked AFTER auth
// so:
// - underscoped JWT + missing resource → 403 (no info leak)
// - underscoped JWT + existing resource → 403 (existing behavior)
// - PRIVATE key + missing resource → auth passes → 404 (correct)
// - PRIVATE key + existing resource → auth passes → handler runs
if (authorization) {
const { action, resource: authResource } = authorization;
const $resource = authResource(
parsedParams,
parsedSearchParams,
parsedHeaders,
parsedBody,
resource
);
if (!checkAuth(ability, action, $resource)) {
return await wrapResponse(
request,
json(
{
error: "Unauthorized",
code: "unauthorized",
param: "access_token",
type: "authorization",
},
{ status: 403 }
),
corsStrategy !== "none"
);
}
}
if (options.findResource && !resource) {
return await wrapResponse(
request,
json({ error: "Resource not found" }, { status: 404 }),
corsStrategy !== "none"
);
}
const result = await tenantContext.run(
tenantContextFromAuthEnvironment(authenticationResult.environment),
() =>
handler({
params: parsedParams,
searchParams: parsedSearchParams,
headers: parsedHeaders,
body: parsedBody,
authentication: authenticationResult,
request,
resource,
})
);
return await wrapResponse(request, result, corsStrategy !== "none");
} catch (error) {
try {
if (error instanceof Response) {
return await wrapResponse(request, error, corsStrategy !== "none");
}
logBoundaryError("Error in action", error, request.url);
return await wrapResponse(
request,
json({ error: "Internal Server Error" }, { status: 500 }),
corsStrategy !== "none"
);
} catch (innerError) {
logger.error("[apiBuilder] Failed to handle error", { error, innerError });
return json({ error: "Internal Server Error" }, { status: 500 });
}
}
}
return { loader, action };
}
// ---------------------------------------------------------------------------
// Multi-method action route builder
// ---------------------------------------------------------------------------
type HttpMethod = "POST" | "PUT" | "PATCH" | "DELETE";
type InferZod<T> = T extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
? z.infer<T>
: undefined;
type MethodHandlerArgs<TParamsSchema, TSearchParamsSchema, THeadersSchema, TBodySchema> = {
params: InferZod<TParamsSchema>;
searchParams: InferZod<TSearchParamsSchema>;
headers: InferZod<THeadersSchema>;
body: InferZod<TBodySchema>;
authentication: ApiAuthenticationResultSuccess;
request: Request;
};
type MethodConfig<TParamsSchema, TSearchParamsSchema, THeadersSchema> = {
body?: AnyZodSchema;
handler: (
args: MethodHandlerArgs<TParamsSchema, TSearchParamsSchema, THeadersSchema, any>
) => Promise<Response>;
};
type MultiMethodApiRouteOptions<
TParamsSchema extends AnyZodSchema | undefined = undefined,
TSearchParamsSchema extends AnyZodSchema | undefined = undefined,
THeadersSchema extends AnyZodSchema | undefined = undefined,
> = {
params?: TParamsSchema;
searchParams?: TSearchParamsSchema;
headers?: THeadersSchema;
allowJWT?: boolean;
corsStrategy?: "all" | "none";
authorization?: {
action: string;
resource: (params: InferZod<TParamsSchema>) => AuthResource;
};
maxContentLength?: number;
methods: Partial<
Record<HttpMethod, MethodConfig<TParamsSchema, TSearchParamsSchema, THeadersSchema>>
>;
};
/**
* Creates a Remix route that dispatches to different handlers based on HTTP method.
* Shares authentication, param parsing, CORS, and authorization across all methods.
* Each method can define its own body schema.
*/
export function createMultiMethodApiRoute<
TParamsSchema extends AnyZodSchema | undefined = undefined,
TSearchParamsSchema extends AnyZodSchema | undefined = undefined,
THeadersSchema extends AnyZodSchema | undefined = undefined,
>(options: MultiMethodApiRouteOptions<TParamsSchema, TSearchParamsSchema, THeadersSchema>) {
const {
params: paramsSchema,
searchParams: searchParamsSchema,
headers: headersSchema,
allowJWT = false,
corsStrategy = "none",
authorization,
maxContentLength,
methods,
} = options;
const allowedMethods = Object.keys(methods).join(", ");
async function loader({ request }: LoaderFunctionArgs) {
if (corsStrategy !== "none" && request.method.toUpperCase() === "OPTIONS") {
return apiCors(request, json({}));
}
return new Response(null, { status: 405 });
}
async function action({ request, params }: ActionFunctionArgs) {
const method = request.method.toUpperCase() as HttpMethod;
const methodConfig = methods[method];
if (!methodConfig) {
return await wrapResponse(
request,
json({ error: "Method not allowed" }, { status: 405, headers: { Allow: allowedMethods } }),
corsStrategy !== "none"
);
}
try {
// Authenticate
const authResult = await authenticateRequestForApiBuilder(request, { allowJWT });
if (!authResult.ok) {
return await wrapResponse(
request,
json({ error: authResult.error }, { status: authResult.status }),
corsStrategy !== "none"
);
}
const { authentication: authenticationResult, ability } = authResult;
if (maxContentLength) {
const contentLength = request.headers.get("content-length");
if (!contentLength || parseInt(contentLength) > maxContentLength) {
return await wrapResponse(
request,
json({ error: "Request body too large" }, { status: 413 }),
corsStrategy !== "none"
);
}
}
// Parse params
let parsedParams: any = undefined;
if (paramsSchema) {
const parsed = paramsSchema.safeParse(params);
if (!parsed.success) {
return await wrapResponse(
request,
json(
{ error: "Params Error", details: fromZodError(parsed.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedParams = parsed.data;
}
// Parse search params
let parsedSearchParams: any = undefined;
if (searchParamsSchema) {
const searchParams = Object.fromEntries(new URL(request.url).searchParams);
const parsed = searchParamsSchema.safeParse(searchParams);
if (!parsed.success) {
return await wrapResponse(
request,
json(
{ error: "Query Error", details: fromZodError(parsed.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedSearchParams = parsed.data;
}
// Parse headers
let parsedHeaders: any = undefined;
if (headersSchema) {
const rawHeaders = Object.fromEntries(request.headers);
const headers = headersSchema.safeParse(rawHeaders);
if (!headers.success) {
return await wrapResponse(
request,
json(
{ error: "Headers Error", details: fromZodError(headers.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedHeaders = headers.data;
}
// Authorize
if (authorization) {
const { action, resource } = authorization;
const $resource = resource(parsedParams);
if (!checkAuth(ability, action, $resource)) {
return await wrapResponse(
request,
json(
{
error: "Unauthorized",
code: "unauthorized",
param: "access_token",
type: "authorization",
},
{ status: 403 }
),
corsStrategy !== "none"
);
}
}
// Parse body (per-method schema)
let parsedBody: any = undefined;
if (methodConfig.body) {
const rawBody = await request.text();
if (rawBody.length === 0) {
return await wrapResponse(
request,
json({ error: "Request body is empty" }, { status: 400 }),
corsStrategy !== "none"
);
}
const rawParsedJson = safeJsonParse(rawBody);
if (!rawParsedJson) {
return await wrapResponse(
request,
json({ error: "Invalid JSON" }, { status: 400 }),
corsStrategy !== "none"
);
}
const body = methodConfig.body.safeParse(rawParsedJson);
if (!body.success) {
return await wrapResponse(
request,
json({ error: fromZodError(body.error).toString() }, { status: 400 }),
corsStrategy !== "none"
);
}
parsedBody = body.data;
}
// Dispatch to method handler
const result = await tenantContext.run(
tenantContextFromAuthEnvironment(authenticationResult.environment),
() =>
methodConfig.handler({
params: parsedParams,
searchParams: parsedSearchParams,
headers: parsedHeaders,
body: parsedBody,
authentication: authenticationResult,
request,
})
);
return await wrapResponse(request, result, corsStrategy !== "none");
} catch (error) {
try {
if (error instanceof Response) {
return await wrapResponse(request, error, corsStrategy !== "none");
}
logBoundaryError("Error in action", error, request.url);
return await wrapResponse(
request,
json({ error: "Internal Server Error" }, { status: 500 }),
corsStrategy !== "none"
);
} catch (innerError) {
logger.error("[apiBuilder] Failed to handle error", { error, innerError });
return json({ error: "Internal Server Error" }, { status: 500 });
}
}
}
return { loader, action };
}
async function wrapResponse(
request: Request,
response: Response,
useCors: boolean
): Promise<Response> {
return useCors
? await apiCors(request, response, {
exposedHeaders: ["x-trigger-jwt", "x-trigger-jwt-claims"],
})
: response;
}
type WorkerLoaderRouteBuilderOptions<
TParamsSchema extends AnyZodSchema | undefined = undefined,
TSearchParamsSchema extends AnyZodSchema | undefined = undefined,
THeadersSchema extends AnyZodSchema | undefined = undefined,
> = {
params?: TParamsSchema;
searchParams?: TSearchParamsSchema;
headers?: THeadersSchema;
};
type WorkerLoaderHandlerFunction<
TParamsSchema extends AnyZodSchema | undefined,
TSearchParamsSchema extends AnyZodSchema | undefined,
THeadersSchema extends AnyZodSchema | undefined = undefined,
> = (args: {
params: TParamsSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
? z.infer<TParamsSchema>
: undefined;
searchParams: TSearchParamsSchema extends
| z.ZodFirstPartySchemaTypes
| z.ZodDiscriminatedUnion<any, any>
? z.infer<TSearchParamsSchema>
: undefined;
authenticatedWorker: AuthenticatedWorkerInstance;
request: Request;
headers: THeadersSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
? z.infer<THeadersSchema>
: undefined;
runnerId?: string;
}) => Promise<Response>;
export function createLoaderWorkerApiRoute<
TParamsSchema extends AnyZodSchema | undefined = undefined,
TSearchParamsSchema extends AnyZodSchema | undefined = undefined,
THeadersSchema extends AnyZodSchema | undefined = undefined,
>(
options: WorkerLoaderRouteBuilderOptions<TParamsSchema, TSearchParamsSchema, THeadersSchema>,
handler: WorkerLoaderHandlerFunction<TParamsSchema, TSearchParamsSchema, THeadersSchema>
) {
return async function loader({ request, params }: LoaderFunctionArgs) {
const {
params: paramsSchema,
searchParams: searchParamsSchema,
headers: headersSchema,
} = options;
try {
const service = new WorkerGroupTokenService();
const authenticationResult = await service.authenticate(request);
if (!authenticationResult) {
return json({ error: "Invalid or missing worker token" }, { status: 401 });
}
let parsedParams: any = undefined;
if (paramsSchema) {
const parsed = paramsSchema.safeParse(params);
if (!parsed.success) {
return json(
{ error: "Params Error", details: fromZodError(parsed.error).details },
{ status: 400 }
);
}
parsedParams = parsed.data;
}
let parsedSearchParams: any = undefined;
if (searchParamsSchema) {
const searchParams = Object.fromEntries(new URL(request.url).searchParams);
const parsed = searchParamsSchema.safeParse(searchParams);
if (!parsed.success) {
return json(
{ error: "Query Error", details: fromZodError(parsed.error).details },
{ status: 400 }
);
}
parsedSearchParams = parsed.data;
}
let parsedHeaders: any = undefined;
if (headersSchema) {
const rawHeaders = Object.fromEntries(request.headers);
const headers = headersSchema.safeParse(rawHeaders);
if (!headers.success) {
return json(
{ error: "Headers Error", details: fromZodError(headers.error).details },
{ status: 400 }
);
}
parsedHeaders = headers.data;
}
const runnerId = request.headers.get(WORKER_HEADERS.RUNNER_ID) ?? undefined;
const result = await handler({
params: parsedParams,
searchParams: parsedSearchParams,
authenticatedWorker: authenticationResult,
request,
headers: parsedHeaders,
runnerId,
});
return result;
} catch (error) {
console.error("Error in API route:", error);
if (error instanceof Response) {
return error;
}
logBoundaryError("Error in loader", error, request.url);
return json({ error: "Internal Server Error" }, { status: 500 });
}
};
}
type WorkerActionRouteBuilderOptions<
TParamsSchema extends AnyZodSchema | undefined = undefined,
TSearchParamsSchema extends AnyZodSchema | undefined = undefined,
THeadersSchema extends AnyZodSchema | undefined = undefined,
TBodySchema extends AnyZodSchema | undefined = undefined,
> = {
params?: TParamsSchema;
searchParams?: TSearchParamsSchema;
headers?: THeadersSchema;
body?: TBodySchema;
method?: "POST" | "PUT" | "DELETE" | "PATCH";
};
type WorkerActionHandlerFunction<
TParamsSchema extends AnyZodSchema | undefined,
TSearchParamsSchema extends AnyZodSchema | undefined,
THeadersSchema extends AnyZodSchema | undefined = undefined,
TBodySchema extends AnyZodSchema | undefined = undefined,
> = (args: {
params: TParamsSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
? z.infer<TParamsSchema>
: undefined;
searchParams: TSearchParamsSchema extends
| z.ZodFirstPartySchemaTypes
| z.ZodDiscriminatedUnion<any, any>
? z.infer<TSearchParamsSchema>
: undefined;
authenticatedWorker: AuthenticatedWorkerInstance;
request: Request;
headers: THeadersSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
? z.infer<THeadersSchema>
: undefined;
body: TBodySchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
? z.infer<TBodySchema>
: undefined;
runnerId?: string;
}) => Promise<Response>;
export function createActionWorkerApiRoute<
TParamsSchema extends AnyZodSchema | undefined = undefined,
TSearchParamsSchema extends AnyZodSchema | undefined = undefined,
THeadersSchema extends AnyZodSchema | undefined = undefined,
TBodySchema extends AnyZodSchema | undefined = undefined,
>(
options: WorkerActionRouteBuilderOptions<
TParamsSchema,
TSearchParamsSchema,
THeadersSchema,
TBodySchema
>,
handler: WorkerActionHandlerFunction<
TParamsSchema,
TSearchParamsSchema,
THeadersSchema,
TBodySchema
>
) {
return async function action({ request, params }: ActionFunctionArgs) {
if (options.method) {
if (request.method.toUpperCase() !== options.method) {
return json(
{ error: "Method not allowed" },
{ status: 405, headers: { Allow: options.method } }
);
}
}
const {
params: paramsSchema,
searchParams: searchParamsSchema,
body: bodySchema,
headers: headersSchema,
} = options;
try {
const service = new WorkerGroupTokenService();
const authenticationResult = await service.authenticate(request);
if (!authenticationResult) {
return json({ error: "Invalid or missing worker token" }, { status: 401 });
}
let parsedParams: any = undefined;
if (paramsSchema) {
const parsed = paramsSchema.safeParse(params);
if (!parsed.success) {
return json(
{ error: "Params Error", details: fromZodError(parsed.error).details },
{ status: 400 }
);
}
parsedParams = parsed.data;
}
let parsedSearchParams: any = undefined;
if (searchParamsSchema) {
const searchParams = Object.fromEntries(new URL(request.url).searchParams);
const parsed = searchParamsSchema.safeParse(searchParams);
if (!parsed.success) {
return json(
{ error: "Query Error", details: fromZodError(parsed.error).details },
{ status: 400 }
);
}
parsedSearchParams = parsed.data;
}
let parsedHeaders: any = undefined;
if (headersSchema) {
const rawHeaders = Object.fromEntries(request.headers);
const headers = headersSchema.safeParse(rawHeaders);
if (!headers.success) {
return json(
{ error: "Headers Error", details: fromZodError(headers.error).details },
{ status: 400 }
);
}
parsedHeaders = headers.data;
}
let parsedBody: any = undefined;
if (bodySchema) {
const body = await request.clone().json();
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return json(
{ error: "Body Error", details: fromZodError(parsed.error).details },
{ status: 400 }
);
}
parsedBody = parsed.data;
}
const runnerId = request.headers.get(WORKER_HEADERS.RUNNER_ID) ?? undefined;
const result = await handler({
params: parsedParams,
searchParams: parsedSearchParams,
authenticatedWorker: authenticationResult,
request,
body: parsedBody,
headers: parsedHeaders,
runnerId,
});
return result;
} catch (error) {
if (error instanceof Response) {
return error;
}
if (error instanceof EngineServiceValidationError) {
return json({ error: error.message }, { status: error.status ?? 422 });
}
if (error instanceof ServiceValidationError) {
return json({ error: error.message }, { status: error.status ?? 422 });
}
logBoundaryError("Error in action", error, request.url);
return json({ error: "Internal Server Error" }, { status: 500 });
}
};
}