mirror of
https://gh-proxy.org/https://github.com/klinsmaya/new-api.git
synced 2026-11-08 01:17:09 +08:00
Reuse the channel base host and plugin allowedHosts for redirected query credentials. Keep anonymous redirects working, prevent credentials from returning after an untrusted hop, and restrict body replay to trusted hosts. Preserve SSRF and redirect limits, reject HTTPS downgrades, and redact signed URLs from redirect-policy errors. Document the existing allowedHosts contract. Validation: go test ./relay/channel/task/jsplugin -count=1 Passed against clean 6347fb0aa plus this patch, without an overlay. Covers single/batch polling, 301/302/303/307/308, host and port boundaries, multi-hop credentials, SSRF, downgrade rejection, and real HTTP receivers. Related OWASP ASVS v5.0.0 controls: 14.2.3, 1.3.6, 12.3.1, 16.2.5. https://github.com/OWASP/ASVS/tree/v5.0.0/5.0/en
99 lines
11 KiB
TypeScript
99 lines
11 KiB
TypeScript
export type JSONValue = null | boolean | number | string | readonly JSONValue[] | {readonly [key: string]: JSONValue};
|
|
/** json-clone@1 and submit-sse-delta@1 are accepted for earlier hosts; new plugins need neither. */
|
|
export type HostCapability = "json-clone@1" | "submit-sse-delta@1" | "json-order@1" | "duration-auto@1";
|
|
/** Kinds of upstream a driver can address: the vendor API itself, or another New API gateway with the same plugin installed. */
|
|
export type UpstreamKind = "vendor" | "new_api";
|
|
/** Host-injected on every driver hook context. With "new_api" the driver uses its own native-route prefix and the host already set Bearer credentials. */
|
|
export interface UpstreamContext {kind: UpstreamKind}
|
|
export type MutableJSON<T> = T extends readonly (infer Item)[] ? MutableJSON<Item>[] : T extends object ? {-readonly [Key in keyof T]: MutableJSON<T[Key]>} : T;
|
|
export interface HostUtils {
|
|
hasCapability(name: string): boolean;
|
|
/** Independent native JS containers; at most 1 MiB encoded JSON, depth 32 and 32,768 nodes. */
|
|
json: {clone<T extends JSONValue>(value: T): MutableJSON<T>};
|
|
unixNow(): number;
|
|
uuid(): string;
|
|
hmacSHA256(message: string, secret: string): string;
|
|
jwtSignHS256(claims: Record<string, JSONValue>, secret: string): string;
|
|
base64(value: string): string;
|
|
base64URL(value: string): string;
|
|
base64URLDecode(value: string): string;
|
|
volcSignV4(request: {method: string; url: string; headers?: Record<string, string>; body?: string; accessKey: string; secretKey: string; region?: string; service?: string; timestamp?: number}): Record<string, string>;
|
|
/** Synchronous read-only request from driver hooks to the channel Base URL host or allowedHosts. At most 4 per hook call, 15 s each, 30 s in total, 1 MiB per body; redirects are returned, not followed. */
|
|
fetch(request: FetchRequest): FetchResponse;
|
|
}
|
|
export interface FetchRequest {url: string; method?: "GET" | "HEAD"; headers?: Record<string, string>; responseType?: "json" | "bytes"}
|
|
/** headers holds the first value of each header under its canonical name; body is parsed JSON when the response parses, otherwise text, and "" for HEAD; with responseType "bytes" it is an ArrayBuffer of the response bytes. */
|
|
export interface FetchResponse {status: number; headers: Record<string, string>; body: JSONValue | string | ArrayBuffer}
|
|
declare global {const utils: HostUtils;}
|
|
export type FileReference = Readonly<{ref: string; field: string; filename: string; mimeType: string; size: number}>;
|
|
export type FilePlaceholder = Readonly<{__fileRef: string; encoding: "base64" | "dataUrl"; mimeType?: string; maxBytes?: number}>;
|
|
export type DecodedBody =
|
|
| Readonly<{kind: "json"; value: JSONValue}>
|
|
| Readonly<{kind: "form"; fields: Readonly<Record<string, readonly string[]>>}>
|
|
| Readonly<{kind: "multipart"; fields: Readonly<Record<string, readonly string[]>>; files: readonly FileReference[]}>
|
|
| Readonly<{kind: "none"}>;
|
|
|
|
export interface NativeDecodeContext {method: string; path: string; params: Readonly<Record<string, string>>; query: Readonly<Record<string, readonly string[]>>; body: DecodedBody}
|
|
export interface ProtocolDecodeContext extends NativeDecodeContext {protocol: ProtocolName; operation: string; model: string; upstreamModel?: string; stream: boolean}
|
|
export type SubmitIntent = {kind: "submit"; model: string; action?: string; requestBody?: unknown; originTaskIds?: readonly string[]};
|
|
export type QueryIntent = {kind: "query"; taskIds: readonly string[]};
|
|
export type TaskIntent = SubmitIntent | QueryIntent;
|
|
export interface NativeRoute {method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; path: string; type: "submit" | "query" | "dynamic"; action?: string; taskIdParam?: string; decode?: string; render: string; models?: readonly string[]; retainResult?: boolean}
|
|
export type ProtocolName = "openai_responses" | "openai_video" | "openai_image";
|
|
export type ResponsesMode = "stream" | "sync" | "background";
|
|
export type ProtocolClaim =
|
|
| "openai_video"
|
|
| "openai_image"
|
|
| {name: "openai_responses"; supports: readonly ResponsesMode[]; models?: readonly string[]}
|
|
| {name: "openai_video"; models?: readonly string[]}
|
|
| {name: "openai_image"; models?: readonly string[]};
|
|
/** One entry of the OpenAI ImageResponse `data` array rendered by protocols.openai_image.render. */
|
|
export type ImageResponseEntry = {url?: string; b64_json?: string; revised_prompt?: string};
|
|
export type LocalizedText = string | ({ en: string } & Record<string, string>);
|
|
export type UsageFieldSchema =
|
|
| {type: "number"; unit: "count"; unitLabel?: LocalizedText; description?: LocalizedText}
|
|
| {type: "number"; unit: "second" | "token" | "credit"; unitLabel?: never; description?: LocalizedText}
|
|
| {type: "boolean"; unitLabel?: never; description?: LocalizedText}
|
|
| {enum: readonly string[]; unitLabel?: never; description?: LocalizedText; enumLabels?: Readonly<Record<string, LocalizedText>>};
|
|
export type UsageExample = {label: string; facts: Readonly<Record<string, string | number | boolean>>};
|
|
export type UsageProfile = {models: readonly string[]; schema: Readonly<Record<string, UsageFieldSchema>>; examples?: readonly UsageExample[]};
|
|
export interface Meta {requiredCapabilities?: readonly HostCapability[]; submitResponseTypes?: readonly ("json" | "sse")[]; sortPriority?: number; website?: string; apiVersion: 1; key: string; name: string; icon?: string; description?: LocalizedText; version: string; author: {name: string; url?: string}; baseUrl?: string; channelTypes?: readonly number[]; models: readonly string[]; fetchMode: "per_task" | "batch"; allowedHosts?: readonly string[]; upstreams?: readonly UpstreamKind[]; routes?: readonly NativeRoute[]; protocols?: readonly ProtocolClaim[]; usageSchema?: Readonly<Record<string, UsageFieldSchema>>; usageExamples?: readonly UsageExample[]; usageProfiles?: readonly UsageProfile[]; auth?: "none" | "api_key" | "vertex_oauth" | {type: "none" | "api_key" | "oauth2_jwt"}}
|
|
export interface TaskView {task_id: string; status: string; progress?: string; fail_reason?: string; created_at?: number; updated_at?: number; data?: unknown; properties?: Record<string, unknown>}
|
|
export interface DriverContext {requestBody: unknown; requestHeaders: Readonly<Record<string, string>>; action: string; model: string; upstreamModel: string; baseUrl: string; apiKey?: string; authHeader: string; upstream: UpstreamContext; files: readonly FileReference[]; publicTaskId: string; originTasks?: readonly {taskId: string; upstreamTaskId: string; action: string; status: string; data: unknown}[]}
|
|
export interface TaskQueryContext {taskId: string; publicTaskId: string; action: string; model: string; upstreamModel: string; baseUrl: string; apiKey?: string; authHeader: string; auth?: unknown; upstream: UpstreamContext; data: unknown; state: unknown}
|
|
export interface BatchQueryContext {baseUrl: string; apiKey?: string; authHeader: string; auth?: unknown; upstream: UpstreamContext; tasks: readonly TaskQueryContext[]}
|
|
export type HookHTTPResponse = {readonly status: number; readonly headers: Readonly<Record<string, string>>}
|
|
export interface RequestDescriptor {responseType?: "json" | "sse"; url: string; method?: string; headers?: Record<string, string>; /** JSON body may contain FilePlaceholder objects at any depth; the host replaces each with a Base64 or data-URL string. */ body?: unknown; credentialless?: boolean; action?: string; model?: string; rewriteModel?: string; bodyType?: "json" | "multipart"; parts?: readonly {name: string; value?: unknown; fileRef?: string; filename?: string}[]}
|
|
export interface UpstreamResponse {statusCode: number; headers: Readonly<Record<string, readonly string[]>>; body: unknown}
|
|
export interface NormalizedTaskResult {taskId?: string; status: "NOT_START" | "SUBMITTED" | "QUEUED" | "IN_PROGRESS" | "SUCCESS" | "FAILURE" | "UNKNOWN"; progress?: string; reason?: string; url?: string; remoteUrl?: string; completionTokens?: number; totalTokens?: number}
|
|
export interface TaskArtifact {key: string; type: "video" | "audio" | "image" | "file"; mimeType?: string}
|
|
export declare const meta: Meta;
|
|
export declare const native: Record<string, ((ctx: NativeDecodeContext) => TaskIntent) | ((ctx: NativeDecodeContext, task: TaskView | readonly TaskView[]) => unknown)> & {error?: (ctx: NativeDecodeContext, error: {code: string; message: string; httpStatus: number; retryable: boolean}) => unknown};
|
|
export declare const protocols: {
|
|
openai_responses?: {decodeRequest(ctx: ProtocolDecodeContext): SubmitIntent; renderEvents?(ctx: unknown, task: TaskView, previousState: unknown): unknown; renderFinal?(ctx: unknown, task: TaskView): unknown};
|
|
openai_video?: {decodeRequest(ctx: ProtocolDecodeContext): SubmitIntent; render(ctx: unknown, task: TaskView): unknown};
|
|
/** render returns the OpenAI ImageResponse; the host adds `created` when absent and resolves response_format b64_json. */
|
|
openai_image?: {decodeRequest(ctx: ProtocolDecodeContext): SubmitIntent; render(ctx: unknown, task: TaskView): {created?: number; data: readonly ImageResponseEntry[]} & Record<string, unknown>};
|
|
};
|
|
export declare function buildSubmitRequest(ctx: DriverContext): RequestDescriptor;
|
|
export interface SubmitEvent {event: string; id: string; data: string}
|
|
export type JSONPath = readonly (string | number)[];
|
|
export type JSONChange =
|
|
| {op: "set" | "append"; path: JSONPath; value: JSONValue}
|
|
| {op: "appendText"; path: JSONPath; value: string};
|
|
export interface SubmitEventDeltaResult {changes: readonly JSONChange[]; state: JSONValue; done: boolean}
|
|
/** With submit-sse-delta@1, state is small control data; changes build the separate response body. */
|
|
export declare function parseSubmitEventDelta(ctx: DriverContext, event: SubmitEvent, previousState: JSONValue | null): SubmitEventDeltaResult;
|
|
export declare function parseSubmitEvent(ctx: DriverContext, event: SubmitEvent, previousState: JSONValue | null): {state: JSONValue; done: boolean};
|
|
export declare function parseSubmitResponse(ctx: DriverContext, response: UpstreamResponse): {taskId: string; taskData?: unknown; immediate?: NormalizedTaskResult; state?: unknown};
|
|
/** Query redirects retain credentials only within the channel host and meta.allowedHosts; SSRF checks and HTTPS downgrade protection still apply. */
|
|
export declare function buildQueryRequest(ctx: TaskQueryContext): RequestDescriptor;
|
|
export declare function buildBatchQueryRequest(ctx: BatchQueryContext, tasks: readonly TaskQueryContext[]): RequestDescriptor;
|
|
export declare function parseTaskResult(ctx: TaskQueryContext, body: unknown, response: HookHTTPResponse): NormalizedTaskResult;
|
|
export declare function parseBatchResult(ctx: BatchQueryContext, body: unknown, response: HookHTTPResponse): readonly (NormalizedTaskResult & {taskId: string; data?: unknown; state?: unknown})[];
|
|
export declare function extractUsage(ctx: DriverContext & {usagePurpose?: "facts" | "billing_ratios"}): Readonly<Record<string, string | number | boolean>> | null;
|
|
export declare function extractUsageOnSubmit(ctx: DriverContext, taskData: unknown): Readonly<Record<string, string | number | boolean>> | null;
|
|
export declare function extractUsageOnComplete(task: TaskQueryContext, result: NormalizedTaskResult, data: unknown): Readonly<Record<string, string | number | boolean>> | null;
|
|
export declare function listArtifacts(task: {taskId: string; status: string; action: string; data: unknown; producerVersion: string}): readonly TaskArtifact[];
|
|
export declare function buildContentRequest(ctx: DriverContext & {artifactKey: string; data: unknown; state?: unknown; upstreamTaskId: string; clientRequest: {method: "GET" | "HEAD"; headers: Readonly<Record<string, string>>}}): RequestDescriptor;
|