Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

@@ -0,0 +1,186 @@
import { adminGetAllLegacyTenantSummaries } from "@toolctrl/db/accounts.server";
import {
adminDeleteLegacyApiKeyLogsByIds,
countLegacyApiKeyLogs,
deleteTenantLegacyApiKeyLogsByIds,
getLegacyApiKeyLogAdminDetails,
groupLegacyApiKeyLogs,
tenantLegacyApiKeyLogScope,
type LegacyApiKeyLogOrderByInput,
type LegacyApiKeyLogWhereInput,
} from "@toolctrl/db/api-keys.server";
import { Prisma } from "@toolctrl/db/prisma-browser";
import { Params } from "react-router";
import { FilterablePropertyDto } from "~/application/dtos/data/FilterablePropertyDto";
import { getFiltersFromCurrentUrl, getPaginationFromCurrentUrl } from "~/utils/helpers/RowPaginationHelper";
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
import { ApiCallSummaryDto } from "../dtos/ApiCallSummaryDto";
import ApiKeyLogUtils from "../utils/ApiKeyLogUtils";
import { ApiKeyLogDto } from "../dtos/ApiKeyLogDto";
import { PaginationDto } from "~/application/dtos/data/PaginationDto";
async function getSummary({ request, params }: { request: Request; params: Params }) {
const { filterableProperties, whereFilters, allTenants } = await getFilters({ request, params });
const searchParams = new URL(request.url).searchParams;
let groupBy = ApiKeyLogUtils.getGroupByValues(searchParams);
let items: ApiCallSummaryDto[] = [];
if (groupBy.length > 0) {
try {
const data = await groupLegacyApiKeyLogs({
by: groupBy,
where: whereFilters,
});
items = data.map((x) => x as ApiCallSummaryDto);
} catch {
throw Error("Invalid group by: " + groupBy.join(", "));
}
}
return { items, allTenants, filterableProperties };
}
async function getDetails({ request, params }: { request: Request; params: Params }): Promise<{
items: ApiKeyLogDto[];
filterableProperties: FilterablePropertyDto[];
pagination: PaginationDto;
}> {
const { filterableProperties, whereFilters } = await getFilters({ request, params });
const urlSearchParams = new URL(request.url).searchParams;
const pagination = getPaginationFromCurrentUrl(urlSearchParams);
const orderBy: LegacyApiKeyLogOrderByInput | LegacyApiKeyLogOrderByInput[] = !pagination.sortedBy.length
? { createdAt: "desc" }
: pagination.sortedBy.map((x) => ({
[x.name]: x.direction,
}));
const items = await getLegacyApiKeyLogAdminDetails({
take: pagination.pageSize,
skip: pagination.pageSize * (pagination.page - 1),
where: whereFilters,
includeTenant: !whereFilters.tenantId,
orderBy,
});
const totalItems = await countLegacyApiKeyLogs(whereFilters);
return {
items: items as ApiKeyLogDto[],
filterableProperties,
pagination: {
page: pagination.page,
pageSize: pagination.pageSize,
totalItems,
totalPages: Math.ceil(totalItems / pagination.pageSize),
},
};
}
async function getGroupBys() {
const allMethods = [
{ name: "GET", value: "GET" },
{ name: "POST", value: "POST" },
{ name: "PUT", value: "PUT" },
{ name: "PATCH", value: "PATCH" },
{ name: "DELETE", value: "DELETE" },
];
return {
allMethods,
};
}
async function getFilterableProperties(tenantId: string | null) {
const { allMethods } = await getGroupBys();
const filterableProperties: FilterablePropertyDto[] = [
{ name: "method", title: "models.apiKeyLog.method", options: allMethods },
{
name: "endpoint",
title: "models.apiKeyLog.endpoint",
},
{
name: "params",
title: "models.apiKeyLog.params",
},
{
name: "status",
title: "models.apiKeyLog.status",
},
{
name: "apiKeyId",
title: "models.apiKey.object",
},
];
let allTenants: { id: string; name: string; slug: string }[] = [];
if (tenantId === null) {
allTenants = await adminGetAllLegacyTenantSummaries();
filterableProperties.unshift({
name: "tenantId",
title: "models.tenant.object",
options: [
{ value: "null", name: "{null}" },
...allTenants.map((item) => {
return {
value: item.id,
name: item.name,
};
}),
],
});
}
return { filterableProperties, allTenants };
}
async function getFilters({ request, params }: { request: Request; params: Params }) {
const tenantId = await getTenantIdOrNull({ request, params });
const { filterableProperties, allTenants } = await getFilterableProperties(tenantId);
const filters = getFiltersFromCurrentUrl(request, filterableProperties);
const q = filters.query || "";
const AND_filters: Prisma.ApiKeyLogWhereInput[] = [];
filterableProperties.forEach((filter) => {
const value = filters.properties.find((f) => f.name === filter.name)?.value;
if (value) {
let formattedValue = filter.name === "status" ? Number(value) : value;
AND_filters.push({
[filter.name]: value === "null" ? null : formattedValue,
});
}
});
const OR_filters: Prisma.ApiKeyLogWhereInput[] = [];
if (q) {
OR_filters.push(
{ ip: { contains: q, mode: "insensitive" } },
{ endpoint: { contains: q, mode: "insensitive" } },
{ method: { contains: q, mode: "insensitive" } },
{ params: { contains: q, mode: "insensitive" } },
{ error: { contains: q, mode: "insensitive" } }
);
}
const whereFilters: LegacyApiKeyLogWhereInput = {};
if (OR_filters.length > 0) {
whereFilters.OR = OR_filters;
}
if (AND_filters.length > 0) {
whereFilters.AND = AND_filters;
}
if (tenantId) {
const existingAndFilters = Array.isArray(whereFilters.AND) ? whereFilters.AND : whereFilters.AND ? [whereFilters.AND] : [];
whereFilters.AND = [...existingAndFilters, tenantLegacyApiKeyLogScope(tenantId)];
}
return { filterableProperties, whereFilters, allTenants };
}
async function adminDeleteMany(ids: string[]) {
return await adminDeleteLegacyApiKeyLogsByIds(ids);
}
async function deleteManyForTenant(ids: string[], tenantId: string | null) {
return await deleteTenantLegacyApiKeyLogsByIds({ ids, tenantId });
}
export default {
getSummary,
getDetails,
adminDeleteMany,
deleteManyForTenant,
};
@@ -0,0 +1,95 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@toolctrl/db/entities.server", () => ({
getAllLegacyEntities: vi.fn(),
}));
vi.mock("~/locale/i18next.server", () => ({
getTranslations: vi.fn(async () => ({ t: (value: string) => value, translations: {}, locale: "en" })),
}));
vi.mock("~/utils/url.server", () => ({
getAbsoluteApiUrl: vi.fn((_request: Request, path: string) => `https://api.example.com${path.startsWith("/") ? path : `/${path}`}`),
}));
import { getAllLegacyEntities } from "@toolctrl/db/entities.server";
import ApiSpecsService from "./ApiSpecsService";
const entity = {
id: "entity_1",
slug: "contacts",
title: "Contact",
titlePlural: "Contacts",
hasApi: true,
active: true,
properties: [],
childEntities: [{ id: "child_relation" }],
parentEntities: [],
};
describe("ApiSpecsService", () => {
it("documents canonical API auth and idempotency for generated specs and Postman requests", async () => {
vi.mocked(getAllLegacyEntities).mockResolvedValue([entity] as never);
const specs = await ApiSpecsService.generateSpecs({ request: new Request("https://admin.example.com/api/docs") });
const openApi = specs.openApi;
expect(openApi.servers).toEqual([{ url: "https://api.example.com", description: "API host" }]);
expect(openApi.components.securitySchemes.bearerAuth).toMatchObject({
type: "http",
scheme: "bearer",
bearerFormat: "service-token",
});
expect(openApi.paths["/v1/entities/{entity}/rows"].post.parameters).toContainEqual(expect.objectContaining({ name: "Idempotency-Key", required: true }));
expect(openApi.paths["/v1/entities/{entity}/rows/{rowId}"].patch.parameters).toContainEqual(expect.objectContaining({ name: "Idempotency-Key", required: true }));
expect(openApi.paths["/v1/entities/{entity}/rows/{rowId}"].delete.parameters).toContainEqual(expect.objectContaining({ name: "Idempotency-Key", required: true }));
expect(openApi.paths["/v1/relationships"].post.parameters).toContainEqual(expect.objectContaining({ name: "Idempotency-Key", required: true }));
expect(openApi.paths["/v1/relationships/{id}"].delete.parameters).toContainEqual(expect.objectContaining({ name: "Idempotency-Key", required: true }));
const postmanItems = specs.postmanCollection.item as Array<{
name: string;
request: { header: Array<{ key: string; value?: string }>; url: { raw: string; query?: Array<{ key: string; value: string; disabled?: boolean }> } };
}>;
expect(postmanItems.find((item) => item.name === "GET /v1/session/current-user")?.request.header).toEqual([
expect.objectContaining({ key: "Authorization", value: "Bearer <token>" }),
]);
expect(postmanItems.find((item) => item.name === "POST /v1/api-keys/validate")?.request.header).toEqual([
expect.objectContaining({ key: "x-api-key", value: "<api-key-or-service-token>" }),
]);
expect(postmanItems.some((item) => item.name.startsWith("POST /v1/workflows") || item.name.startsWith("GET /v1/workflows"))).toBe(false);
expect(postmanItems.find((item) => item.name === "GET /v1/entities/:entity/rows")?.request.header).toEqual([
expect.objectContaining({ key: "x-api-key", value: "<api-key>" }),
]);
expect(postmanItems.find((item) => item.name === "GET /v1/entities/:entity/rows")?.request.url.query).toContainEqual(
expect.objectContaining({ key: "tenantId", value: "<tenantId>" })
);
expect(postmanItems.find((item) => item.name === "GET /v1/entities/:entity/rows")?.request.url.raw).toContain("tenantId=%3CtenantId%3E");
expect(postmanItems.find((item) => item.name === "POST /v1/entities/:entity/rows")?.request.header).toEqual([
expect.objectContaining({ key: "x-api-key", value: "<api-key>" }),
expect.objectContaining({ key: "Idempotency-Key" }),
]);
expect(postmanItems.find((item) => item.name === "POST /v1/ai/upload")?.request.header).toContainEqual(
expect.objectContaining({ key: "x-tenant-id", value: "<x-tenant-id>" })
);
expect(postmanItems.find((item) => item.name === "POST /v1/analytics/events")?.request.header).toEqual([
expect.objectContaining({ key: "x-analytics-token" }),
]);
expect(postmanItems.find((item) => item.name === "GET /v1/exports/:exportJobId/download-url")?.request.header).toEqual([
expect.objectContaining({ key: "x-api-key", value: "<api-key>" }),
]);
expect(postmanItems.find((item) => item.name === "POST /v1/billing/stripe/webhooks")?.request.header).toEqual([
expect.objectContaining({ key: "stripe-signature" }),
]);
expect(postmanItems.find((item) => item.name === "POST /v1/events/webhooks/attempts/:id")?.request.header).toEqual([
expect.objectContaining({ key: "Authorization", value: "Basic <base64(mailjet:webhook-secret)>" }),
]);
expect((specs.endpoints ?? []).map((endpoint) => endpoint.route)).toEqual([
"https://api.example.com/v1/entities/contacts/rows",
"https://api.example.com/v1/entities/contacts/rows/:rowId",
"https://api.example.com/v1/entities/contacts/rows",
"https://api.example.com/v1/entities/contacts/rows/:rowId",
"https://api.example.com/v1/entities/contacts/rows/:rowId",
]);
});
});
@@ -0,0 +1,340 @@
import { openApiDocument } from "@toolctrl/api/openapi";
import { getAllLegacyEntities, type LegacyEntityWithDetails } from "@toolctrl/db/entities.server";
import type { TFunction } from "i18next";
import type { ApiEndpointDto } from "../dtos/ApiEndpointDto";
import ApiHelper from "~/utils/helpers/ApiHelper";
import { getTranslations } from "~/locale/i18next.server";
import { getAbsoluteApiUrl } from "~/utils/url.server";
import { apiPath } from "~/utils/app/CanonicalUrlUtils";
export type ApiSpecsDto = {
endpoints?: ApiEndpointDto[];
openApi: CanonicalOpenApiDocument;
postmanCollection: PostmanCollection;
apiDocsUrl: string;
};
type CanonicalOpenApiDocument = typeof openApiDocument & {
servers: Array<{ url: string; description: string }>;
};
type PostmanCollection = {
info: {
_postman_id: string;
name: string;
schema: string;
};
item: Array<ReturnType<typeof toPostmanItem>>;
};
type PostmanHeader = { key: string; value: string; type: "text"; description?: string };
type PostmanQueryParam = { key: string; value: string; disabled?: boolean; description?: string };
async function generateSpecs({ request }: { request: Request }): Promise<ApiSpecsDto> {
const { t } = await getTranslations(request);
const entities = (await getAllLegacyEntities({ tenantId: null, active: true })).filter((entity) => entity.hasApi);
const openApi = generateCanonicalOpenApiSpecs({ request, t });
return {
endpoints: entities.flatMap((entity) => getEntityEndpoints({ entity, t, apiOrigin: getApiOrigin(request) })),
openApi,
postmanCollection: generatePostmanCollection({ request, openApi }),
apiDocsUrl: getAbsoluteApiUrl(request, apiPath("docs", { mode: "host-aware" })),
};
}
function getApiOrigin(request: Request) {
return new URL(getAbsoluteApiUrl(request, "/")).origin;
}
function generateCanonicalOpenApiSpecs({ request, t }: { request: Request; t: TFunction }): CanonicalOpenApiDocument {
return {
...openApiDocument,
servers: [
{
url: getApiOrigin(request),
description: translateApiSpec(t, "api.specs.hostDescription", "API host"),
},
],
} as CanonicalOpenApiDocument;
}
function getEntityApiRoute(apiOrigin: string, path: string) {
return new URL(apiPath(path, { mode: "host-aware" }), apiOrigin).toString();
}
function getEntityEndpoints({ entity, t, apiOrigin }: { entity: LegacyEntityWithDetails; t: TFunction; apiOrigin: string }) {
const body = Object.fromEntries(
entity.properties
.filter((property) => !property.isDefault)
.map((property) => [property.name, ApiHelper.getSampleValue({ property: property as never }) ?? ""])
);
const schema = Object.fromEntries(
entity.properties
.filter((property) => !property.isDefault)
.map((property) => [property.name, ApiHelper.getSchemaType({ property: property as never }) ?? ""])
);
return [
{
entity,
route: getEntityApiRoute(apiOrigin, `entities/${entity.slug}/rows`),
method: "GET",
description: translateApiSpec(t, "api.specs.operations.getAll", `Get all ${t(entity.titlePlural)}`, { name: t(entity.titlePlural) }),
responseSchema: JSON.stringify(schema, null, 2),
bodyExample: "",
},
{
entity,
route: getEntityApiRoute(apiOrigin, `entities/${entity.slug}/rows/:rowId`),
method: "GET",
description: translateApiSpec(t, "api.specs.operations.get", `Get ${t(entity.title)}`, { name: t(entity.title) }),
responseSchema: JSON.stringify(schema, null, 2),
bodyExample: "",
},
{
entity,
route: getEntityApiRoute(apiOrigin, `entities/${entity.slug}/rows`),
method: "POST",
description: translateApiSpec(t, "api.specs.operations.create", `Create ${t(entity.title)}`, { name: t(entity.title) }),
responseSchema: JSON.stringify(schema, null, 2),
bodyExample: JSON.stringify(body, null, 2),
},
{
entity,
route: getEntityApiRoute(apiOrigin, `entities/${entity.slug}/rows/:rowId`),
method: "PATCH",
description: translateApiSpec(t, "api.specs.operations.update", `Update ${t(entity.title)}`, { name: t(entity.title) }),
responseSchema: JSON.stringify(schema, null, 2),
bodyExample: JSON.stringify(body, null, 2),
},
{
entity,
route: getEntityApiRoute(apiOrigin, `entities/${entity.slug}/rows/:rowId`),
method: "DELETE",
description: translateApiSpec(t, "api.specs.operations.delete", `Delete ${t(entity.title)}`, { name: t(entity.title) }),
responseSchema: "",
bodyExample: "",
},
] satisfies ApiEndpointDto[];
}
function translateApiSpec(t: TFunction, key: string, fallback: string, options?: Record<string, unknown>) {
const translated = t(key, options);
return translated === key ? fallback : translated;
}
function generatePostmanCollection({ request, openApi }: { request: Request; openApi: CanonicalOpenApiDocument }): PostmanCollection {
const serverUrl = getApiOrigin(request);
const paths = openApi.paths as Record<string, Record<string, unknown>>;
return {
info: {
_postman_id: "canonical-api",
name: `${process.env.APP_NAME ?? "Boilerplate"} API`,
schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
},
item: Object.entries(paths).flatMap(([path, operations]) =>
Object.entries(operations)
.filter(([method]) => isHttpMethod(method))
.map(([method, operation]) => toPostmanItem({ serverUrl, path, method: method.toUpperCase(), operation }))
),
};
}
function toPostmanItem({ serverUrl, path, method, operation }: { serverUrl: string; path: string; method: string; operation: unknown }) {
const description = getOperationString(operation, "description") ?? getOperationString(operation, "summary") ?? "";
const postmanPath = path.replace(/\{([^}]+)\}/g, ":$1");
const request: Record<string, unknown> = {
url: getPostmanUrl(serverUrl, postmanPath, operation),
method,
header: getPostmanHeaders(operation),
description,
};
if (hasRequestBody(operation)) {
request.body = {
mode: "raw",
raw: "{}",
options: { raw: { language: "json" } },
};
}
return {
name: `${method} ${postmanPath}`,
request,
};
}
function getPostmanUrl(serverUrl: string, route: string, operation: unknown) {
const url = new URL(route, serverUrl);
const query = getPostmanQueryParams(operation);
for (const parameter of query) {
if (!parameter.disabled) {
url.searchParams.set(parameter.key, parameter.value);
}
}
return {
raw: url.toString(),
protocol: url.protocol.replace(":", ""),
host: [url.hostname],
path: url.pathname.replace(/^\//, "").split("/"),
port: url.port,
...(query.length > 0 ? { query } : {}),
};
}
function getPostmanHeaders(operation: unknown) {
const headers: PostmanHeader[] = [];
const securityScheme = getPreferredPostmanSecurityScheme(getOperationArray(operation, "security"));
const parameters = getOperationArray(operation, "parameters");
if (securityScheme === "apiKeyHeader") {
headers.push({ key: "x-api-key", value: getApiKeyHeaderPlaceholder(operation), type: "text" });
}
if (securityScheme === "analyticsTokenHeader") {
headers.push({ key: "x-analytics-token", value: "<analytics-token>", type: "text" });
}
if (securityScheme === "stripeWebhookSignature") {
headers.push({ key: "stripe-signature", value: "<stripe-signature>", type: "text" });
}
if (securityScheme === "mailjetWebhookAuth") {
headers.push({ key: "Authorization", value: "Basic <base64(mailjet:webhook-secret)>", type: "text" });
}
if (securityScheme === "bearerAuth" || securityScheme === "apiSessionBearer") {
headers.push({ key: "Authorization", value: "Bearer <token>", type: "text" });
}
if (parameters.some((entry) => isParameterNamed(entry, "Idempotency-Key"))) {
headers.push({ key: "Idempotency-Key", value: "<unique-operation-key>", type: "text" });
}
for (const parameter of getPostmanHeaderParams(parameters)) {
if (!headers.some((header) => header.key.toLowerCase() === parameter.key.toLowerCase())) {
headers.push(parameter);
}
}
return headers;
}
function getPostmanQueryParams(operation: unknown): PostmanQueryParam[] {
return getOperationArray(operation, "parameters")
.filter((parameter) => getParameterLocation(parameter) === "query")
.map((parameter) => {
const key = getParameterName(parameter);
return {
key,
value: `<${key}>`,
...(getParameterRequired(parameter) ? {} : { disabled: true }),
...getParameterDescription(parameter),
};
})
.filter((parameter) => parameter.key.length > 0);
}
function getPostmanHeaderParams(parameters: unknown[]): PostmanHeader[] {
return parameters
.filter((parameter) => getParameterLocation(parameter) === "header")
.map((parameter) => {
const key = getParameterName(parameter);
return {
key,
value: `<${key}>`,
type: "text" as const,
...getParameterDescription(parameter),
};
})
.filter((parameter) => parameter.key.length > 0);
}
function getPreferredPostmanSecurityScheme(security: unknown[]) {
if (security.some((entry) => isSecurityScheme(entry, "apiKeyHeader"))) {
return "apiKeyHeader";
}
if (security.some((entry) => isSecurityScheme(entry, "analyticsTokenHeader"))) {
return "analyticsTokenHeader";
}
if (security.some((entry) => isSecurityScheme(entry, "stripeWebhookSignature"))) {
return "stripeWebhookSignature";
}
if (security.some((entry) => isSecurityScheme(entry, "mailjetWebhookAuth"))) {
return "mailjetWebhookAuth";
}
if (security.some((entry) => isSecurityScheme(entry, "bearerAuth"))) {
return "bearerAuth";
}
if (security.some((entry) => isSecurityScheme(entry, "apiSessionBearer"))) {
return "apiSessionBearer";
}
return null;
}
function getOperationString(operation: unknown, key: string) {
return operation && typeof operation === "object" && typeof (operation as Record<string, unknown>)[key] === "string"
? (operation as Record<string, string>)[key]
: undefined;
}
function getOperationArray(operation: unknown, key: string) {
const value = operation && typeof operation === "object" ? (operation as Record<string, unknown>)[key] : undefined;
return Array.isArray(value) ? value : [];
}
function hasRequestBody(operation: unknown) {
return Boolean(operation && typeof operation === "object" && "requestBody" in operation);
}
function hasScopedApiKeyPermissions(operation: unknown) {
return Boolean(operation && typeof operation === "object" && "x-api-key-permissions" in operation);
}
function getApiKeyHeaderPlaceholder(operation: unknown) {
if (operation && typeof operation === "object" && "x-api-key-placeholder" in operation) {
const placeholder = (operation as { "x-api-key-placeholder"?: unknown })["x-api-key-placeholder"];
if (typeof placeholder === "string" && placeholder.length > 0) {
return placeholder;
}
}
return hasScopedApiKeyPermissions(operation) ? "<api-key>" : "<service-token>";
}
function isSecurityScheme(value: unknown, scheme: string) {
return Boolean(value && typeof value === "object" && scheme in value);
}
function isParameterNamed(value: unknown, name: string) {
return Boolean(value && typeof value === "object" && (value as Record<string, unknown>).name === name);
}
function getParameterName(value: unknown) {
return value && typeof value === "object" && typeof (value as Record<string, unknown>).name === "string"
? ((value as Record<string, string>).name as string)
: "";
}
function getParameterLocation(value: unknown) {
return value && typeof value === "object" && typeof (value as Record<string, unknown>).in === "string"
? ((value as Record<string, string>).in as string)
: "";
}
function getParameterRequired(value: unknown) {
return Boolean(value && typeof value === "object" && (value as Record<string, unknown>).required === true);
}
function getParameterDescription(value: unknown) {
if (value && typeof value === "object" && typeof (value as Record<string, unknown>).description === "string") {
return { description: (value as Record<string, string>).description };
}
return {};
}
function isHttpMethod(value: string) {
return ["get", "post", "put", "patch", "delete", "options", "head"].includes(value);
}
export default {
generateSpecs,
getEntityEndpoints,
};
@@ -0,0 +1,101 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@toolctrl/db/logs.server", () => ({
createLegacyEntityWebhookLog: vi.fn(async (data) => data),
createLegacyRowLog: vi.fn(async () => ({ id: "log_1" })),
getLegacyEntityWebhooksByAction: vi.fn(),
}));
vi.mock("~/utils/url.server", () => ({
getBaseURL: vi.fn((request: Request) => new URL(request.url).origin),
}));
vi.mock("~/utils/helpers/ApiHelper", () => ({
default: {
getApiFormat: vi.fn(() => ({ formatted: true })),
},
}));
import { createLegacyEntityWebhookLog, createLegacyRowLog, getLegacyEntityWebhooksByAction } from "@toolctrl/db/logs.server";
import { createApiRowLog } from "./RowLogService.server";
describe("RowLogService webhook delivery", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 202 })));
});
it("resolves relative webhook URLs against the request base before validation", async () => {
vi.mocked(getLegacyEntityWebhooksByAction).mockResolvedValueOnce([{ id: "webhook_relative", method: "POST", endpoint: "/webhooks/row" }]);
await createApiRowLog(new Request("https://tenant.example.com/api/rows"), {
tenantId: "tenant_1",
action: "created",
entity: { id: "entity_1" },
item: { id: "row_1" },
});
expect(fetch).toHaveBeenCalledWith(
"https://tenant.example.com/webhooks/row",
expect.objectContaining({ method: "POST", redirect: "manual", signal: expect.any(AbortSignal) })
);
expect(createLegacyEntityWebhookLog).toHaveBeenCalledWith({ webhookId: "webhook_relative", logId: "log_1", status: 202 });
});
it("follows safe redirects after validating the redirect target", async () => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValueOnce(new Response(null, { status: 307, headers: { location: "https://hooks.example.com/row-next" } }))
.mockResolvedValueOnce(new Response(null, { status: 202 }))
);
vi.mocked(getLegacyEntityWebhooksByAction).mockResolvedValueOnce([{ id: "webhook_redirect", method: "POST", endpoint: "https://hooks.example.com/row" }]);
await createApiRowLog(new Request("https://tenant.example.com/api/rows"), {
tenantId: "tenant_1",
action: "created",
entity: { id: "entity_1" },
item: { id: "row_1" },
});
expect(fetch).toHaveBeenNthCalledWith(1, "https://hooks.example.com/row", expect.objectContaining({ method: "POST", redirect: "manual" }));
expect(fetch).toHaveBeenNthCalledWith(2, "https://hooks.example.com/row-next", expect.objectContaining({ method: "POST", redirect: "manual" }));
expect(createLegacyEntityWebhookLog).toHaveBeenCalledWith({ webhookId: "webhook_redirect", logId: "log_1", status: 202 });
});
it("blocks unsafe redirect targets without following them", async () => {
vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 302, headers: { location: "http://127.0.0.1/webhook" } })));
vi.mocked(getLegacyEntityWebhooksByAction).mockResolvedValueOnce([{ id: "webhook_redirect_blocked", method: "POST", endpoint: "https://hooks.example.com/row" }]);
await createApiRowLog(new Request("https://tenant.example.com/api/rows"), {
tenantId: "tenant_1",
action: "created",
entity: { id: "entity_1" },
item: { id: "row_1" },
});
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith("https://hooks.example.com/row", expect.objectContaining({ redirect: "manual" }));
expect(createLegacyEntityWebhookLog).toHaveBeenCalledWith({ webhookId: "webhook_redirect_blocked", logId: "log_1", status: 0 });
});
it("blocks unsafe webhook URLs and records failed delivery status without fetching", async () => {
vi.mocked(getLegacyEntityWebhooksByAction).mockResolvedValueOnce([
{ id: "webhook_loopback", method: "POST", endpoint: "http://127.0.0.1/webhook" },
{ id: "webhook_relative_localhost", method: "POST", endpoint: "/webhooks/row" },
]);
await createApiRowLog(new Request("https://localhost/api/rows"), {
tenantId: "tenant_1",
action: "created",
entity: { id: "entity_1" },
item: { id: "row_1" },
});
expect(fetch).not.toHaveBeenCalled();
expect(createLegacyRowLog).toHaveBeenCalledTimes(1);
expect(createLegacyEntityWebhookLog).toHaveBeenCalledWith({ webhookId: "webhook_loopback", logId: "log_1", status: 0 });
expect(createLegacyEntityWebhookLog).toHaveBeenCalledWith({ webhookId: "webhook_relative_localhost", logId: "log_1", status: 0 });
});
});
@@ -0,0 +1,285 @@
import { createLegacyEntityWebhookLog, createLegacyRowLog, getLegacyEntityWebhooksByAction } from "@toolctrl/db/logs.server";
import { getBaseURL } from "~/utils/url.server";
import ApiHelper from "~/utils/helpers/ApiHelper";
const LEGACY_WEBHOOK_FETCH_TIMEOUT_MS = 10_000;
const BLOCKED_WEBHOOK_STATUS = 0;
const LEGACY_WEBHOOK_REDIRECT_LIMIT = 5;
const INTERNAL_WEBHOOK_HOST_SUFFIXES = [".localhost", ".local", ".internal", ".lan", ".home", ".corp", ".intranet", ".svc"] as const;
type ApiRowLogEntity = {
id: string;
[key: string]: unknown;
};
type ApiRowLogItem = {
id: string;
[key: string]: unknown;
} | null;
export async function createApiRowLog(
request: Request,
data: {
tenantId: string | null;
createdByUserId?: string | null;
createdByApiKey?: string | null;
action: string;
entity: ApiRowLogEntity;
item: ApiRowLogItem;
commentId?: string | null;
details?: string;
},
) {
const log = await createLegacyRowLog({
tenantId: data.tenantId,
userId: data.createdByUserId ?? null,
apiKeyId: data.createdByApiKey ?? null,
url: new URL(request.url).pathname,
rowId: data.item?.id ?? null,
action: data.action,
details: data.details ?? null,
commentId: data.commentId ?? null,
});
const apiFormat = ApiHelper.getApiFormat(data.entity as never, data.item as never);
await callApiEntityWebhooks({
logId: log.id,
entityId: data.entity.id,
action: data.action,
body: apiFormat,
request,
});
return log;
}
async function callApiEntityWebhooks({
logId,
entityId,
action,
body,
request,
}: {
logId: string;
entityId: string;
action: string;
body: string;
request: Request;
}) {
const webhooks = await getLegacyEntityWebhooksByAction(entityId, action);
return Promise.all(
webhooks.map(async (webhook) => {
if (!webhook.endpoint) {
return undefined;
}
const status = await fetchLegacyWebhookStatus(webhook.endpoint, request, {
method: webhook.method,
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
return createLegacyEntityWebhookLog({
webhookId: webhook.id,
logId,
status,
});
}),
);
}
function resolveWebhookEndpoint(endpoint: string, request: Request): string {
const resolvedEndpoint = endpoint.startsWith("/") ? getBaseURL(request) + endpoint : endpoint;
return validateOutboundWebhookUrl(resolvedEndpoint);
}
async function fetchLegacyWebhookStatus(endpoint: string, request: Request, init: RequestInit): Promise<number> {
try {
const response = await fetchWebhookWithRedirects(resolveWebhookEndpoint(endpoint, request), init);
return response.status;
} catch {
return BLOCKED_WEBHOOK_STATUS;
}
}
async function fetchWithTimeout(input: Parameters<typeof fetch>[0], init: RequestInit): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), LEGACY_WEBHOOK_FETCH_TIMEOUT_MS);
try {
return await fetch(input, { ...init, redirect: "manual", signal: controller.signal });
} finally {
clearTimeout(timeout);
}
}
async function fetchWebhookWithRedirects(input: string, init: RequestInit): Promise<Response> {
let currentUrl = input;
let currentInit = init;
for (let redirects = 0; redirects <= LEGACY_WEBHOOK_REDIRECT_LIMIT; redirects += 1) {
const response = await fetchWithTimeout(currentUrl, currentInit);
if (!isRedirectResponse(response.status)) {
return response;
}
const location = response.headers.get("location");
if (!location) {
return response;
}
if (redirects === LEGACY_WEBHOOK_REDIRECT_LIMIT) {
throw new Error("Webhook redirect limit exceeded.");
}
currentUrl = resolveOutboundWebhookRedirect(currentUrl, location);
currentInit = buildRedirectRequestInit(currentInit, response.status);
}
throw new Error("Webhook redirect limit exceeded.");
}
function resolveOutboundWebhookRedirect(currentUrl: string, location: string): string {
let redirectUrl: URL;
try {
redirectUrl = new URL(location, currentUrl);
} catch {
throw new Error("Webhook redirect location must be a valid URL.");
}
return validateOutboundWebhookUrl(redirectUrl.toString());
}
function isRedirectResponse(status: number): boolean {
return status >= 300 && status < 400;
}
function buildRedirectRequestInit(init: RequestInit, status: number): RequestInit {
const method = typeof init.method === "string" ? init.method.toUpperCase() : undefined;
if (status === 303 || ((status === 301 || status === 302) && method === "POST")) {
const headers = new Headers(init.headers);
headers.delete("content-type");
headers.delete("content-length");
return { ...init, method: "GET", body: undefined, headers };
}
return init;
}
function validateOutboundWebhookUrl(endpoint: string): string {
let url: URL;
try {
url = new URL(endpoint);
} catch {
throw new Error("Webhook endpoint must be an absolute URL.");
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("Webhook endpoint must use http or https.");
}
if (isBlockedWebhookHostname(url.hostname)) {
throw new Error("Webhook endpoint host is not allowed.");
}
return url.toString();
}
function isBlockedWebhookHostname(hostname: string): boolean {
const normalizedHostname = hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
if (!normalizedHostname) {
return true;
}
const ipv4 = parseIpv4Address(normalizedHostname);
if (ipv4) {
return isBlockedIpv4Address(ipv4);
}
const ipv6 = parseIpv6Address(normalizedHostname);
if (ipv6) {
return isBlockedIpv6Address(ipv6);
}
return (
normalizedHostname === "localhost" ||
!normalizedHostname.includes(".") ||
INTERNAL_WEBHOOK_HOST_SUFFIXES.some((suffix) => normalizedHostname.endsWith(suffix))
);
}
function parseIpv4Address(hostname: string): [number, number, number, number] | undefined {
const parts = hostname.split(".");
if (parts.length !== 4) {
return undefined;
}
const octets = parts.map((part) => Number(part));
if (octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
return undefined;
}
return octets as [number, number, number, number];
}
function isBlockedIpv4Address([first, second]: [number, number, number, number]): boolean {
return (
first === 0 ||
first === 10 ||
first === 127 ||
first >= 224 ||
(first === 100 && second >= 64 && second <= 127) ||
(first === 169 && second === 254) ||
(first === 172 && second >= 16 && second <= 31) ||
(first === 192 && second === 168) ||
(first === 198 && (second === 18 || second === 19))
);
}
function parseIpv6Address(hostname: string): number[] | undefined {
if (!hostname.includes(":")) {
return undefined;
}
const [head = "", tail = "", extra] = hostname.split("::");
if (extra !== undefined) {
return undefined;
}
const headParts = head ? head.split(":") : [];
const tailParts = tail ? tail.split(":") : [];
const missingPartCount = 8 - headParts.length - tailParts.length;
if (missingPartCount < 0 || (!hostname.includes("::") && missingPartCount !== 0)) {
return undefined;
}
const parts = [...headParts, ...Array(missingPartCount).fill("0"), ...tailParts];
if (parts.length !== 8) {
return undefined;
}
const hextets = parts.map((part) => Number.parseInt(part, 16));
if (hextets.some((part) => !Number.isInteger(part) || part < 0 || part > 0xffff)) {
return undefined;
}
return hextets;
}
function isBlockedIpv6Address(hextets: number[]): boolean {
const first = hextets[0] ?? 0;
const isUnspecified = hextets.every((part) => part === 0);
const isLoopback = hextets.slice(0, 7).every((part) => part === 0) && hextets[7] === 1;
const isUniqueLocal = (first & 0xfe00) === 0xfc00;
const isLinkLocal = (first & 0xffc0) === 0xfe80;
const isIpv4Mapped = hextets.slice(0, 5).every((part) => part === 0) && hextets[5] === 0xffff;
const isIpv4Compatible = hextets.slice(0, 6).every((part) => part === 0);
if (isUnspecified || isLoopback || isUniqueLocal || isLinkLocal) {
return true;
}
if (isIpv4Mapped || isIpv4Compatible) {
const ipv4: [number, number, number, number] = [hextets[6] >> 8, hextets[6] & 0xff, hextets[7] >> 8, hextets[7] & 0xff];
return isBlockedIpv4Address(ipv4);
}
return false;
}