1517 lines
51 KiB
TypeScript
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 });
|
|
}
|
|
};
|
|
}
|