feat!: release v2 (#6545)

Co-authored-by: Artur <145776561+arturict@users.noreply.github.com>
Co-authored-by: arturict <arturict@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
Co-authored-by: Danura30082 <danura30082+github@gmail.com>
Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com>
Co-authored-by: ajnart <thomas@ajnart.fr>
Co-authored-by: ajnart <thomas@ajnart.dev>
Co-authored-by: chenjun <chenjun@foxmail.com>
Co-authored-by: Wikibear <wikibear@users.noreply.github.com>
Co-authored-by: Bergasha <bergasha@gmail.com>
Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com>
Co-authored-by: Lucas Araujo <lucas.lra@gmail.com>
Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com>
Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social>
Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com>
Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com>
Co-authored-by: thrawnx <tight.bulb9384@fastmail.com>
Co-authored-by: Chris <58918656+nordada@users.noreply.github.com>
Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com>
Co-authored-by: Yanis <naamaneyanis@gmail.com>
Co-authored-by: Wikibear <me@sej.de>
Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
This commit is contained in:
3286 files changed
+284386 -139176

No files matched your search

+104
View File
@@ -0,0 +1,104 @@
import type { CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "./request-types";
import { CustomWidgetDomainError } from "./errors";
import { assertCustomWidgetPathScope, resolveSameOriginTarget } from "./network-policy";
type HeaderSetter = Pick<Headers, "set">;
export const AUTH_HANDLERS: Record<
string,
(headers: HeaderSetter, url: URL, apiKey: string, headerName?: string) => void
> = {
bearer: (headers, _url, apiKey) => {
headers.set("Authorization", `Bearer ${apiKey}`);
},
apiKeyHeader: (headers, _url, apiKey, headerName) => {
headers.set(headerName ?? "X-API-Key", apiKey);
},
apiKeyQuery: (_headers, url, apiKey, headerName) => {
url.searchParams.set(headerName ?? "api_key", apiKey);
},
};
export function buildSecretMap(secrets: Array<{ kind: string; value: string }>): Record<string, string> {
const map: Record<string, string> = {};
for (const secret of secrets) {
map[secret.kind] = secret.value;
}
return map;
}
export function applyAuth(
headers: HeaderSetter,
url: URL,
authType: string,
secrets: Array<{ kind: string; value: string }>,
headerName?: string | null,
): void {
const secretMap = buildSecretMap(secrets);
if (authType === "basic" && secretMap.username && secretMap.password) {
const credentials = Buffer.from(`${secretMap.username}:${secretMap.password}`).toString("base64");
headers.set("Authorization", `Basic ${credentials}`);
return;
}
const handler = AUTH_HANDLERS[authType];
if (handler && secretMap.apiKey) {
handler(headers, url, secretMap.apiKey, headerName ?? undefined);
}
}
export function applyBodyAuth(body: string | undefined, auth: CustomWidgetHttpRequest["auth"]): string | undefined {
if (!auth?.body) return body;
if (!body) throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Authenticated JSON body is required" });
let value: unknown;
try {
value = JSON.parse(body) as unknown;
} catch {
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Authenticated JSON body is invalid" });
}
if (value === null || typeof value !== "object" || Array.isArray(value))
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Authenticated JSON body must be an object" });
const record = value as Record<string, unknown>;
if (auth.body.type === "jsonField") {
record[auth.body.name] = auth.body.value;
} else {
const current = record[auth.body.name];
if (current !== undefined && !Array.isArray(current))
throw new CustomWidgetDomainError({
code: "BAD_REQUEST",
message: "Authenticated JSON parameter must be an array",
});
record[auth.body.name] = [auth.body.value, ...(current ?? [])];
}
return JSON.stringify(record);
}
export async function performAuthenticatedRequest(
input: CustomWidgetHttpRequest,
signal: AbortSignal,
perform: (input: CustomWidgetHttpRequest, signal: AbortSignal) => Promise<CustomWidgetHttpResponse>,
): Promise<CustomWidgetHttpResponse> {
const target = resolveSameOriginTarget(input.baseUrl, input.targetUrl);
if (input.pathPrefix !== undefined) assertCustomWidgetPathScope(target, input.pathPrefix);
if (input.resolveConnectionAsync) {
const connection = await withinDeadline(input.resolveConnectionAsync, signal);
resolveSameOriginTarget(connection.baseUrl, target);
input = { ...input, ...connection, targetUrl: target };
}
return perform(input, signal);
}
async function withinDeadline<T>(callback: () => Promise<T>, signal: AbortSignal): Promise<T> {
signal.throwIfAborted();
let abort: (() => void) | undefined;
const aborted = new Promise<never>((_resolve, reject) => {
abort = () => reject(signal.reason);
signal.addEventListener("abort", abort, { once: true });
});
try {
return await Promise.race([callback(), aborted]);
} finally {
if (abort) signal.removeEventListener("abort", abort);
}
}
@@ -0,0 +1,28 @@
export type CustomWidgetDomainErrorCode =
| "BAD_GATEWAY"
| "BAD_REQUEST"
| "CONFLICT"
| "FORBIDDEN"
| "INTERNAL_SERVER_ERROR"
| "NOT_FOUND"
| "PAYLOAD_TOO_LARGE"
| "TOO_MANY_REQUESTS";
export class CustomWidgetDomainError extends Error {
public readonly code: CustomWidgetDomainErrorCode;
public readonly reason?: "timeout";
public readonly retryAfterMs?: number;
public constructor(input: {
code: CustomWidgetDomainErrorCode;
message: string;
reason?: "timeout";
retryAfterMs?: number;
}) {
super(input.message);
this.name = "CustomWidgetDomainError";
this.code = input.code;
this.reason = input.reason;
this.retryAfterMs = input.retryAfterMs;
}
}
@@ -0,0 +1,45 @@
export { CustomWidgetDomainError } from "./errors";
export type { CustomWidgetDomainErrorCode } from "./errors";
export {
assertJsonBudget,
assertSafeStaticHeaders,
classifyAddress,
executeCustomWidgetRequest,
invalidateCustomWidgetResponseCache,
MAX_REQUEST_DURATION_MS,
MAX_REQUEST_BODY_BYTES,
MAX_RESPONSE_BODY_BYTES,
MAX_RESPONSE_JSON_DEPTH,
MAX_RESPONSE_JSON_NODES,
resolveAndValidateHost,
resolveSameOriginTarget,
validateCustomWidgetUrl,
} from "./request-executor";
export type { CustomWidgetAuthConfig, CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "./request-executor";
export {
CUSTOM_WIDGET_DEFINITION_CONCURRENCY_LIMIT,
CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS,
CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT,
CustomWidgetRequestLimiter,
} from "./request-limits";
export type { RequestLimitInput, RequestLimiterOptions, RequestLimitStore } from "./request-limits";
export { CustomWidgetPreviewSessionService } from "./preview-sessions";
export type {
CreatePreviewSessionInput,
CustomWidgetPreviewJournalEntry,
CustomWidgetPreviewSession,
PreviewSessionServiceOptions,
PreviewSessionStore,
} from "./preview-sessions";
export {
hashRuntimeParams,
renderBoundValue,
renderRequestBody,
renderRequestTarget,
resolveCustomWidgetRequestValues,
} from "./request-manifest";
export type { CustomJsxRuntimeParams } from "./request-manifest";
export { redactResponseSecrets } from "./response";
export { assertCustomWidgetPathScope } from "./network-policy";
@@ -0,0 +1,255 @@
import { lookup } from "node:dns/promises";
import { BlockList, isIP } from "node:net";
import type { LookupFunction } from "node:net";
import type { ConnectionOptions } from "node:tls";
import { Agent } from "undici";
import type { CustomJsxNetworkScope } from "../core";
import { CustomWidgetDomainError } from "./errors";
import { MAX_RESPONSE_BODY_BYTES } from "./response";
const RESERVED_HEADERS = new Set([
"authorization",
"connection",
"content-length",
"cookie",
"expect",
"forwarded",
"host",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
"via",
"x-forwarded-for",
"x-forwarded-host",
"x-forwarded-port",
"x-forwarded-proto",
]);
const blocked = new BlockList();
for (const [address, prefix] of [
["0.0.0.0", 8],
["100.64.0.0", 10],
["169.254.0.0", 16],
["192.0.0.0", 24],
["192.0.2.0", 24],
["198.18.0.0", 15],
["198.51.100.0", 24],
["203.0.113.0", 24],
["224.0.0.0", 4],
["240.0.0.0", 4],
] as const)
blocked.addSubnet(address, prefix, "ipv4");
blocked.addAddress("::", "ipv6");
for (const [address, prefix] of [
["64:ff9b::", 96],
["100::", 64],
["2001:db8::", 32],
["fe80::", 10],
["ff00::", 8],
] as const) {
blocked.addSubnet(address, prefix, "ipv6");
}
blocked.addAddress("fd00:ec2::254", "ipv6");
const privateAddresses = new BlockList();
privateAddresses.addSubnet("10.0.0.0", 8, "ipv4");
privateAddresses.addSubnet("172.16.0.0", 12, "ipv4");
privateAddresses.addSubnet("192.168.0.0", 16, "ipv4");
privateAddresses.addSubnet("fc00::", 7, "ipv6");
const loopbackAddresses = new BlockList();
loopbackAddresses.addSubnet("127.0.0.0", 8, "ipv4");
loopbackAddresses.addAddress("::1", "ipv6");
type AddressFamily = 4 | 6;
export type ResolvedAddress = { address: string; family: AddressFamily };
export type HostResolver = (hostname: string) => Promise<ResolvedAddress[]>;
export interface ResolveHostOptions {
signal?: AbortSignal;
resolver?: HostResolver;
}
type AddressClass = "public" | "private" | "loopback" | "blocked";
const normalizeHostname = (value: string) =>
value.startsWith("[") && value.endsWith("]") ? value.slice(1, -1) : value;
const familyName = (family: AddressFamily) => (family === 4 ? "ipv4" : "ipv6");
export function classifyAddress(address: string): AddressClass {
const normalized = normalizeHostname(address).toLowerCase();
const family = isIP(normalized) as AddressFamily | 0;
if (!family || normalized.includes("::ffff:") || blocked.check(normalized, familyName(family))) return "blocked";
if (loopbackAddresses.check(normalized, familyName(family))) return "loopback";
if (privateAddresses.check(normalized, familyName(family))) return "private";
return "public";
}
export async function resolveAndValidateHost(
hostname: string,
scope: CustomJsxNetworkScope,
options: ResolveHostOptions = {},
): Promise<ResolvedAddress[]> {
const normalized = normalizeHostname(hostname);
const family = isIP(normalized) as AddressFamily | 0;
throwIfAborted(options.signal);
const addresses = family
? [{ address: normalized, family }]
: await abortable(
options.resolver?.(normalized) ??
(lookup(normalized, { all: true, verbatim: true }) as Promise<ResolvedAddress[]>),
options.signal,
);
throwIfAborted(options.signal);
if (!addresses.length)
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Target host did not resolve" });
for (const address of addresses) {
const classification = classifyAddress(address.address);
const allowed =
classification === "public" ||
(classification === "private" && scope !== "public") ||
(classification === "loopback" && scope === "loopback");
if (!allowed)
throw new CustomWidgetDomainError({
code: "FORBIDDEN",
message: `Target address is not allowed by the ${scope} network scope`,
});
}
return addresses;
}
function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) throw signal.reason ?? createAbortError();
}
function abortable<T>(operation: Promise<T>, signal?: AbortSignal): Promise<T> {
if (!signal) return operation;
if (signal.aborted) return Promise.reject(signal.reason ?? createAbortError());
return new Promise<T>((resolve, reject) => {
const onAbort = () => {
signal.removeEventListener("abort", onAbort);
reject(signal.reason ?? createAbortError());
};
signal.addEventListener("abort", onAbort, { once: true });
operation.then(
(value) => {
signal.removeEventListener("abort", onAbort);
resolve(value);
},
(error: unknown) => {
signal.removeEventListener("abort", onAbort);
reject(error);
},
);
});
}
function createAbortError(): Error {
const error = new Error("Operation aborted");
error.name = "AbortError";
return error;
}
export function validateCustomWidgetUrl(value: string | URL): URL {
let url: URL;
try {
url = new URL(value);
} catch {
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Invalid custom widget URL" });
}
if (url.protocol !== "http:" && url.protocol !== "https:")
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Only HTTP and HTTPS URLs are allowed" });
if (url.username || url.password)
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "URL credentials are not allowed" });
if (url.hash) throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "URL fragments are not allowed" });
return url;
}
export function resolveSameOriginTarget(baseValue: string, targetValue?: string | URL): URL {
const base = validateCustomWidgetUrl(baseValue);
const target = validateCustomWidgetUrl(targetValue ?? base);
if (target.origin !== base.origin)
throw new CustomWidgetDomainError({
code: "FORBIDDEN",
message: "Named requests must stay on the widget's origin",
});
return target;
}
export function assertSafeStaticHeaders(headers: Record<string, string> | undefined): void {
for (const name of Object.keys(headers ?? {})) {
const value = name.trim().toLowerCase();
if (
RESERVED_HEADERS.has(value) ||
value.startsWith("proxy-") ||
value.startsWith("sec-") ||
value.startsWith("x-forwarded-")
) {
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: `Header '${name}' is reserved` });
}
}
}
export function createPinnedAgent(
addresses: ResolvedAddress[],
timeoutMs: number,
tls?: Pick<ConnectionOptions, "ca" | "checkServerIdentity">,
) {
const customLookup: LookupFunction = (_hostname, options, callback) => {
const family = options.family === 4 || options.family === 6 ? options.family : undefined;
const candidates = family ? addresses.filter((entry) => entry.family === family) : addresses;
const selected = candidates[0];
if (!selected) {
const error = new Error("No validated address for the requested family") as NodeJS.ErrnoException;
error.code = "ENOTFOUND";
callback(error, "", 0);
} else if (options.all) callback(null, candidates);
else callback(null, selected.address, selected.family);
};
return new Agent({
connect: { ...tls, lookup: customLookup },
connectTimeout: timeoutMs,
headersTimeout: timeoutMs,
bodyTimeout: timeoutMs,
maxResponseSize: MAX_RESPONSE_BODY_BYTES,
});
}
export function assertCustomWidgetPathScope(url: URL, pathPrefix: string) {
const decodedPrefix = decodePath(pathPrefix);
let prefixEnd = decodedPrefix.length;
while (prefixEnd > 0 && decodedPrefix.charAt(prefixEnd - 1) === "/") {
prefixEnd -= 1;
}
const prefix = decodedPrefix.slice(0, prefixEnd);
const path = decodePath(url.pathname);
if (
path.includes("\\") ||
path.split("/").some((segment) => segment === "." || segment === "..") ||
(prefix && path !== prefix && !path.startsWith(`${prefix}/`))
) {
throw new CustomWidgetDomainError({
code: "FORBIDDEN",
message: "Request must remain within the selected integration URL",
});
}
}
function decodePath(value: string) {
let path = value;
for (let pass = 0; pass < 4; pass += 1) {
let decoded: string;
try {
decoded = decodeURIComponent(path);
} catch {
throw new CustomWidgetDomainError({ code: "FORBIDDEN", message: "Request path contains invalid encoding" });
}
if (decoded === path) return path;
path = decoded;
}
if (/%[0-9a-f]{2}/iu.test(path))
throw new CustomWidgetDomainError({ code: "FORBIDDEN", message: "Request path is excessively encoded" });
return path;
}
@@ -0,0 +1,39 @@
import { z } from "zod/v4";
export const previewJournalEntrySchema = z.object({
id: z.string(),
requestId: z.string(),
kind: z.enum(["query", "action"]),
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
path: z.string(),
status: z.number().nullable(),
durationMs: z.number().nonnegative(),
simulated: z.boolean(),
sessionRevision: z.number().int().nonnegative(),
timestamp: z.number(),
});
export type CustomWidgetPreviewJournalEntry = z.infer<typeof previewJournalEntrySchema>;
export const isVerifiedPreviewEvidence = (entry: CustomWidgetPreviewJournalEntry) => {
if (entry.kind === "query") return entry.status !== null && entry.status >= 200 && entry.status < 300;
return entry.simulated || (entry.status !== null && entry.status >= 200 && entry.status < 300);
};
export const getPreviewEvidenceKey = (entry: CustomWidgetPreviewJournalEntry) =>
`${entry.sessionRevision}:${entry.kind}:${entry.requestId}`;
export const parsePreviewJournalEntries = (entries: readonly unknown[]) =>
entries.flatMap((entry) => {
const parsed = previewJournalEntrySchema.safeParse(parsePreviewStoredValue(entry));
return parsed.success ? [parsed.data] : [];
});
export const parsePreviewStoredValue = (value: unknown): unknown => {
if (typeof value !== "string") return value;
try {
return JSON.parse(value) as unknown;
} catch {
return null;
}
};
@@ -0,0 +1,45 @@
import { hasSameCustomWidgetSourceAuthentication } from "../core";
import { customWidgetPreviewDefinitionSchema } from "../core";
import type { CustomJsxRequest, CustomWidgetOptions, CustomWidgetSource } from "../core";
import { CustomWidgetDomainError } from "./errors";
interface PreviewTemplateContext {
name: string;
description?: string;
iconUrl?: string;
sources: Record<string, CustomWidgetSource>;
requests: Record<string, CustomJsxRequest>;
optionDefinitions: CustomWidgetOptions;
template: string;
}
export function validatePreviewTemplateRevision(current: PreviewTemplateContext, template: string) {
const parsed = customWidgetPreviewDefinitionSchema.safeParse({
$schema: "homarr-custom-widget-v2",
name: current.name,
description: current.description,
iconUrl: current.iconUrl,
sources: current.sources,
requests: current.requests,
options: current.optionDefinitions,
template,
});
if (!parsed.success) {
const issue = parsed.error.issues[0];
const path = issue?.path.length ? `${issue.path.join(".")}: ` : "";
throw new CustomWidgetDomainError({
code: "BAD_REQUEST",
message: `Revised preview template is invalid: ${path}${issue?.message ?? "Unknown validation error"}`,
});
}
if (parsed.data.template === current.template) {
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Revised preview template is unchanged" });
}
return parsed.data.template;
}
export function canConfigurePreviewSource(current: CustomWidgetSource, source: CustomWidgetSource) {
if (current.type === "integration" && source.type === "integration")
return current.integrationKind === source.integrationKind;
return hasSameCustomWidgetSourceAuthentication(current, source);
}
@@ -0,0 +1,303 @@
import { canConfigurePreviewSource } from "./preview-session-template";
import { z } from "zod/v4";
import {
customWidgetOptionsSchema,
customWidgetRequestsSchema,
customWidgetSecretKinds,
customWidgetSourcesSchema,
} from "../core";
import type { CustomJsxRequest, CustomWidgetOptions, CustomWidgetSource } from "../core";
import { CustomWidgetDomainError } from "./errors";
import {
getPreviewEvidenceKey,
isVerifiedPreviewEvidence,
parsePreviewJournalEntries,
parsePreviewStoredValue,
previewJournalEntrySchema,
} from "./preview-session-evidence";
import type { CustomWidgetPreviewJournalEntry } from "./preview-session-evidence";
import { validatePreviewTemplateRevision } from "./preview-session-template";
const SESSION_TTL_MS = 10 * 60_000;
const MAX_JOURNAL_ENTRIES = 50;
const MAX_UPDATE_ATTEMPTS = 8;
const encryptedSecretSchema = z.object({
sourceId: z.string(),
kind: z.enum(customWidgetSecretKinds),
value: z.string(),
});
const sessionSchema = z.object({
id: z.string(),
userId: z.string(),
revision: z.number().int().nonnegative(),
expiresAt: z.number(),
sources: customWidgetSourcesSchema,
secrets: z.array(encryptedSecretSchema),
requests: customWidgetRequestsSchema,
name: z.string(),
description: z.string().optional(),
iconUrl: z.string().optional(),
template: z.string(),
optionDefinitions: customWidgetOptionsSchema,
options: z.record(z.string(), z.unknown()),
definitionId: z.string().optional(),
definitionStateFingerprint: z.string().optional(),
liveActions: z.boolean(),
});
export type CustomWidgetPreviewSession = z.infer<typeof sessionSchema>;
export type { CustomWidgetPreviewJournalEntry } from "./preview-session-evidence";
export interface CreatePreviewSessionInput {
userId: string;
sources: Record<string, CustomWidgetSource>;
secrets: Array<{ sourceId: string; kind: (typeof customWidgetSecretKinds)[number]; value: string }>;
requests: Record<string, CustomJsxRequest>;
name: string;
description?: string;
iconUrl?: string;
template: string;
optionDefinitions: CustomWidgetOptions;
options: Record<string, unknown>;
definitionId?: string;
definitionStateFingerprint?: string;
}
export interface PreviewSessionStore {
saveSession(id: string, value: unknown, ttlMs: number): Promise<void>;
compareAndSwapSession(id: string, expectedRevision: number, value: unknown, ttlMs: number): Promise<boolean>;
getSession(id: string): Promise<unknown>;
deleteSession(id: string): Promise<void>;
appendJournal(id: string, value: unknown, maxEntries: number, ttlMs: number): Promise<void>;
getJournal(id: string, maxEntries: number): Promise<unknown[]>;
saveEvidence(id: string, key: string, value: unknown, ttlMs: number): Promise<void>;
getEvidence(id: string): Promise<unknown[]>;
}
export interface PreviewSessionServiceOptions {
createId(): string;
encrypt(value: string): string;
decrypt(value: string): string;
now?: () => number;
store?: PreviewSessionStore;
}
export class CustomWidgetPreviewSessionService {
private readonly sessions = new Map<string, CustomWidgetPreviewSession>();
private readonly journals = new Map<string, CustomWidgetPreviewJournalEntry[]>();
private readonly evidence = new Map<string, Map<string, CustomWidgetPreviewJournalEntry>>();
private readonly now: () => number;
public constructor(private readonly options: PreviewSessionServiceOptions) {
this.now = options.now ?? Date.now;
}
public async create(input: CreatePreviewSessionInput) {
const sourceIds = new Set(Object.keys(input.sources));
if (input.secrets.some((secret) => !sourceIds.has(secret.sourceId))) {
throw new CustomWidgetDomainError({
code: "BAD_REQUEST",
message: "A preview secret references an unknown source",
});
}
const session: CustomWidgetPreviewSession = {
id: this.options.createId(),
userId: input.userId,
revision: 0,
expiresAt: this.now() + SESSION_TTL_MS,
sources: input.sources,
secrets: input.secrets.map((secret) => ({ ...secret, value: this.options.encrypt(secret.value) })),
requests: input.requests,
name: input.name,
description: input.description,
iconUrl: input.iconUrl,
template: input.template,
optionDefinitions: input.optionDefinitions,
options: input.options,
definitionId: input.definitionId,
definitionStateFingerprint: input.definitionStateFingerprint,
liveActions: false,
};
await this.save(session);
return { id: session.id, revision: session.revision, expiresAt: session.expiresAt, liveActions: false as const };
}
public async get(id: string, userId: string): Promise<CustomWidgetPreviewSession> {
const candidate = this.options.store ? await this.options.store.getSession(id) : this.sessions.get(id);
const parsed = sessionSchema.safeParse(parsePreviewStoredValue(candidate));
if (!parsed.success || parsed.data.expiresAt <= this.now()) {
if (this.options.store) await this.options.store.deleteSession(id);
else {
this.sessions.delete(id);
this.journals.delete(id);
this.evidence.delete(id);
}
throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview session expired or was not found" });
}
if (parsed.data.userId !== userId) {
throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview session expired or was not found" });
}
return parsed.data;
}
public async setLiveActions(id: string, userId: string, enabled: boolean) {
const session = await this.update(id, userId, (current) => ({ ...current, liveActions: enabled }));
return { id, expiresAt: session.expiresAt, liveActions: enabled };
}
public async reviseTemplate(id: string, userId: string, template: string, expectedRevision?: number) {
const session = await this.update(
id,
userId,
(current) => ({ ...current, template: validatePreviewTemplateRevision(current, template) }),
expectedRevision,
);
return {
id: session.id,
revision: session.revision,
expiresAt: session.expiresAt,
liveActions: session.liveActions,
evidenceReset: true as const,
};
}
public async appendJournal(
session: CustomWidgetPreviewSession,
input: Omit<CustomWidgetPreviewJournalEntry, "id" | "timestamp">,
): Promise<void> {
const value = previewJournalEntrySchema.parse({ ...input, id: this.options.createId(), timestamp: this.now() });
const ttlMs = Math.max(1, session.expiresAt - this.now());
if (this.options.store) {
const writes = [this.options.store.appendJournal(session.id, value, MAX_JOURNAL_ENTRIES, ttlMs)];
if (isVerifiedPreviewEvidence(value)) {
writes.push(this.options.store.saveEvidence(session.id, getPreviewEvidenceKey(value), value, ttlMs));
}
await Promise.all(writes);
return;
}
this.journals.set(session.id, [value, ...(this.journals.get(session.id) ?? [])].slice(0, MAX_JOURNAL_ENTRIES));
if (!isVerifiedPreviewEvidence(value)) return;
const evidence = this.evidence.get(session.id) ?? new Map<string, CustomWidgetPreviewJournalEntry>();
evidence.set(getPreviewEvidenceKey(value), value);
this.evidence.set(session.id, evidence);
}
public async getJournal(id: string, userId: string): Promise<CustomWidgetPreviewJournalEntry[]> {
await this.get(id, userId);
const entries = this.options.store
? await this.options.store.getJournal(id, MAX_JOURNAL_ENTRIES)
: (this.journals.get(id) ?? []);
return parsePreviewJournalEntries(entries);
}
public async getEvidence(id: string, userId: string): Promise<CustomWidgetPreviewJournalEntry[]> {
await this.get(id, userId);
const entries = this.options.store
? await this.options.store.getEvidence(id)
: [...(this.evidence.get(id)?.values() ?? [])];
return parsePreviewJournalEntries(entries);
}
public getSecrets(session: CustomWidgetPreviewSession, sourceId: string) {
return session.secrets
.filter((secret) => secret.sourceId === sourceId)
.map((secret) => ({ kind: secret.kind, value: this.options.decrypt(secret.value) }));
}
public async setSecrets(
id: string,
userId: string,
secrets: Array<{ sourceId: string; kind: (typeof customWidgetSecretKinds)[number]; value: string }>,
) {
const next = await this.update(id, userId, (session) => {
const sourceIds = new Set(Object.keys(session.sources));
if (secrets.some((secret) => !sourceIds.has(secret.sourceId))) {
throw new CustomWidgetDomainError({
code: "BAD_REQUEST",
message: "A preview secret references an unknown source",
});
}
const replacements = new Set(secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
return {
...session,
secrets: [
...session.secrets.filter((secret) => !replacements.has(`${secret.sourceId}:${secret.kind}`)),
...secrets.map((secret) => ({ ...secret, value: this.options.encrypt(secret.value) })),
],
};
});
return { id, expiresAt: next.expiresAt };
}
public async configureSource(
id: string,
userId: string,
sourceId: string,
source: CustomWidgetSource,
secrets: Array<{ sourceId: string; kind: (typeof customWidgetSecretKinds)[number]; value: string }>,
) {
const next = await this.update(id, userId, (session) => {
const currentSource = session.sources[sourceId];
if (!currentSource) {
throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview source was not found" });
}
if (!canConfigurePreviewSource(currentSource, source)) {
throw new CustomWidgetDomainError({
code: "BAD_REQUEST",
message: "Preview source authentication changed; create a new configuration request",
});
}
const replacements = new Set(secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
return {
...session,
sources: {
...session.sources,
[sourceId]: source,
},
secrets: [
...session.secrets.filter((secret) => !replacements.has(`${secret.sourceId}:${secret.kind}`)),
...secrets.map((secret) => ({ ...secret, value: this.options.encrypt(secret.value) })),
],
};
});
return { id, expiresAt: next.expiresAt };
}
private async update(
id: string,
userId: string,
mutate: (session: CustomWidgetPreviewSession) => CustomWidgetPreviewSession,
expectedRevision?: number,
) {
for (let attempt = 0; attempt < MAX_UPDATE_ATTEMPTS; attempt += 1) {
const current = await this.get(id, userId);
if (expectedRevision !== undefined && current.revision !== expectedRevision) {
throw new CustomWidgetDomainError({
code: "CONFLICT",
message: `Preview session revision changed from ${expectedRevision} to ${current.revision}`,
});
}
const next = { ...mutate(current), revision: current.revision + 1 };
const ttlMs = Math.max(0, next.expiresAt - this.now());
if (!ttlMs) throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview session expired" });
if (this.options.store) {
if (await this.options.store.compareAndSwapSession(id, current.revision, next, ttlMs)) return next;
} else if (this.sessions.get(id)?.revision === current.revision) {
this.sessions.set(id, next);
return next;
}
}
throw new CustomWidgetDomainError({ code: "CONFLICT", message: "Preview session changed too many times; retry" });
}
private async save(session: CustomWidgetPreviewSession): Promise<void> {
const ttlMs = Math.max(0, session.expiresAt - this.now());
if (!ttlMs) throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview session expired" });
if (this.options.store) await this.options.store.saveSession(session.id, session, ttlMs);
else this.sessions.set(session.id, session);
}
}
@@ -0,0 +1,62 @@
import type { createPinnedAgent } from "./network-policy";
export const DISPATCHER_CLOSE_GRACE_MS = 1_000;
type CustomWidgetDispatcher = ReturnType<typeof createPinnedAgent>;
export async function closeDispatcher(
dispatcher: CustomWidgetDispatcher,
deadlineSignal: AbortSignal,
): Promise<boolean> {
const cleanupController = new AbortController();
const timeout = setTimeout(() => cleanupController.abort(), DISPATCHER_CLOSE_GRACE_MS);
try {
await abortable(dispatcher.close(), AbortSignal.any([deadlineSignal, cleanupController.signal]));
return true;
} catch (error) {
detachDispatcherDestroy(dispatcher);
if (cleanupController.signal.aborted && !deadlineSignal.aborted) return false;
throw error;
} finally {
clearTimeout(timeout);
}
}
function detachDispatcherDestroy(dispatcher: CustomWidgetDispatcher): void {
try {
void dispatcher.destroy().catch(() => undefined);
} catch {
// Cleanup is best effort after its bounded grace period.
}
}
function abortable<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
if (signal.aborted) {
void operation.catch(() => undefined);
return Promise.reject(signal.reason ?? createAbortError());
}
return new Promise<T>((resolve, reject) => {
const onAbort = () => {
signal.removeEventListener("abort", onAbort);
reject(signal.reason ?? createAbortError());
};
signal.addEventListener("abort", onAbort, { once: true });
operation.then(
(value) => {
signal.removeEventListener("abort", onAbort);
resolve(value);
},
(error: unknown) => {
signal.removeEventListener("abort", onAbort);
reject(error);
},
);
});
}
function createAbortError(): Error {
const error = new Error("Operation aborted");
error.name = "AbortError";
return error;
}
@@ -0,0 +1,298 @@
import { Buffer } from "node:buffer";
import { STATUS_CODES } from "node:http";
import { Headers, Response } from "undici";
import type { CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "./request-types";
export type { CustomWidgetAuthConfig, CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "./request-types";
import { applyAuth, applyBodyAuth, performAuthenticatedRequest } from "./auth";
import { CustomWidgetDomainError } from "./errors";
import {
assertSafeStaticHeaders,
assertCustomWidgetPathScope,
createPinnedAgent,
resolveAndValidateHost,
resolveSameOriginTarget,
validateCustomWidgetUrl,
} from "./network-policy";
import { closeDispatcher } from "./request-dispatcher-lifecycle";
import { decodeResponseBody, parseResponseBody, redactResponseSecrets } from "./response";
export {
assertSafeStaticHeaders,
classifyAddress,
resolveAndValidateHost,
resolveSameOriginTarget,
validateCustomWidgetUrl,
} from "./network-policy";
export {
assertJsonBudget,
MAX_RESPONSE_BODY_BYTES,
MAX_RESPONSE_JSON_DEPTH,
MAX_RESPONSE_JSON_NODES,
} from "./response";
export const MAX_REQUEST_BODY_BYTES = 10 * 1024;
const REQUEST_TIMEOUT_MS = 10_000;
const MAX_QUERY_REDIRECTS = 3;
const MAX_RESPONSE_CACHE_ENTRIES = 1_000;
export const MAX_REQUEST_DURATION_MS = 45_000;
const TIMEOUT_ERROR_CODES = new Set(["UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]);
const RESPONSE_TOO_LARGE_ERROR_CODE = "UND_ERR_RES_EXCEEDED_MAX_SIZE";
const cache = new Map<string, { expiresAt: number; response: CustomWidgetHttpResponse }>();
const inFlight = new Map<string, Promise<CustomWidgetHttpResponse>>();
let cacheEpoch = 0;
type RequestHopResult =
| { kind: "response"; response: CustomWidgetHttpResponse }
| { kind: "redirect"; location: string | null; statusCode: number };
async function performRequest(input: CustomWidgetHttpRequest): Promise<CustomWidgetHttpResponse> {
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
Math.min(input.timeoutMs ?? MAX_REQUEST_DURATION_MS, MAX_REQUEST_DURATION_MS),
);
try {
assertRequest(input);
return await performAuthenticatedRequest(input, controller.signal, performRequestWithinDeadline);
} catch (error) {
if (controller.signal.aborted) {
throw new CustomWidgetDomainError({
code: "BAD_GATEWAY",
message: "External request exceeded the total time limit",
reason: "timeout",
});
}
if (error instanceof CustomWidgetDomainError) throw error;
input.logError?.({
origin: URL.canParse(input.baseUrl) ? new URL(input.baseUrl).origin : "invalid",
method: input.method,
errorName: "TransportError",
});
throw new CustomWidgetDomainError({
code: "BAD_GATEWAY",
message: "External request failed",
});
} finally {
clearTimeout(timeout);
}
}
async function performRequestWithinDeadline(
input: CustomWidgetHttpRequest,
deadlineSignal: AbortSignal,
): Promise<CustomWidgetHttpResponse> {
assertRequest(input);
const baseUrl = validateCustomWidgetUrl(input.baseUrl);
let currentUrl = resolveSameOriginTarget(input.baseUrl, input.targetUrl);
let currentMethod = input.method;
let currentBody = input.body;
const authenticated =
input.auth !== undefined &&
input.auth.type !== "none" &&
(input.auth.type !== "integration" ||
Object.keys(input.auth.headers ?? {}).length > 0 ||
Object.keys(input.auth.query ?? {}).length > 0 ||
input.auth.body !== undefined);
const maxRedirects = input.kind === "query" && !authenticated ? MAX_QUERY_REDIRECTS : 0;
for (let redirects = 0; ; redirects += 1) {
if (input.pathPrefix !== undefined) assertCustomWidgetPathScope(currentUrl, input.pathPrefix);
const requestBody = applyBodyAuth(currentBody, input.auth);
if (requestBody !== undefined && Buffer.byteLength(requestBody, "utf8") > MAX_REQUEST_BODY_BYTES)
throw new CustomWidgetDomainError({
code: "PAYLOAD_TOO_LARGE",
message: "Request body exceeds the 10 KiB limit",
});
const dispatcher = createPinnedAgent(
await resolveAndValidateHost(currentUrl.hostname, input.networkScope, { signal: deadlineSignal }),
REQUEST_TIMEOUT_MS,
input.tls,
);
const headers = buildHeaders(input, currentUrl, requestBody);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
let result: RequestHopResult | undefined;
let lifecycleFailure: { error: unknown } | undefined;
try {
const responseData = await dispatcher.request({
origin: currentUrl.origin,
path: `${currentUrl.pathname}${currentUrl.search}`,
method: currentMethod,
headers,
body: requestBody,
signal: AbortSignal.any([deadlineSignal, controller.signal]),
});
if (![301, 302, 303, 307, 308].includes(responseData.statusCode)) {
let body: ArrayBuffer | Buffer = await responseData.body.arrayBuffer();
const responseHeaders = normalizeResponseHeaders(responseData.headers);
if (currentMethod !== "HEAD" && responseHeaders.has("content-encoding")) {
body = decodeResponseBody(body, responseHeaders.get("content-encoding"));
responseHeaders.delete("content-encoding");
responseHeaders.delete("content-length");
}
const response = new Response(body.byteLength > 0 ? body : null, {
status: responseData.statusCode,
statusText: STATUS_CODES[responseData.statusCode] ?? "",
headers: responseHeaders,
});
let data: unknown = null;
if (currentMethod !== "HEAD") data = await parseResponseBody(response, input.textFallback);
const parsed = {
ok: response.ok,
status: response.status,
statusText: response.statusText,
data,
};
parsed.data = redactResponseSecrets(parsed.data, input.redactSecrets ?? []);
result = { kind: "response", response: parsed };
} else {
await responseData.body.dump();
result = {
kind: "redirect",
location: normalizeResponseHeaders(responseData.headers).get("location"),
statusCode: responseData.statusCode,
};
}
} catch (error) {
lifecycleFailure = { error };
} finally {
clearTimeout(timeout);
try {
const closed = await closeDispatcher(dispatcher, deadlineSignal);
if (!closed)
input.logError?.({
origin: currentUrl.origin,
method: currentMethod,
errorName: "DispatcherCloseTimeout",
});
} catch {
input.logError?.({
origin: currentUrl.origin,
method: currentMethod,
errorName: "DispatcherCloseError",
});
}
}
if (lifecycleFailure) {
const { error } = lifecycleFailure;
if (error instanceof CustomWidgetDomainError) throw error;
if (error instanceof Error && "code" in error && error.code === RESPONSE_TOO_LARGE_ERROR_CODE) {
throw new CustomWidgetDomainError({
code: "PAYLOAD_TOO_LARGE",
message: "Response exceeds the 1 MiB limit",
});
}
const timedOut = isCustomWidgetRequestTimeoutError(error, deadlineSignal, controller.signal);
input.logError?.({
origin: currentUrl.origin,
method: currentMethod,
errorName: timedOut ? "RequestTimeout" : "TransportError",
...(timedOut ? { reason: "timeout" as const } : {}),
});
throw new CustomWidgetDomainError({
code: "BAD_GATEWAY",
message: timedOut ? "External request timed out" : "External request failed",
...(timedOut ? { reason: "timeout" as const } : {}),
});
}
if (!result)
throw new CustomWidgetDomainError({
code: "INTERNAL_SERVER_ERROR",
message: "External request did not complete",
});
if (result.kind === "response") return result.response;
if (redirects >= maxRedirects)
throw new CustomWidgetDomainError({ code: "BAD_GATEWAY", message: "Upstream redirect limit exceeded" });
if (!result.location)
throw new CustomWidgetDomainError({ code: "BAD_GATEWAY", message: "Upstream redirect is missing a location" });
const redirected = validateCustomWidgetUrl(new URL(result.location, currentUrl));
if (redirected.origin !== baseUrl.origin)
throw new CustomWidgetDomainError({ code: "FORBIDDEN", message: "Cross-origin redirects are not allowed" });
if (result.statusCode === 303 || ([301, 302].includes(result.statusCode) && currentMethod === "POST")) {
currentMethod = "GET";
currentBody = undefined;
}
currentUrl = redirected;
}
}
export function isCustomWidgetRequestTimeoutError(error: unknown, ...signals: readonly AbortSignal[]): boolean {
if (signals.some((signal) => signal.aborted)) return true;
if (!(error instanceof Error)) return false;
if (error.name.includes("Timeout")) return true;
const code = "code" in error && typeof error.code === "string" ? error.code : undefined;
return code !== undefined && TIMEOUT_ERROR_CODES.has(code);
}
function normalizeResponseHeaders(values: Record<string, string | string[] | undefined>) {
const headers = new Headers();
for (const [name, value] of Object.entries(values)) {
if (Array.isArray(value)) value.forEach((entry) => headers.append(name, entry));
else if (value !== undefined) headers.set(name, value);
}
return headers;
}
function assertRequest(input: CustomWidgetHttpRequest): void {
if (input.body !== undefined && Buffer.byteLength(input.body, "utf8") > MAX_REQUEST_BODY_BYTES)
throw new CustomWidgetDomainError({ code: "PAYLOAD_TOO_LARGE", message: "Request body exceeds the 10 KiB limit" });
assertSafeStaticHeaders(input.staticHeaders);
}
function buildHeaders(input: CustomWidgetHttpRequest, url: URL, body: string | undefined): Headers {
const headers = new Headers({ Accept: "application/json" });
for (const [name, value] of Object.entries(input.staticHeaders ?? {})) headers.set(name, value);
if (body !== undefined && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
if (body === undefined) headers.delete("Content-Type");
if (input.auth) {
if (input.auth.type === "apiKeyHeader") assertSafeStaticHeaders({ [input.auth.headerName ?? "X-API-Key"]: "" });
applyAuth(headers, url, input.auth.type, input.auth.secrets, input.auth.headerName);
for (const [name, value] of Object.entries(input.auth.headers ?? {})) headers.set(name, value);
for (const [name, value] of Object.entries(input.auth.query ?? {})) url.searchParams.set(name, value);
}
return headers;
}
export async function executeCustomWidgetRequest(input: CustomWidgetHttpRequest): Promise<CustomWidgetHttpResponse> {
const key = input.kind === "query" ? input.cacheKey : undefined;
if (!key) return performRequest(input);
const cached = cache.get(key);
if (cached && cached.expiresAt > Date.now()) return cached.response;
if (cached) cache.delete(key);
const pending = inFlight.get(key);
if (pending) return pending;
const requestEpoch = cacheEpoch;
let request: Promise<CustomWidgetHttpResponse>;
request = performRequest(input)
.then((response) => {
if (response.ok && (input.cacheTtlSeconds ?? 0) > 0 && requestEpoch === cacheEpoch) {
pruneCache();
cache.set(key, { expiresAt: Date.now() + (input.cacheTtlSeconds ?? 0) * 1000, response });
}
return response;
})
.finally(() => {
if (inFlight.get(key) === request) inFlight.delete(key);
});
inFlight.set(key, request);
return request;
}
export function invalidateCustomWidgetResponseCache(prefixes: readonly string[]): void {
if (prefixes.length === 0) return;
cacheEpoch += 1;
for (const key of cache.keys()) if (prefixes.some((prefix) => key.startsWith(prefix))) cache.delete(key);
for (const key of inFlight.keys()) if (prefixes.some((prefix) => key.startsWith(prefix))) inFlight.delete(key);
}
function pruneCache(): void {
for (const [key, entry] of cache) if (entry.expiresAt <= Date.now()) cache.delete(key);
while (cache.size >= MAX_RESPONSE_CACHE_ENTRIES) {
const key = cache.keys().next().value as string | undefined;
if (!key) return;
cache.delete(key);
}
}
@@ -0,0 +1,161 @@
import { createHash, randomUUID } from "node:crypto";
import { CustomWidgetDomainError } from "./errors";
const WINDOW_MS = 60_000;
// A query may legally follow three redirects with a ten-second timeout per hop.
// Keep the lease above that total and identify every acquisition so a late
// release can never remove capacity owned by a newer request.
export const CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS = 60_000;
export const CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT = 4;
export const CUSTOM_WIDGET_DEFINITION_CONCURRENCY_LIMIT = 8;
const CATEGORY_LIMITS = { query: 60, action: 10, delete: 3, metadata: 60 } as const;
const DEFINITION_CATEGORY_LIMITS = { query: 240, action: 40, delete: 12, metadata: 240 } as const;
type RequestCategory = keyof typeof CATEGORY_LIMITS;
export interface RequestLimitInput {
category: RequestCategory;
userId?: string;
anonymousId?: string;
itemId: string;
definitionId: string;
}
export interface RequestLimitStore {
incrementRate(key: string, windowMs: number): Promise<{ count: number; retryAfterMs: number }>;
acquireConcurrency(key: string, ownerId: string, limit: number, ttlMs: number): Promise<boolean>;
releaseConcurrency(key: string, ownerId: string): Promise<void>;
}
export interface RequestLimiterOptions {
store?: RequestLimitStore;
onStoreError?: (error: unknown) => void;
}
export class CustomWidgetRequestLimiter {
private readonly rateBuckets = new Map<string, number[]>();
private readonly concurrency = new Map<string, number>();
public constructor(private readonly options: RequestLimiterOptions = {}) {}
public async acquire(input: RequestLimitInput): Promise<() => Promise<void>> {
const identity = input.userId ? `user:${input.userId}` : `anonymous:${input.anonymousId ?? "unknown"}`;
const rateKey = `custom-widget:rate:${input.category}:${hashKey(identity, input.itemId)}`;
const definitionRateKey = `custom-widget:rate:v2:definition:${input.category}:${hashKey(input.definitionId)}`;
const userKey = `custom-widget:concurrency:v2:user-item:${hashKey(identity, input.itemId)}`;
const definitionKey = `custom-widget:concurrency:v2:definition:${hashKey(input.definitionId)}`;
if (!this.options.store)
return this.acquireLocal(rateKey, definitionRateKey, userKey, definitionKey, input.category);
try {
return await this.acquireStored(
rateKey,
definitionRateKey,
userKey,
definitionKey,
input.category,
this.options.store,
);
} catch (error) {
if (error instanceof CustomWidgetDomainError) throw error;
this.options.onStoreError?.(error);
throw new CustomWidgetDomainError({
code: "INTERNAL_SERVER_ERROR",
message: "Request limiter is unavailable",
});
}
}
private acquireLocal(
rateKey: string,
definitionRateKey: string,
userKey: string,
definitionKey: string,
category: RequestCategory,
) {
const now = Date.now();
this.acquireLocalRate(rateKey, CATEGORY_LIMITS[category], now);
this.acquireLocalRate(definitionRateKey, DEFINITION_CATEGORY_LIMITS[category], now);
this.acquireLocalConcurrency(userKey, CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT);
try {
this.acquireLocalConcurrency(definitionKey, CUSTOM_WIDGET_DEFINITION_CONCURRENCY_LIMIT);
} catch (error) {
this.releaseLocalConcurrency(userKey);
throw error;
}
return async () => {
this.releaseLocalConcurrency(userKey);
this.releaseLocalConcurrency(definitionKey);
};
}
private async acquireStored(
rateKey: string,
definitionRateKey: string,
userKey: string,
definitionKey: string,
category: RequestCategory,
store: RequestLimitStore,
) {
const ownerId = randomUUID();
const rate = await store.incrementRate(rateKey, WINDOW_MS);
if (rate.count > CATEGORY_LIMITS[category]) throwLimit(rate.retryAfterMs);
const definitionRate = await store.incrementRate(definitionRateKey, WINDOW_MS);
if (definitionRate.count > DEFINITION_CATEGORY_LIMITS[category]) throwLimit(definitionRate.retryAfterMs);
if (
!(await store.acquireConcurrency(
userKey,
ownerId,
CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT,
CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS,
))
) {
throwLimit(1_000);
}
try {
if (
!(await store.acquireConcurrency(
definitionKey,
ownerId,
CUSTOM_WIDGET_DEFINITION_CONCURRENCY_LIMIT,
CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS,
))
) {
throwLimit(1_000);
}
} catch (error) {
await store.releaseConcurrency(userKey, ownerId);
throw error;
}
return async () => {
await Promise.all([store.releaseConcurrency(userKey, ownerId), store.releaseConcurrency(definitionKey, ownerId)]);
};
}
private acquireLocalRate(key: string, limit: number, now: number): void {
const recent = (this.rateBuckets.get(key) ?? []).filter((timestamp) => now - timestamp < WINDOW_MS);
if (recent.length >= limit) throwLimit(WINDOW_MS - (now - (recent[0] ?? now)));
recent.push(now);
this.rateBuckets.set(key, recent);
}
private acquireLocalConcurrency(key: string, limit: number): void {
const count = this.concurrency.get(key) ?? 0;
if (count >= limit) throwLimit(1_000);
this.concurrency.set(key, count + 1);
}
private releaseLocalConcurrency(key: string): void {
const count = this.concurrency.get(key) ?? 0;
if (count <= 1) this.concurrency.delete(key);
else this.concurrency.set(key, count - 1);
}
}
const hashKey = (...parts: string[]) => createHash("sha256").update(parts.join("\0")).digest("hex");
const throwLimit = (retryAfterMs: number): never => {
throw new CustomWidgetDomainError({
code: "TOO_MANY_REQUESTS",
message: "Custom widget request limit exceeded",
retryAfterMs: Math.max(1, retryAfterMs),
});
};
@@ -0,0 +1,107 @@
import { createHash } from "node:crypto";
import type { CustomJsxRequest } from "../core";
import { collectCustomWidgetRequestReferences } from "../core";
import { CustomWidgetDomainError } from "./errors";
import { resolveSameOriginTarget } from "./request-executor";
export type CustomJsxRuntimeParams = Record<string, string | number | boolean>;
type CustomJsxResolvedValues = Record<string, unknown>;
export function resolveCustomWidgetRequestValues(
request: CustomJsxRequest,
options: Record<string, unknown>,
suppliedParams: CustomJsxRuntimeParams = {},
): CustomJsxResolvedValues {
const references = collectCustomWidgetRequestReferences(request);
const suppliedNames = Object.keys(suppliedParams).toSorted();
const expectedNames = [...references.params].toSorted();
if (
suppliedNames.length !== expectedNames.length ||
suppliedNames.some((name, index) => name !== expectedNames[index])
) {
throw new CustomWidgetDomainError({
code: "BAD_REQUEST",
message: "Request parameters do not match the request references",
});
}
const values: CustomJsxResolvedValues = { ...suppliedParams };
for (const name of references.options) {
const value = options[name];
if (value === undefined) {
throw new CustomWidgetDomainError({
code: "BAD_REQUEST",
message: `Widget option '${name}' is missing`,
});
}
values[`option:${name}`] = value;
}
return values;
}
export const renderBoundValue = (template: unknown, values: CustomJsxResolvedValues): unknown => {
const substitute = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(substitute);
if (value !== null && typeof value === "object") {
const entries = Object.entries(value);
if (
entries.length === 1 &&
["$param", "$option"].includes(entries[0]?.[0] ?? "") &&
typeof entries[0]?.[1] === "string"
) {
const prefix = entries[0][0] === "$option" ? "option:" : "";
const resolved = values[`${prefix}${entries[0][1]}`];
if (resolved === undefined)
throw new CustomWidgetDomainError({
code: "BAD_REQUEST",
message: `Unknown request value '${entries[0][1]}'`,
});
return resolved;
}
return Object.fromEntries(entries.map(([key, child]) => [key, substitute(child)]));
}
return value;
};
return substitute(template);
};
export const renderRequestBody = (request: CustomJsxRequest, values: CustomJsxResolvedValues): string | undefined =>
request.body === undefined ? undefined : JSON.stringify(renderBoundValue(request.body, values));
export const renderRequestTarget = (baseUrl: string, request: CustomJsxRequest, values: CustomJsxResolvedValues) => {
const path = request.path.replaceAll(
/\{(option|param):([A-Za-z][A-Za-z0-9_-]*)\}/gu,
(_match, kind: string, name: string) => {
const value = values[kind === "option" ? `option:${name}` : name];
if (value === undefined)
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: `Unknown path value '${name}'` });
if (!["string", "number", "boolean"].includes(typeof value))
throw new CustomWidgetDomainError({
code: "BAD_REQUEST",
message: `Path value '${name}' must resolve to text, number, or boolean`,
});
return encodeURIComponent(String(value));
},
);
if (/[{}]/u.test(path))
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Request path contains an invalid placeholder" });
const base = new URL(baseUrl);
base.pathname = `${base.pathname.replace(/\/$/u, "")}${path}` || "/";
const target = resolveSameOriginTarget(baseUrl, base);
for (const [name, template] of Object.entries(request.query ?? {})) {
const value = renderBoundValue(template, values);
if (!["string", "number", "boolean"].includes(typeof value)) {
throw new CustomWidgetDomainError({
code: "BAD_REQUEST",
message: `Query value '${name}' must resolve to text, number, or boolean`,
});
}
target.searchParams.set(name, String(value));
}
return target;
};
export function hashRuntimeParams(params: CustomJsxResolvedValues) {
const stable = Object.fromEntries(Object.entries(params).toSorted(([left], [right]) => left.localeCompare(right)));
return createHash("sha256").update(JSON.stringify(stable)).digest("hex");
}
@@ -0,0 +1,47 @@
import type { ConnectionOptions } from "node:tls";
import type { IntegrationHttpBodyAuth } from "@homarr/definitions";
import type { CustomJsxNetworkScope, CustomWidgetMethod } from "../core";
export interface CustomWidgetAuthConfig {
type: string;
secrets: Array<{ kind: string; value: string }>;
headerName?: string | null;
headers?: Record<string, string>;
query?: Record<string, string>;
body?: IntegrationHttpBodyAuth;
}
export interface CustomWidgetHttpRequest {
resolveConnectionAsync?(): Promise<
Pick<CustomWidgetHttpRequest, "baseUrl" | "auth" | "tls" | "pathPrefix" | "redactSecrets">
>;
tls?: Pick<ConnectionOptions, "ca" | "checkServerIdentity">;
pathPrefix?: string;
redactSecrets?: CustomWidgetAuthConfig["secrets"];
baseUrl: string;
targetUrl?: string | URL;
method: CustomWidgetMethod | "HEAD" | "OPTIONS";
body?: string;
staticHeaders?: Record<string, string>;
auth?: CustomWidgetAuthConfig;
networkScope: CustomJsxNetworkScope;
kind: "query" | "action";
timeoutMs?: number;
textFallback?: boolean;
cacheKey?: string;
cacheTtlSeconds?: number;
logError?: (event: {
origin: string;
method: CustomWidgetMethod | "HEAD" | "OPTIONS";
errorName: string;
reason?: "timeout";
}) => void;
}
export interface CustomWidgetHttpResponse {
ok: boolean;
status: number;
statusText: string;
data: unknown;
}
@@ -0,0 +1,157 @@
import { Buffer } from "node:buffer";
import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib";
import type { Response } from "undici";
import { CustomWidgetDomainError } from "./errors";
export const MAX_RESPONSE_BODY_BYTES = 1024 * 1024;
export const MAX_RESPONSE_JSON_DEPTH = 32;
export const MAX_RESPONSE_JSON_NODES = 50_000;
export function assertJsonBudget(value: unknown): void {
let nodes = 0;
const visit = (current: unknown, depth: number): void => {
if (++nodes > MAX_RESPONSE_JSON_NODES)
throw new CustomWidgetDomainError({ code: "PAYLOAD_TOO_LARGE", message: "Response JSON is too large" });
if (depth > MAX_RESPONSE_JSON_DEPTH)
throw new CustomWidgetDomainError({ code: "PAYLOAD_TOO_LARGE", message: "Response JSON is too deeply nested" });
if (Array.isArray(current)) current.forEach((entry) => visit(entry, depth + 1));
else if (current !== null && typeof current === "object")
Object.values(current).forEach((entry) => visit(entry, depth + 1));
};
visit(value, 0);
}
async function readLimitedBody(response: Response): Promise<string> {
const length = Number(response.headers.get("content-length"));
if (Number.isFinite(length) && length > MAX_RESPONSE_BODY_BYTES) throw tooLarge();
if (!response.body) return "";
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let size = 0;
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
size += chunk.value.byteLength;
if (size > MAX_RESPONSE_BODY_BYTES) {
await reader.cancel();
throw tooLarge();
}
chunks.push(chunk.value);
}
const bytes = new Uint8Array(size);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
}
/** Bound every decoding stage as well as the on-wire response body. */
export function decodeResponseBody(body: ArrayBuffer, contentEncoding: string | null): ArrayBuffer | Buffer {
if (!contentEncoding || body.byteLength === 0) return body;
const encodings = contentEncoding
.toLowerCase()
.split(",")
.map((value) => value.trim());
if (encodings.length > 3)
throw new CustomWidgetDomainError({ code: "BAD_GATEWAY", message: "Too many upstream content encodings" });
let decoded: ArrayBuffer | Buffer = body;
for (const encoding of encodings.toReversed()) {
try {
const options = { maxOutputLength: MAX_RESPONSE_BODY_BYTES };
switch (encoding) {
case "identity":
break;
case "gzip":
decoded = gunzipSync(decoded, options);
break;
case "deflate":
decoded = inflateSync(decoded, options);
break;
case "br":
decoded = brotliDecompressSync(decoded, options);
break;
default:
throw new CustomWidgetDomainError({ code: "BAD_GATEWAY", message: "Unsupported upstream content encoding" });
}
} catch (error) {
if (error instanceof CustomWidgetDomainError) throw error;
if (error instanceof Error && "code" in error && error.code === "ERR_BUFFER_TOO_LARGE") throw tooLarge();
throw new CustomWidgetDomainError({ code: "BAD_GATEWAY", message: "Invalid compressed upstream response" });
}
}
return decoded;
}
export async function parseResponseBody(response: Response, textFallback = false): Promise<unknown> {
const text = await readLimitedBody(response);
if (!text) return null;
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
try {
if (contentType.includes("ndjson")) {
const json = text
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0)
.map((line) => JSON.parse(line) as unknown);
assertJsonBudget(json);
return json;
}
const json = JSON.parse(text) as unknown;
assertJsonBudget(json);
return json;
} catch (error) {
if (error instanceof CustomWidgetDomainError) throw error;
if (!textFallback && contentType.includes("json")) {
throw new CustomWidgetDomainError({ code: "BAD_REQUEST", message: "Upstream returned invalid JSON" });
}
return text;
}
}
const tooLarge = () =>
new CustomWidgetDomainError({ code: "PAYLOAD_TOO_LARGE", message: "Response exceeds the 1 MiB limit" });
export function redactResponseSecrets(data: unknown, secrets: Array<{ kind: string; value: string }>): unknown {
if (secrets.length === 0) return data;
const values = secrets.filter(({ value }) => value.length > 0);
const username = secrets.find(({ kind }) => kind === "username")?.value;
const password = secrets.find(({ kind }) => kind === "password")?.value;
if (username && password) values.push({ kind: "basic", value: `${username}:${password}` });
const sensitive = new Set<string>();
const embedded = new Set<string>();
for (const { kind, value } of values) {
for (const encoded of [
value,
JSON.stringify(value).slice(1, -1),
encodeURIComponent(value),
Buffer.from(value).toString("base64"),
Buffer.from(value).toString("base64").replace(/=+$/u, ""),
Buffer.from(value).toString("base64url"),
]) {
sensitive.add(encoded);
// Usernames and short credentials can be ordinary words or characters in response data.
if (kind === "authenticationQuery" || (kind !== "username" && value.length >= 12)) embedded.add(encoded);
}
}
const embeddedValues = [...embedded].toSorted((a, b) => b.length - a.length);
const redactEmbedded = (value: string) => {
for (const secret of embeddedValues) value = value.replaceAll(secret, "[REDACTED]");
return value;
};
const visit = (value: unknown): unknown => {
if (typeof value === "string") {
if (sensitive.has(value)) return "[REDACTED]";
return redactEmbedded(value);
}
if ((typeof value === "number" || typeof value === "boolean" || value === null) && sensitive.has(String(value)))
return "[REDACTED]";
if (Array.isArray(value)) return value.map(visit);
if (value !== null && typeof value === "object") {
return Object.fromEntries(Object.entries(value).map(([key, child]) => [redactEmbedded(key), visit(child)]));
}
return value;
};
return visit(data);
}