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,55 @@
import { createCookieSessionStorage, redirect, Session } from "react-router";
import { loadRequiredWebConfig, resolveSessionCookiePolicy } from "@toolctrl/config";
import { randomBytes } from "crypto";
export type AnalyticsSession = {
userAnalyticsId: string;
};
const webConfig = loadRequiredWebConfig();
const storage = createCookieSessionStorage({
cookie: {
...resolveSessionCookiePolicy({
nodeEnv: webConfig.nodeEnv,
routing: webConfig.routing,
name: "app_analytics",
maxAge: 60 * 60 * 24 * 30,
}),
secrets: webConfig.sessionSecret ? [webConfig.sessionSecret] : [],
},
});
export function getAnalyticsSession(request: Request) {
return storage.getSession(request.headers.get("Cookie"));
}
export async function getAnalyticsInfo(request: Request): Promise<AnalyticsSession> {
const session = await getAnalyticsSession(request);
const userAnalyticsId = session.get("userAnalyticsId") ?? null;
return {
userAnalyticsId,
};
}
export async function createAnalyticsSession(analyticsSession: AnalyticsSession, redirectTo = "/") {
const session = await storage.getSession();
session.set("userAnalyticsId", analyticsSession.userAnalyticsId);
return redirect(redirectTo, {
headers: {
"Set-Cookie": await storage.commitSession(session),
},
});
}
export async function commitAnalyticsSession(session: Session) {
return await storage.commitSession(session);
}
export async function destroyAnalyticsSession(session: Session) {
return await storage.destroySession(session);
}
export function generateAnalyticsUserId() {
return randomBytes(100).toString("base64");
}
@@ -0,0 +1,27 @@
// @vitest-environment node
import { afterEach, describe, expect, test } from "vitest";
import CryptoApi from "./CryptoApi";
const previousCryptoSecret = process.env.CRYPTO_SECRET;
afterEach(() => {
process.env.CRYPTO_SECRET = previousCryptoSecret;
});
describe("CryptoApi", () => {
test("encrypts and decrypts values with authenticated node crypto", () => {
process.env.CRYPTO_SECRET = "test-crypto-secret";
const encrypted = CryptoApi.encrypt("secret value");
expect(encrypted).toMatch(/^v1:[A-Za-z0-9_-]+:[A-Za-z0-9_-]+:[A-Za-z0-9_-]+$/);
expect(encrypted).not.toContain("secret value");
expect(CryptoApi.decrypt(encrypted)).toBe("secret value");
});
test("rejects malformed encrypted payloads", () => {
process.env.CRYPTO_SECRET = "test-crypto-secret";
expect(() => CryptoApi.decrypt("legacy-or-corrupt-value")).toThrow("Invalid encrypted payload");
});
});
@@ -0,0 +1,35 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
const algorithm = "aes-256-gcm";
const version = "v1";
function getSecretKey() {
const secret = process.env.CRYPTO_SECRET?.toString();
if (!secret) {
throw Error("CRYPTO_SECRET env variable not set");
}
return createHash("sha256").update(secret).digest();
}
function encrypt(text: string) {
const iv = randomBytes(12);
const cipher = createCipheriv(algorithm, getSecretKey(), iv);
const ciphertext = Buffer.concat([cipher.update(text, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return [version, iv.toString("base64url"), tag.toString("base64url"), ciphertext.toString("base64url")].join(":");
}
function decrypt(encryptedText: string) {
const [payloadVersion, iv, tag, ciphertext] = encryptedText.split(":");
if (payloadVersion !== version || !iv || !tag || !ciphertext) {
throw new Error("Invalid encrypted payload");
}
const decipher = createDecipheriv(algorithm, getSecretKey(), Buffer.from(iv, "base64url"));
decipher.setAuthTag(Buffer.from(tag, "base64url"));
return Buffer.concat([decipher.update(Buffer.from(ciphertext, "base64url")), decipher.final()]).toString("utf8");
}
export default {
encrypt,
decrypt,
};
@@ -0,0 +1,88 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { EntitiesApi } from "./EntitiesApi";
import { findEntityByName, findEntityBySlug, getEntityByPrefix } from "../../db/entities/entities.db.server";
vi.mock("../../db/entities/entities.db.server", () => ({
findEntityByName: vi.fn(),
findEntityBySlug: vi.fn(),
getEntityByIdOrName: vi.fn(),
getEntityByPrefix: vi.fn(),
}));
vi.mock("../../services/.server/subscriptionService", () => ({
getPlanFeatureUsage: vi.fn(),
}));
describe("EntitiesApi.validateEntity", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(findEntityByName).mockResolvedValue(null);
vi.mocked(findEntityBySlug).mockResolvedValue(null);
vi.mocked(getEntityByPrefix).mockResolvedValue(null);
});
it("rejects entity slugs that would collide with canonical tenant child routes", async () => {
await expect(
EntitiesApi.validateEntity({
tenantId: null,
name: "settings",
slug: " Settings ",
order: null,
prefix: "set",
})
).resolves.toContain("Reserved entity slug ' Settings ' conflicts with a workspace route");
});
});
describe("EntitiesApi.getNoCodeRoutes", () => {
it("does not classify tenant slugs that only start with admin as admin routes", () => {
const routes = EntitiesApi.getNoCodeRoutes({
request: new Request("http://app.localhost:3000/adminish/g/sales/contact"),
params: { tenant: "adminish", group: "sales" },
});
expect(routes.list).toBe("/adminish/g/sales/:entity");
expect(routes.overview).toBe("/adminish/g/sales/:entity/:id");
});
it("uses existing no-code entity routes for admin grouped entity contexts", () => {
const routes = EntitiesApi.getNoCodeRoutes({
request: new Request("http://admin.localhost:3000/g/sales/contact"),
params: { group: "sales" },
});
expect(routes.list).toBe("/entities/:entity/no-code/:entity");
expect(routes.new).toBe("/entities/:entity/no-code/:entity/new");
expect(routes.overview).toBe("/entities/:entity/no-code/:entity/:id");
expect(routes.edit).toBe("/entities/:entity/no-code/:entity/:id/edit");
expect(routes.import).toBe("/entities/:entity/no-code/:entity/import");
expect(routes.export).toBe("/entities/:entity/no-code/:entity/export");
expect(routes.group).toBe("/entities/groups");
});
it("uses host context instead of removed admin path prefixes", () => {
const request = new Request("https://unknown.example.test/admin/entities/company/no-code/company");
const routes = EntitiesApi.getNoCodeRoutes({ request, params: { tenant: "acme" } });
expect(routes.list).toBe("/acme/:entity");
expect(routes.list).not.toBe("/entities/:entity/no-code/:entity");
});
it("does not treat removed tenant-prefixed runtime paths as app context for grouped routes", () => {
const request = new Request("https://unknown.example.test/app/acme/g/sales/company");
const routes = EntitiesApi.getNoCodeRoutes({ request, params: { tenant: "acme", group: "sales" } });
expect(routes.group).toBeUndefined();
expect(routes.list).toBe("/acme/:entity");
});
it("matches tenant CRM routes by exact path segment", () => {
const crmRequest = new Request("https://app.example.test/acme/crm/company");
const crmishRequest = new Request("https://app.example.test/acme/crmish/company");
expect(EntitiesApi.getNoCodeRoutes({ request: crmRequest, params: { tenant: "acme" } }).list).toBe("/acme/crm/:entity");
expect(EntitiesApi.getNoCodeRoutes({ request: crmishRequest, params: { tenant: "acme" } }).list).toBe("/acme/:entity");
});
});
@@ -0,0 +1,190 @@
import { PlanFeatureUsageDto } from "~/application/dtos/subscriptions/PlanFeatureUsageDto";
import { EntityWithDetails, findEntityByName, findEntityBySlug, getEntityByIdOrName, getEntityByPrefix } from "../../db/entities/entities.db.server";
import { getPlanFeatureUsage } from "../../services/.server/subscriptionService";
import { Entity } from "@toolctrl/db/prisma-browser";
import { Params } from "react-router";
import { getAbsoluteUrl } from "../../url.server";
import { adminPath, appPath } from "~/utils/app/CanonicalUrlUtils";
import { getHostRoutingContextFromRequest } from "~/utils/app/HostRouting.server";
import { isReservedTenantChildRouteSlug } from "~/utils/services/tenantService";
export namespace EntitiesApi {
export type GetEntityData = {
entity: EntityWithDetails;
featureUsageEntity?: PlanFeatureUsageDto | undefined;
};
export type Routes = {
list?: string;
new?: string;
overview?: string;
edit?: string;
publicUrl?: string;
import?: string;
export?: string;
group?: string;
};
export async function get({ entity, tenantId, userId }: { entity: { id?: string; name?: string }; tenantId?: string | null; userId?: string }) {
const item = await getEntityByIdOrName({ tenantId, ...entity });
const featureUsageEntity = tenantId ? await getPlanFeatureUsage(tenantId, item.name) : undefined;
await fillSystemProperties({ entity: item, tenantId, userId });
const data: GetEntityData = {
entity: item,
featureUsageEntity,
};
return data;
}
export async function validateEntity({
tenantId,
name,
slug,
order,
prefix,
entity,
}: {
tenantId: string | null;
name: string;
slug: string;
order: number | null;
prefix: string;
entity?: Entity;
}) {
const errors: string[] = [];
if (isReservedTenantChildRouteSlug(slug)) {
errors.push(`Reserved entity slug '${slug}' conflicts with a workspace route`);
}
if (!entity || entity?.name !== name) {
const existingName = await findEntityByName({ tenantId, name });
if (existingName) {
errors.push(`Existing entity with name '${name}': ${existingName.slug}`);
}
}
if (!entity || entity?.slug !== slug) {
const existingSlug = await findEntityBySlug(slug);
if (existingSlug) {
errors.push(`Existing entity with slug '${slug}': ${existingSlug.slug}`);
}
}
// if (order) {
// if (!entity || entity?.order !== order) {
// const existingOrder = await getEntityByOrder(order);
// if (existingOrder) {
// errors.push(`Existing entity with order '${order}': ${existingOrder.slug}`);
// }
// }
// }
if (!entity || entity?.prefix !== prefix) {
const existingPrefix = await getEntityByPrefix(prefix);
if (existingPrefix) {
errors.push(`Existing entity with prefix '${prefix}': ${existingPrefix.slug}`);
}
}
return errors;
}
export function getNoCodeRoutes({ request, params }: { request: Request; params: Params }): EntitiesApi.Routes {
const url = new URL(request.url);
const hostContext = getHostRoutingContextFromRequest(request);
const isAdminRequest = hostContext === "admin";
const isAppRequest = hostContext === "app";
const adminRoute = (path: string) => adminPath(path, { mode: "host-aware" });
const tenantRoute = (path: string) => appPath({ tenantSlug: params?.tenant ?? "", path, mode: "host-aware" });
if (params.group) {
if (isAdminRequest) {
const routes: EntitiesApi.Routes = {
list: adminRoute("entities/:entity/no-code/:entity"),
new: adminRoute("entities/:entity/no-code/:entity/new"),
overview: adminRoute("entities/:entity/no-code/:entity/:id"),
edit: adminRoute("entities/:entity/no-code/:entity/:id/edit"),
import: adminRoute("entities/:entity/no-code/:entity/import"),
export: adminRoute("entities/:entity/no-code/:entity/export"),
publicUrl: getAbsoluteUrl(request, `/public/:entity/:id`),
group: adminRoute("entities/groups"),
};
return routes;
} else if (isAppRequest) {
const routes: EntitiesApi.Routes = {
list: tenantRoute(`g/${params.group}/:entity`),
new: tenantRoute(`g/${params.group}/:entity/new`),
overview: tenantRoute(`g/${params.group}/:entity/:id`),
edit: tenantRoute(`g/${params.group}/:entity/:id/edit`),
import: tenantRoute(`g/${params.group}/:entity/import`),
export: tenantRoute(`g/${params.group}/:entity/export`),
publicUrl: getAbsoluteUrl(request, `/public/:entity/:id`),
group: tenantRoute(`g/${params.group}`),
};
return routes;
}
} else if (isAdminRequest && isPathOrChild(url.pathname, "/crm")) {
const routes: EntitiesApi.Routes = {
list: adminRoute("crm/:entity"),
new: adminRoute("crm/:entity/new"),
overview: adminRoute("crm/:entity/:id"),
edit: adminRoute("crm/:entity/:id/edit"),
import: adminRoute("crm/:entity/import"),
export: adminRoute("crm/:entity/export"),
publicUrl: getAbsoluteUrl(request, `/public/:entity/:id`),
};
return routes;
} else if (isAdminRequest) {
const routes: EntitiesApi.Routes = {
list: adminRoute("entities/:entity/no-code/:entity"),
new: adminRoute("entities/:entity/no-code/:entity/new"),
overview: adminRoute("entities/:entity/no-code/:entity/:id"),
edit: adminRoute("entities/:entity/no-code/:entity/:id/edit"),
import: adminRoute("entities/:entity/no-code/:entity/import"),
export: adminRoute("entities/:entity/no-code/:entity/export"),
publicUrl: getAbsoluteUrl(request, `/public/:entity/:id`),
};
return routes;
} else if (params?.tenant && isPathOrChild(url.pathname, `/${params.tenant}/crm`)) {
const routes: EntitiesApi.Routes = {
list: tenantRoute("crm/:entity"),
new: tenantRoute("crm/:entity/new"),
overview: tenantRoute("crm/:entity/:id"),
edit: tenantRoute("crm/:entity/:id/edit"),
import: tenantRoute("crm/:entity/import"),
export: tenantRoute("crm/:entity/export"),
publicUrl: getAbsoluteUrl(request, `/public/:entity/:id`),
};
return routes;
}
const routes: EntitiesApi.Routes = {
list: tenantRoute(":entity"),
new: tenantRoute(":entity/new"),
overview: tenantRoute(":entity/:id"),
edit: tenantRoute(":entity/:id/edit"),
import: tenantRoute(":entity/import"),
export: tenantRoute(":entity/export"),
publicUrl: getAbsoluteUrl(request, `/public/:entity/:id`),
};
return routes;
}
export async function fillSystemProperties({
entity,
tenantId,
userId,
}: {
entity: EntityWithDetails;
tenantId: string | null | undefined;
userId: string | undefined;
}) {
// const userProp = entity.properties.find((f) => f.name === "user");
// if (userProp) {
// const users = await adminGetAllUsersNames();
// userProp.options = users.map((f) => ({
// value: f.email,
// name: f.email,
// })) as PropertyOption[];
// }
}
}
function isPathOrChild(pathname: string, prefix: string) {
return pathname === prefix || pathname.startsWith(`${prefix}/`);
}
@@ -0,0 +1,19 @@
// @vitest-environment node
import { describe, expect, test } from "vitest";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
const source = readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "EntityViewsApi.ts"), "utf8");
describe("EntityViewsApi tenant scope", () => {
test("validates entity views against the current tenant/user scope", () => {
expect(source).toContain("validateEntityView({ entityId: entity.id, isDefault, name, title, order: null, tenantId, userId })");
expect(source).toContain("tenantId: item.tenantId, userId: item.userId");
expect(source).toContain("const views = await getEntityViews(entityId, { tenantId, userId })");
});
test("loads entity views by id through the current entity and tenant scope", () => {
expect(source).toContain("getEntityView(id, { entityId: entity.id, tenantId })");
});
});
@@ -0,0 +1,358 @@
import { EntityView } from "@toolctrl/db/prisma-browser";
import { EntityWithDetails, getEntityByName } from "../../db/entities/entities.db.server";
import {
createEntityView,
EntityViewWithDetails,
getEntityView,
getEntityViewByName,
getEntityViewDefault,
getEntityViews,
updateEntityViewFilters,
updateEntityViewProperties,
updateEntityViewSort,
updateScopedEntityView,
updateScopedEntityViewFilters,
updateScopedEntityViewProperties,
updateScopedEntityViewSort,
} from "../../db/entities/entityViews.db.server";
import { RowsApi } from "./RowsApi";
import { getUser } from "../../db/users.db.server";
import { getTenant } from "../../db/tenants.db.server";
import { PropertyType } from "~/application/enums/entities/PropertyType";
export namespace EntityViewsApi {
export type GetEntityViewsWithRows = {
view: EntityViewWithDetails | undefined;
rowsData: RowsApi.GetRowsData;
rowsCount: number;
};
export async function getAll({
entityName,
tenantId,
withDefault = true,
withRows = true,
}: {
entityName: string;
tenantId: string | null;
withDefault: boolean;
withRows: boolean;
}): Promise<GetEntityViewsWithRows[]> {
const entity = await getEntityByName({ tenantId, name: entityName });
const views = await getEntityViews(entity.id, { tenantId });
let allViews: (EntityViewWithDetails | undefined)[] = views;
if (withDefault) {
allViews = [undefined, ...views];
}
return await Promise.all(
allViews.map(async (entityView) => {
let rowsData = await RowsApi.getAll({
entity,
tenantId,
entityView,
urlSearchParams: new URLSearchParams(),
pageSize: 10000,
});
const rowsCount = rowsData.items.length;
if (!withRows) {
rowsData = { ...rowsData, items: [] };
}
const data: GetEntityViewsWithRows = {
view: entityView,
rowsData,
rowsCount,
};
return data;
})
);
}
export async function get(
id: string | undefined,
{ entityName, tenantId, pageSize }: { entityName: string; tenantId: string | null; pageSize?: number }
): Promise<GetEntityViewsWithRows | null> {
const entity = await getEntityByName({ tenantId, name: entityName });
if (!entity) {
return null;
}
let view: EntityViewWithDetails | null = null;
if (id) {
view = await getEntityView(id, { entityId: entity.id, tenantId });
if (!view) {
return null;
}
}
const rowsData = await RowsApi.getAll({
entity,
tenantId,
entityView: view ?? undefined,
urlSearchParams: new URLSearchParams(),
pageSize,
});
const data: GetEntityViewsWithRows = {
view: view ?? undefined,
rowsData,
rowsCount: rowsData.items.length,
};
return data;
}
export async function createFromForm({ entity, form, createdByUserId }: { form: FormData; entity: EntityWithDetails; createdByUserId: string }) {
const layout = form.get("layout")?.toString() ?? "";
const name = form.get("name")?.toString().toLowerCase() ?? "";
const title = form.get("title")?.toString() ?? "";
const pageSize = Number(form.get("pageSize"));
const isDefault = Boolean(form.get("isDefault"));
// Board
const groupBy = form.get("groupBy")?.toString() ?? undefined;
const groupByPropertyId = form.get("groupByPropertyId")?.toString() ?? undefined;
// Grid
const gridColumns = Number(form.get("gridColumns") ?? 0);
const gridColumnsSm = Number(form.get("gridColumnsSm") ?? 0);
const gridColumnsMd = Number(form.get("gridColumnsMd") ?? 0);
const gridColumnsLg = Number(form.get("gridColumnsLg") ?? 0);
const gridColumnsXl = Number(form.get("gridColumnsXl") ?? 0);
const gridColumns2xl = Number(form.get("gridColumns2xl") ?? 0);
const gridGap = form.get("gridGap")?.toString() ?? "sm";
const isSystem = form.get("isSystem") === "true";
let tenantId: string | null = form.get("tenantId")?.toString() ?? null;
let userId: string | null = form.get("userId")?.toString() ?? null;
if (!tenantId?.toString().trim()) {
tenantId = null;
}
if (!userId?.toString().trim()) {
userId = null;
}
const errors = [
...(await validateEntityView({ entityId: entity.id, isDefault, name, title, order: null, tenantId, userId })),
...(await validateGroupBy(entity, layout, groupBy, groupByPropertyId)),
];
if (errors.length > 0) {
throw Error(errors.join(", "));
}
const properties: { propertyId: string; name: string; order: number }[] = form.getAll("properties[]").map((f: FormDataEntryValue) => {
return JSON.parse(f.toString());
});
if (properties.length === 0) {
throw Error("Add at least one property to display");
}
const filters: { name: string; condition: string; value: string; match: string }[] = form.getAll("filters[]").map((f: FormDataEntryValue) => {
return JSON.parse(f.toString());
});
const sort: { name: string; asc: boolean; order: number }[] = form.getAll("sort[]").map((f: FormDataEntryValue) => {
return JSON.parse(f.toString());
});
if (tenantId) {
const tenant = await getTenant(tenantId);
if (!tenant) {
// eslint-disable-next-line no-console
console.log("Invalid account", { tenantId });
throw Error("Invalid account");
}
}
if (userId) {
const user = await getUser(userId);
if (!user) {
// eslint-disable-next-line no-console
console.log("Invalid user", { userId });
throw Error("Invalid user");
}
}
try {
const entityView = await createEntityView({
entityId: entity.id,
tenantId: tenantId,
userId: userId,
createdByUserId,
layout,
name,
title,
isDefault,
pageSize,
groupByPropertyId: groupBy === "byProperty" ? groupByPropertyId ?? null : null,
gridColumns,
gridColumnsSm,
gridColumnsMd,
gridColumnsLg,
gridColumnsXl,
gridColumns2xl,
gridGap,
isSystem,
});
await updateEntityViewProperties(entityView.id, properties);
await updateEntityViewFilters(entityView.id, filters);
await updateEntityViewSort(entityView.id, sort);
return entityView;
} catch (e: any) {
throw Error(e.message);
}
}
export async function updateFromForm({ item, entity, form }: { item: EntityView; form: FormData; entity: EntityWithDetails }) {
const layout = form.get("layout")?.toString() ?? "";
const name = form.get("name")?.toString() ?? "";
const title = form.get("title")?.toString() ?? "";
const pageSize = Number(form.get("pageSize"));
const order = Number(form.get("order"));
const isDefault = Boolean(form.get("isDefault"));
// Board
const groupBy = form.get("groupBy")?.toString() ?? undefined;
const groupByPropertyId = form.get("groupByPropertyId")?.toString() ?? undefined;
// Grid
const gridColumns = Number(form.get("gridColumns") ?? 0);
const gridColumnsSm = Number(form.get("gridColumnsSm") ?? 0);
const gridColumnsMd = Number(form.get("gridColumnsMd") ?? 0);
const gridColumnsLg = Number(form.get("gridColumnsLg") ?? 0);
const gridColumnsXl = Number(form.get("gridColumnsXl") ?? 0);
const gridColumns2xl = Number(form.get("gridColumns2xl") ?? 0);
const gridGap = form.get("gridGap")?.toString() ?? "sm";
const errors = [
...(await validateEntityView({ entityId: entity.id, isDefault, name, title, order, entityView: item, tenantId: item.tenantId, userId: item.userId })),
...(await validateGroupBy(entity, layout, groupBy, groupByPropertyId)),
];
if (errors.length > 0) {
throw Error(errors.join(", "));
}
const properties: { propertyId: string; name: string; order: number }[] = form.getAll("properties[]").map((f: FormDataEntryValue) => {
return JSON.parse(f.toString());
});
if (properties.length === 0) {
throw Error("Add at least one property to display");
}
const filters: { name: string; condition: string; value: string; match: string }[] = form.getAll("filters[]").map((f: FormDataEntryValue) => {
return JSON.parse(f.toString());
});
const sort: { name: string; asc: boolean; order: number }[] = form.getAll("sort[]").map((f: FormDataEntryValue) => {
return JSON.parse(f.toString());
});
try {
const viewScope = {
id: item.id,
entityId: entity.id,
tenantId: item.tenantId,
userId: item.userId,
};
const view = await updateScopedEntityView(viewScope, {
order,
layout,
name,
title,
isDefault,
pageSize,
groupByPropertyId: groupBy === "byProperty" ? groupByPropertyId ?? null : null,
gridColumns,
gridColumnsSm,
gridColumnsMd,
gridColumnsLg,
gridColumnsXl,
gridColumns2xl,
gridGap,
});
await updateScopedEntityViewProperties(viewScope, properties);
await updateScopedEntityViewFilters(viewScope, filters);
await updateScopedEntityViewSort(viewScope, sort);
return view;
} catch (e: any) {
throw Error(e.message);
}
}
export const getCurrentEntityView = async (
entityId: string,
urlParams: URLSearchParams,
{ tenantId, userId }: { tenantId?: string | null; userId?: string | null } = {}
) => {
const name = urlParams.get("v");
if (name) {
return await getEntityViewByName({ entityId, name, isSystem: false, tenantId, userId });
} else {
return await getEntityViewDefault({ entityId, isSystem: false, tenantId, userId });
}
};
const validateEntityView = async ({
entityId,
isDefault,
name,
title,
order,
entityView,
tenantId,
userId,
}: {
entityId: string;
isDefault: boolean;
name: string;
title: string;
order: number | null;
entityView?: EntityView;
tenantId: string | null;
userId: string | null;
}) => {
const errors: string[] = [];
const views = await getEntityViews(entityId, { tenantId, userId });
if (isDefault && userId) {
const defaultView = views.find((f) => f.id !== entityView?.id && f.isDefault && f.userId === userId);
if (defaultView) {
errors.push(`Existing default entity view with title: ${defaultView.title}`);
}
}
if (!entityView || entityView?.name !== name) {
const existingName = views.find((f) => f.id !== entityView?.id && f.name.toLowerCase() === name.toLowerCase());
if (existingName) {
errors.push(`Existing entity view with name: ${existingName.name}`);
}
}
if (!entityView || entityView?.title !== title) {
const existingTitle = views.find((f) => f.id !== entityView?.id && f.title.toLowerCase() === title.toLowerCase());
if (existingTitle) {
errors.push(`Existing entity view with title: ${existingTitle.title}`);
}
}
// if (order) {
// if (!entityView || entityView?.order !== order) {
// const existingOrder = await getEntityViewByOrder(entityId, order);
// if (existingOrder) {
// errors.push(`Existing entity view with order '${order}': ${existingOrder.title}`);
// }
// }
// }
return errors;
};
const validateGroupBy = async (entity: EntityWithDetails, layout: string, groupBy: string | undefined, groupByPropertyId: string | undefined) => {
if (layout !== "board") {
return [];
}
const errors: string[] = [];
if (groupBy === "byProperty") {
const property = entity.properties.find((p) => p.id === groupByPropertyId);
if (!property || property.type !== PropertyType.SELECT) {
errors.push(`Cannot save a Board view without a SELECT property`);
}
}
return errors;
};
}
@@ -0,0 +1,128 @@
import { createProperty, updatePropertyAttributes, updatePropertyOptions } from "../../db/entities/properties.db.server";
import { PropertyType } from "~/application/enums/entities/PropertyType";
import { Colors } from "~/application/enums/shared/Colors";
import { EntityWithDetails } from "../../db/entities/entities.db.server";
export namespace PropertiesApi {
export async function create({
entityId,
name,
title,
type,
subtype,
order,
isDefault,
isRequired,
isHidden,
isDisplay,
isReadOnly,
canUpdate,
showInCreate,
formulaId,
options,
attributes,
tenantId,
}: {
entityId: string;
name: string;
title: string;
type: PropertyType;
subtype?: string | null;
order?: number;
isDefault?: boolean;
isRequired?: boolean;
isHidden?: boolean;
isDisplay?: boolean;
isReadOnly?: boolean;
canUpdate?: boolean;
showInCreate?: boolean;
formulaId?: string | null;
options?: { order: number; value: string; name?: string | null; color?: Colors }[];
attributes?: { name: string; value: string }[];
tenantId: string | null;
}) {
if (!order) {
order = 0;
}
const property = await createProperty({
entityId,
name,
title,
type,
subtype: subtype ?? null,
order,
isDefault: isDefault ?? false,
isRequired: isRequired ?? false,
isHidden: isHidden ?? false,
isDisplay: isDisplay ?? false,
isReadOnly: isReadOnly ?? false,
canUpdate: canUpdate ?? true,
showInCreate: showInCreate ?? true,
formulaId: formulaId ?? null,
tenantId,
});
if (options !== undefined) {
await updatePropertyOptions(property.id, options);
}
if (attributes !== undefined) {
await updatePropertyAttributes(property.id, attributes);
}
return property;
}
export async function duplicate({ entity, propertyId }: { entity: EntityWithDetails; propertyId: string }) {
const existingProperty = entity.properties.find((p) => p.id === propertyId);
if (!existingProperty) {
throw Error("Property not found");
}
let number = 2;
let name = "";
const findExistingProperty = (name: string) => {
return entity.properties.find((p) => p.name === name);
};
do {
name = `${existingProperty.name}${number}`;
const existingPropertyWithNewName = findExistingProperty(name);
if (!existingPropertyWithNewName) {
break;
}
if (number > 10) {
throw Error("Too many duplicates");
}
number++;
} while (true);
let maxOrder = 0;
if (entity.properties.length > 0) {
maxOrder = Math.max(...entity.properties.map((p) => p.order));
}
return await create({
entityId: entity.id,
name,
title: existingProperty.title + " " + number,
type: existingProperty.type,
subtype: existingProperty.subtype,
order: maxOrder + 1,
isDefault: existingProperty.isDefault,
isRequired: existingProperty.isRequired,
isHidden: existingProperty.isHidden,
isDisplay: existingProperty.isDisplay,
isReadOnly: existingProperty.isReadOnly,
showInCreate: existingProperty.showInCreate,
formulaId: existingProperty.formulaId,
options: existingProperty.options.map((o) => ({
order: o.order,
value: o.value,
name: o.name,
color: o.color,
})),
attributes: existingProperty.attributes.map((a) => ({
name: a.name,
value: a.value,
})),
tenantId: existingProperty.tenantId,
});
}
}
@@ -0,0 +1,40 @@
import { getEntityByIdOrName } from "~/utils/db/entities/entities.db.server";
import { getRowById } from "~/utils/db/entities/rows.db.server";
import { createManualRowLog } from "../../db/logs.db.server";
import { DefaultLogActions } from "~/application/dtos/shared/DefaultLogActions";
import { createRowComment } from "../../db/entities/rowComments.db.server";
export namespace RowCommentsApi {
export async function create(
id: string,
{
comment,
tenantId,
userId,
}: {
comment: string;
tenantId: string | null;
userId: string;
}
) {
const item = await getRowById(id, { tenantId });
const entity = await getEntityByIdOrName({ tenantId, id: item?.entityId });
if (!item || !entity) {
throw new Error("Row not found");
}
const created = await createRowComment({
createdByUserId: userId,
rowId: id,
value: comment,
});
await createManualRowLog({
tenantId: item.tenantId,
createdByUserId: userId,
action: DefaultLogActions.Commented,
entity,
item,
commentId: created.id,
});
return created;
}
}
@@ -0,0 +1,74 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { RowPermissionsApi } from "./RowPermissionsApi";
import { getGroup } from "~/utils/db/permissions/groups.db.server";
import { getRole } from "~/utils/db/permissions/roles.db.server";
import { getTenant, getTenantUserByIds } from "~/utils/db/tenants.db.server";
vi.mock("~/utils/db/permissions/rowPermissions.db.server", () => ({
createRowPermission: vi.fn(),
deleteRowPermissionByIdForRow: vi.fn(),
getRowPermissions: vi.fn(async () => []),
updateRowPermissionForRow: vi.fn(),
}));
vi.mock("~/utils/db/permissions/groups.db.server", () => ({
getGroup: vi.fn(),
}));
vi.mock("~/utils/db/permissions/roles.db.server", () => ({
getRole: vi.fn(),
}));
vi.mock("~/utils/db/tenants.db.server", () => ({
getTenant: vi.fn(),
getTenantUserByIds: vi.fn(),
}));
describe("RowPermissionsApi.validateShareTarget", () => {
beforeEach(() => {
vi.mocked(getGroup).mockReset();
vi.mocked(getRole).mockReset();
vi.mocked(getTenant).mockReset();
vi.mocked(getTenantUserByIds).mockReset();
});
test("rejects tenant share targets outside the current tenant", async () => {
await expect(
RowPermissionsApi.validateShareTarget({ type: "tenant", id: "tenant_b", access: "view" }, { currentTenantId: "tenant_a" })
).rejects.toThrow("Invalid permission target");
});
test("rejects invalid access values", async () => {
await expect(
RowPermissionsApi.validateShareTarget({ type: "public", access: "owner" as never })
).rejects.toThrow("Invalid permission access");
});
test("rejects user share targets that are not members of the current tenant", async () => {
vi.mocked(getTenantUserByIds).mockResolvedValue(null);
await expect(
RowPermissionsApi.validateShareTarget({ type: "user", id: "user_b", access: "view" }, { currentTenantId: "tenant_a" })
).rejects.toThrow("Invalid permission target");
expect(getTenantUserByIds).toHaveBeenCalledWith("tenant_a", "user_b");
});
test("rejects group share targets outside the current tenant", async () => {
vi.mocked(getGroup).mockResolvedValue(null);
await expect(
RowPermissionsApi.validateShareTarget({ type: "group", id: "group_b", access: "view" }, { currentTenantId: "tenant_a" })
).rejects.toThrow("Invalid permission target");
expect(getGroup).toHaveBeenCalledWith("group_b", "tenant_a");
});
test("rejects admin roles as tenant share targets", async () => {
vi.mocked(getRole).mockResolvedValue({ id: "role_admin", type: "admin" } as Awaited<ReturnType<typeof getRole>>);
await expect(
RowPermissionsApi.validateShareTarget({ type: "role", id: "role_admin", access: "view" }, { currentTenantId: "tenant_a" })
).rejects.toThrow("Invalid permission target");
});
});
@@ -0,0 +1,158 @@
import { RowAccess, RowAccessTypes } from "~/application/enums/entities/RowAccess";
import {
createRowPermission,
deleteRowPermissionByIdForRow,
getRowPermissions,
updateRowPermissionForRow,
} from "../../db/permissions/rowPermissions.db.server";
import { DefaultVisibility } from "~/application/dtos/shared/DefaultVisibility";
import { Row } from "@toolctrl/db/prisma-browser";
import { getGroup } from "~/utils/db/permissions/groups.db.server";
import { getRole } from "~/utils/db/permissions/roles.db.server";
import { getTenant, getTenantUserByIds } from "~/utils/db/tenants.db.server";
export namespace RowPermissionsApi {
type ShareData = { type: "tenant" | "user" | "role" | "group" | "public"; id?: string; access: RowAccess };
type ShareScope = { currentTenantId?: string | null };
export async function shareWithDefault(row: Row, defaultVisibility: DefaultVisibility) {
if (defaultVisibility === "tenant" && row.tenantId) {
await shareWithTenant(row, row.tenantId, "edit");
}
if (defaultVisibility === "public") {
return await shareWithPublic(row, "view");
}
}
export async function share(row: Row, data: ShareData, scope: ShareScope = {}) {
await validateShareTarget(data, scope);
const permission = {
tenantId: data.type === "tenant" ? data.id : null,
roleId: data.type === "role" ? data.id : null,
groupId: data.type === "group" ? data.id : null,
userId: data.type === "user" ? data.id : null,
public: data.type === "public",
access: data.access,
};
if (data.type !== "public" && !permission.tenantId && !permission.roleId && !permission.groupId && !permission.userId) {
throw Error("Invalid permission");
}
const permissions = await getRowPermissions(row.id);
const existing = permissions.find(
(f) =>
f.tenantId === permission.tenantId &&
f.roleId === permission.roleId &&
f.groupId === permission.groupId &&
f.userId === permission.userId &&
f.public === permission.public
);
if (data.type === "user" && data.id === row?.createdByUserId) {
return;
} else if (existing) {
return existing;
}
const create = await createRowPermission({
rowId: row.id,
...permission,
});
return create;
}
export async function validateShareTarget(data: ShareData, scope: ShareScope = {}) {
validateAccess(data.access);
if (data.type !== "tenant" && data.type !== "user" && data.type !== "role" && data.type !== "group" && data.type !== "public") {
throw Error("Invalid permission target");
}
if (data.type !== "public" && data.id === "public") {
throw Error("Invalid permission target");
}
if (data.type === "public" && data.id) {
throw Error("Invalid permission target");
}
if (data.type === "public") {
return;
}
if (!data.id) {
throw Error("Invalid permission target");
}
const { currentTenantId } = scope;
if (data.type === "tenant") {
if (currentTenantId && data.id !== currentTenantId) {
throw Error("Invalid permission target");
}
if (currentTenantId === null && !(await getTenant(data.id))) {
throw Error("Invalid permission target");
}
return;
}
if (data.type === "user") {
if (currentTenantId && !(await getTenantUserByIds(currentTenantId, data.id))) {
throw Error("Invalid permission target");
}
return;
}
if (data.type === "group") {
if (!(await getGroup(data.id, currentTenantId))) {
throw Error("Invalid permission target");
}
return;
}
const role = await getRole(data.id);
if (!role || (currentTenantId && role.type !== "app")) {
throw Error("Invalid permission target");
}
}
export function validateAccess(access: RowAccess) {
if (!RowAccessTypes.includes(access)) {
throw Error("Invalid permission access");
}
}
export async function shareWithTenant(row: Row, tenantId: string, access: RowAccess) {
return await share(row, {
type: "tenant",
id: tenantId,
access,
}, { currentTenantId: tenantId });
}
export async function shareWithUser(row: Row, userId: string, access: RowAccess) {
return await share(row, {
type: "user",
id: userId,
access,
});
}
export async function shareWithRole(row: Row, roleId: string, access: RowAccess) {
return await share(row, {
type: "role",
id: roleId,
access,
});
}
export async function shareWithGroup(row: Row, groupId: string, access: RowAccess) {
return await share(row, {
type: "group",
id: groupId,
access,
});
}
export async function shareWithPublic(row: Row, access: RowAccess) {
return await share(row, {
type: "public",
access,
});
}
export async function setAccessForRow(rowId: string, id: string, access: RowAccess) {
validateAccess(access);
const updated = await updateRowPermissionForRow(rowId, id, { access });
if (updated.count !== 1) {
throw Error("Permission not found");
}
return updated;
}
export async function delForRow(rowId: string, id: string) {
const deleted = await deleteRowPermissionByIdForRow(rowId, id);
if (deleted.count !== 1) {
throw Error("Permission not found");
}
return deleted;
}
}
@@ -0,0 +1,231 @@
import {
createLegacyRowRelationship,
deleteLegacyRowRelationship,
deleteLegacyRowRelationshipById,
findLegacyEntityRelationship,
findLegacyRowRelationshipById,
} from "@toolctrl/db/relationships.server";
import { TimeFunction, timeFake } from "~/modules/metrics/services/.server/MetricTracker";
import { RowsApi } from "./RowsApi";
import { EntityWithDetails, EntityWithDetailsAndRelationships, getEntityById, getEntityByName } from "../../db/entities/entities.db.server";
export namespace RowRelationshipsApi {
export async function getRelationship(
id: string,
{
tenantId,
userId,
time,
}: {
userId: string | undefined;
tenantId: string | null;
time?: TimeFunction;
}
) {
if (!time) {
time = timeFake;
}
const relationship = await time(findLegacyRowRelationshipById(id, tenantId), "RowRelationshipsApi.getRelationship");
if (!relationship) {
return null;
}
const parent = await RowsApi.get(relationship.parentId, {
entity: {
id: relationship.relationship.parentId,
},
tenantId,
userId,
});
const child = await RowsApi.get(relationship.childId, {
entity: {
id: relationship.relationship.childId,
},
tenantId,
userId,
});
return {
...relationship,
parent,
child,
};
}
export async function createRelationship({
parent,
child,
time,
allEntities,
metadata,
tenantId,
}: {
parent: { id: string; entityId: string } | { id: string; entityName: string };
child: { id: string; entityId: string } | { id: string; entityName: string };
tenantId?: string | null;
metadata?: string | null;
time?: TimeFunction;
allEntities?: EntityWithDetails[];
}) {
if (!time) {
time = timeFake;
}
const { relationship } = await getParentChildEntities({ parent, child, allEntities });
return await time(
createLegacyRowRelationship({
relationshipId: relationship.id,
parentId: parent.id,
childId: child.id,
tenantId,
metadata: metadata || null,
}),
"RowRelationshipsApi.createRelationship.createRowRelationship"
);
}
export async function createRelationshipWithIds({
parentEntityId,
childEntityId,
parentId,
childId,
metadata,
tenantId,
time,
allEntities,
}: {
parentEntityId: string;
childEntityId: string;
parentId: string;
childId: string;
tenantId?: string | null;
metadata: string | null;
time?: TimeFunction;
allEntities?: EntityWithDetails[];
}) {
if (!time) {
time = timeFake;
}
const { relationship } = await getParentChildEntities({
parent: { id: parentId, entityId: parentEntityId },
child: { id: childId, entityId: childEntityId },
allEntities,
});
return await time(
createLegacyRowRelationship({
relationshipId: relationship.id,
parentId,
childId,
tenantId,
metadata,
}),
"RowRelationshipsApi.createRelationship.createRowRelationship"
);
}
export async function deleteRelationship({
parent,
child,
time,
allEntities,
tenantId,
}: {
parent: { id: string; entityId: string } | { id: string; entityName: string };
child: { id: string; entityId: string } | { id: string; entityName: string };
tenantId?: string | null;
time?: TimeFunction;
allEntities?: EntityWithDetails[];
}) {
if (!time) {
time = timeFake;
}
const { parentEntity, childEntity } = await getParentChildEntities({ parent, child, allEntities });
const relationship = await time(
findLegacyEntityRelationship({
parentId: parentEntity.id,
childId: childEntity.id,
}),
"RowRelationshipsApi.deleteRelationship.getEntitiesRelationship"
);
if (!relationship) {
throw Error(`No relationship found between ${parentEntity.name} and ${childEntity.name}`);
}
return await time(
deleteLegacyRowRelationship({
relationshipId: relationship.id,
parentId: parent.id,
childId: child.id,
tenantId,
}),
"RowRelationshipsApi.deleteRelationship.deleteRowRelationship"
);
}
async function getParentChildEntities({
parent,
child,
allEntities,
}: {
parent: { id: string; entityId: string } | { id: string; entityName: string };
child: { id: string; entityId: string } | { id: string; entityName: string };
allEntities?: EntityWithDetails[];
}) {
let parentEntity: EntityWithDetailsAndRelationships | null = null;
let childEntity: EntityWithDetailsAndRelationships | null = null;
if ("entityName" in parent) {
parentEntity = allEntities
? allEntities.find((e) => e.name === parent.entityName) || null
: await getEntityByName({ tenantId: undefined, name: parent.entityName });
} else {
parentEntity = allEntities
? allEntities.find((e) => e.id === parent.entityId) || null
: await getEntityById({ tenantId: undefined, id: parent.entityId });
}
if ("entityName" in child) {
childEntity = allEntities
? allEntities.find((e) => e.name === child.entityName) || null
: await getEntityByName({ tenantId: undefined, name: child.entityName });
} else {
childEntity = allEntities ? allEntities.find((e) => e.id === child.entityId) || null : await getEntityById({ tenantId: undefined, id: child.entityId });
}
if (!parentEntity) {
throw Error(`Invalid parent entity ${JSON.stringify(parent)}`);
}
if (!childEntity) {
throw Error(`Invalid child entity ${JSON.stringify(child)}`);
}
const relationship = parentEntity.childEntities.find((e) => e.childId === childEntity!.id);
if (!relationship) {
throw Error(`No relationship found between ${parentEntity?.name} and ${childEntity?.name}`);
}
return {
parentEntity,
childEntity,
relationship,
};
}
export async function deleteRelationshipById(
id: string,
{
tenantId,
userId,
time,
}: {
tenantId: string | null;
userId: string | undefined;
time?: TimeFunction;
}
) {
if (!time) {
time = timeFake;
}
const relationship = await getRelationship(id, { tenantId, userId, time });
if (!relationship) {
throw Error(`No relationship found with id ${id}`);
}
return await time(
deleteLegacyRowRelationshipById({
id: relationship.id,
relationshipId: relationship.relationshipId,
parentId: relationship.parentId,
childId: relationship.childId,
tenantId,
}),
"RowRelationshipsApi.deleteRelationship.deleteRowRelationship",
);
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,380 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
const tx = {
event: {
create: vi.fn(),
},
};
return {
tx,
db: {
$transaction: vi.fn(async (runner: (tx: unknown) => Promise<unknown>) => runner(tx)),
},
rows: {
buildLegacyRowPropertyFiltersWhere: vi.fn(() => ({})),
buildLegacyRowQueryWhere: vi.fn(() => ({})),
changeLegacyRowOrder: vi.fn(),
countLegacyRows: vi.fn(),
createRowWithValues: vi.fn(),
hardDeleteRowById: vi.fn(),
getLegacyRow: vi.fn(),
getLegacyRowById: vi.fn(),
listLegacyRows: vi.fn(),
updateRowWithValuesInTransaction: vi.fn(),
},
entities: {
getEntityByIdOrName: vi.fn(),
},
entityViews: {
getEntityViews: vi.fn(),
},
entityTags: {
createEntityTag: vi.fn(),
getEntityTag: vi.fn(),
getEntityTags: vi.fn(),
},
permissions: {
getEntityPermission: vi.fn((entity: { name: string }, name: string) => `${entity.name}.${name}`),
getRowPermissionsCondition: vi.fn(() => ({})),
getUserPermission: vi.fn(async () => ({ permission: { id: "permission_1" }, userPermission: { id: "user_permission_1" } })),
getUserRowPermission: vi.fn(async () => ({ canRead: true, canUpdate: true, canDelete: true })),
getRowPermissions: vi.fn(),
getUserRoleInTenant: vi.fn(),
},
pagination: {
getPaginationFromCurrentUrl: vi.fn(() => ({ page: 1, pageSize: 25, sortedBy: [] })),
getEntityFiltersFromCurrentUrl: vi.fn(() => ({ query: "" })),
},
rowFiltersHelper: {
getRowFiltersCondition: vi.fn(() => ({})),
},
logs: {
createManualRowLog: vi.fn(),
getRowLogs: vi.fn(),
},
rowComments: {
getRowComments: vi.fn(),
},
rowTasks: {
getAllRowTasks: vi.fn(),
getRowTasks: vi.fn(),
},
rowTags: {
createRowTag: vi.fn(),
deleteRowTags: vi.fn(),
getRowTagByIds: vi.fn(),
getRowTags: vi.fn(),
},
subscription: {
getPlanFeatureUsage: vi.fn(),
reportUsage: vi.fn(),
},
rowHelper: {
getTextDescription: vi.fn(() => "Contact row"),
getDiff: vi.fn(() => ({ firstName: { before: "Ada", after: "Grace" } })),
},
relationshipHelper: {
getInputType: vi.fn(),
},
media: {
storeRowMediaInStorageProvider: vi.fn(),
},
rowPermissionsApi: {
shareWithDefault: vi.fn(),
},
rowEventsHelper: {
getRowUserOrApiKey: vi.fn(async () => ({ user: null, apiKey: null })),
},
events: {
getDescription: vi.fn((event: string) => `${event} description`),
publishOutboxEvent: vi.fn(),
},
apiHelper: {
getApiFormat: vi.fn((entity: { name: string }, row: { id: string }) => ({ id: row.id, entity: entity.name })),
},
entityViewsApi: {
getCurrentEntityView: vi.fn(),
},
entitiesApi: {
fillSystemProperties: vi.fn(),
},
pluginHooks: {
emitPluginHookFireAndForget: vi.fn(),
},
rowSearchOutbox: {
emitRowSearchDelete: vi.fn(),
emitRowSearchUpsert: vi.fn(),
},
rowSearch: {
searchRows: vi.fn(),
},
crmContactSearch: {
searchCrmContacts: vi.fn(),
},
notifications: {
sendToRoles: vi.fn(),
},
eventWebhookAttempts: {
createEventWebhookAttempt: vi.fn(),
updateEventWebhookAttempt: vi.fn(),
},
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
};
});
vi.mock("@toolctrl/db/client.server", () => ({
getDbClient: () => mocks.db,
}));
vi.mock("~/utils/db.server", () => ({
db: mocks.db,
}));
vi.mock("@toolctrl/db/rows.server", () => mocks.rows);
vi.mock("~/utils/db/entities/entities.db.server", () => mocks.entities);
vi.mock("~/utils/db/entities/entityViews.db.server", () => mocks.entityViews);
vi.mock("../../db/entities/entityTags.db.server", () => mocks.entityTags);
vi.mock("../../helpers/PermissionsHelper", () => mocks.permissions);
vi.mock("../../helpers/.server/PermissionsService", () => mocks.permissions);
vi.mock("../../helpers/RowPaginationHelper", () => mocks.pagination);
vi.mock("~/utils/helpers/RowFiltersHelper", () => ({ default: mocks.rowFiltersHelper }));
vi.mock("~/application/Constants", () => ({ default: { DEFAULT_PAGE_SIZE: 25 } }));
vi.mock("../../db/logs.db.server", () => mocks.logs);
vi.mock("../../db/entities/rowComments.db.server", () => mocks.rowComments);
vi.mock("../../db/entities/rowTasks.db.server", () => mocks.rowTasks);
vi.mock("../../db/entities/rowTags.db.server", () => mocks.rowTags);
vi.mock("../../db/permissions/rowPermissions.db.server", () => mocks.permissions);
vi.mock("../../services/.server/subscriptionService", () => mocks.subscription);
vi.mock("i18next", () => ({ t: (key: string) => key }));
vi.mock("../../helpers/RowHelper", () => ({ default: mocks.rowHelper }));
vi.mock("../../helpers/RelationshipHelper", () => ({ default: mocks.relationshipHelper }));
vi.mock("~/utils/helpers/.server/MediaService", () => mocks.media);
vi.mock("./RowPermissionsApi", () => ({ RowPermissionsApi: mocks.rowPermissionsApi }));
vi.mock("../../helpers/RowEventsHelper", () => mocks.rowEventsHelper);
vi.mock("@toolctrl/db/outbox", () => ({ publishOutboxEvent: mocks.events.publishOutboxEvent }));
vi.mock("../../utils/EventUtils", () => ({ default: mocks.events }));
vi.mock("../../db/eventWebhookAttempts.db.server", () => mocks.eventWebhookAttempts);
vi.mock("~/modules/notifications/services/.server/NotificationService", () => ({ default: mocks.notifications }));
vi.mock("~/utils/url.server", () => ({
getAbsoluteApiUrl: vi.fn(() => "https://api.example.test/v1/events/webhooks/attempts/attempt_1"),
getBaseURL: vi.fn(() => "https://app.example.test"),
}));
vi.mock("~/utils/app/CanonicalUrlUtils", () => ({
adminPath: vi.fn((path: string) => `/admin/${path}`),
appPath: vi.fn(({ path }: { path: string }) => `/app/${path}`),
}));
vi.mock("~/utils/logger.server", () => ({
createScopedLogger: vi.fn(() => mocks.logger),
}));
vi.mock("../../helpers/ApiHelper", () => ({ default: mocks.apiHelper }));
vi.mock("./EntityViewsApi", () => ({ EntityViewsApi: mocks.entityViewsApi }));
vi.mock("./EntitiesApi", () => ({ EntitiesApi: mocks.entitiesApi }));
vi.mock("~/utils/db/permissions/userRoles.db.server", () => mocks.permissions);
vi.mock("~/modules/plugins/services/.server/pluginHookPublisher", () => mocks.pluginHooks);
vi.mock("@toolctrl/plugin-runtime", () => ({
PLUGIN_HOOK_ENTITY_CREATED: "entity.created",
PLUGIN_HOOK_ENTITY_DELETED: "entity.deleted",
PLUGIN_HOOK_ENTITY_UPDATED: "entity.updated",
}));
vi.mock("~/modules/rows/services/rowSearchOutbox.server", () => mocks.rowSearchOutbox);
vi.mock("~/modules/rows/services/rowSearch.server", () => mocks.rowSearch);
vi.mock("~/modules/crm/services/crmContactSearch.server", () => mocks.crmContactSearch);
import { RowApiError, RowsApi } from "./RowsApi";
const entity = {
id: "entity_contact",
name: "contact",
slug: "contacts",
title: "Contact",
defaultVisibility: "private",
properties: [],
parentEntities: [],
childEntities: [],
};
function row(id: string, patch: Record<string, unknown> = {}) {
return {
id,
entityId: entity.id,
tenantId: "tenant_1",
createdByUserId: "user_1",
createdByApiKeyId: null,
values: [],
parentRows: [],
childRows: [],
createdAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
...patch,
};
}
describe("RowsApi workflow row events", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.db.$transaction.mockImplementation(async (runner: (tx: unknown) => Promise<unknown>) => runner(mocks.tx));
mocks.tx.event.create.mockImplementation(async ({ data }: { data: Record<string, unknown> }) => ({
id: `event_${mocks.tx.event.create.mock.calls.length}`,
...data,
}));
mocks.entities.getEntityByIdOrName.mockResolvedValue(entity);
mocks.subscription.getPlanFeatureUsage.mockResolvedValue(null);
mocks.permissions.getUserPermission.mockResolvedValue({ permission: { id: "permission_1" }, userPermission: { id: "user_permission_1" } });
mocks.permissions.getUserRowPermission.mockResolvedValue({ canRead: true, canUpdate: true, canDelete: true });
mocks.entityViews.getEntityViews.mockResolvedValue([]);
mocks.entityViewsApi.getCurrentEntityView.mockResolvedValue(null);
mocks.entityTags.getEntityTags.mockResolvedValue([]);
mocks.rows.listLegacyRows.mockResolvedValue([]);
mocks.rows.countLegacyRows.mockResolvedValue(0);
});
it("publishes tenant-scoped workflow outbox events for row create, update, and delete", async () => {
const created = row("row_created");
const beforeUpdate = row("row_updated", { values: [{ id: "value_before" }] });
const afterUpdate = row("row_updated", { values: [{ id: "value_after" }] });
const deleted = row("row_deleted");
const rowsById = new Map([
[created.id, created],
[beforeUpdate.id, beforeUpdate],
[deleted.id, deleted],
]);
const request = new Request("https://app.example.test/app/acme/contacts");
mocks.rows.createRowWithValues.mockResolvedValue({ id: created.id });
mocks.rows.getLegacyRowById.mockImplementation(async (id: string) => rowsById.get(id) ?? null);
mocks.rows.updateRowWithValuesInTransaction.mockResolvedValue(afterUpdate);
mocks.rows.hardDeleteRowById.mockResolvedValue(deleted);
await RowsApi.create({ entity, tenantId: "tenant_1", userId: "user_1", request, rowValues: { dynamicProperties: [] } });
await RowsApi.update(beforeUpdate.id, { entity, tenantId: "tenant_1", userId: "user_1", request, rowValues: { dynamicProperties: [] } });
await RowsApi.del(deleted.id, { entity, tenantId: "tenant_1", userId: "user_1", request });
expect(mocks.events.publishOutboxEvent).toHaveBeenCalledTimes(3);
expect(mocks.events.publishOutboxEvent).toHaveBeenNthCalledWith(1, mocks.tx, {
tenantId: "tenant_1",
type: "workflow.trigger.event",
payload: expect.objectContaining({ event: "row.created", userId: "user_1" }),
dispatchKey: "workflow-trigger:event_1",
});
expect(mocks.events.publishOutboxEvent).toHaveBeenNthCalledWith(2, mocks.tx, {
tenantId: "tenant_1",
type: "workflow.trigger.event",
payload: expect.objectContaining({ event: "row.updated", userId: "user_1" }),
dispatchKey: "workflow-trigger:event_2",
});
expect(mocks.events.publishOutboxEvent).toHaveBeenNthCalledWith(3, mocks.tx, {
tenantId: "tenant_1",
type: "workflow.trigger.event",
payload: expect.objectContaining({ event: "row.deleted", userId: "user_1" }),
dispatchKey: "workflow-trigger:event_3",
});
expect(mocks.tx.event.create).toHaveBeenCalledTimes(3);
expect(mocks.tx.event.create).toHaveBeenNthCalledWith(1, expect.objectContaining({ data: expect.objectContaining({ name: "row.created", tenantId: "tenant_1", userId: "user_1" }) }));
expect(mocks.tx.event.create).toHaveBeenNthCalledWith(2, expect.objectContaining({ data: expect.objectContaining({ name: "row.updated", tenantId: "tenant_1", userId: "user_1" }) }));
expect(mocks.tx.event.create).toHaveBeenNthCalledWith(3, expect.objectContaining({ data: expect.objectContaining({ name: "row.deleted", tenantId: "tenant_1", userId: "user_1" }) }));
expect(mocks.notifications.sendToRoles).toHaveBeenCalledTimes(3);
});
it("does not publish duplicate workflow outbox events when row event creation is disabled", async () => {
const deleted = row("row_parent");
mocks.rows.getLegacyRowById.mockResolvedValue(deleted);
mocks.rows.hardDeleteRowById.mockResolvedValue(deleted);
await RowsApi.del(deleted.id, {
entity,
tenantId: "tenant_1",
userId: "user_1",
request: new Request("https://app.example.test/app/acme/contacts"),
options: { createEvent: false },
});
expect(mocks.tx.event.create).not.toHaveBeenCalled();
expect(mocks.events.publishOutboxEvent).not.toHaveBeenCalled();
expect(mocks.notifications.sendToRoles).not.toHaveBeenCalled();
expect(mocks.rowSearchOutbox.emitRowSearchDelete).toHaveBeenCalledWith({ tenantId: "tenant_1", entity: { name: "contact" }, rowId: deleted.id });
});
});
describe("RowsApi row error codes", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.entities.getEntityByIdOrName.mockResolvedValue(entity);
});
it("throws a stable code for denied row reads", async () => {
mocks.rows.getLegacyRow.mockResolvedValue(row("row_private"));
mocks.permissions.getUserRowPermission.mockResolvedValue({ canRead: false, canUpdate: false, canDelete: false });
try {
await RowsApi.get("row_private", { entity, tenantId: "tenant_1", userId: "user_2" });
throw new Error("Expected RowsApi.get to reject");
} catch (error) {
expect(error).toBeInstanceOf(RowApiError);
expect(error).toMatchObject({ code: "row-access-denied", status: 403 });
}
});
});
describe("RowsApi CRM contact list search", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.entities.getEntityByIdOrName.mockResolvedValue(entity);
mocks.permissions.getUserPermission.mockResolvedValue({ permission: { id: "permission_1" }, userPermission: { id: "user_permission_1" } });
mocks.entityViews.getEntityViews.mockResolvedValue([]);
mocks.entityViewsApi.getCurrentEntityView.mockResolvedValue(null);
mocks.pagination.getPaginationFromCurrentUrl.mockReturnValue({ page: 1, pageSize: 25, sortedBy: [] });
mocks.pagination.getEntityFiltersFromCurrentUrl.mockReturnValue({ query: "ada" });
mocks.permissions.getRowPermissionsCondition.mockResolvedValue({});
mocks.rowFiltersHelper.getRowFiltersCondition.mockReturnValue({});
mocks.rows.buildLegacyRowQueryWhere.mockReturnValue({ id: { in: ["contact_row_1"] } });
mocks.rows.listLegacyRows.mockResolvedValue([]);
mocks.rows.countLegacyRows.mockResolvedValue(0);
mocks.entityTags.getEntityTags.mockResolvedValue([]);
mocks.crmContactSearch.searchCrmContacts.mockResolvedValue({
hits: [{ id: "contact_row_1", firstName: "Ada", lastName: "Lovelace", email: "ada@example.test", company: "", jobTitle: "" }],
total: 1,
fellBack: false,
});
mocks.rowSearch.searchRows.mockResolvedValue({ hits: [{ id: "row_1", folio: 1, entityName: "company" }], total: 1, fellBack: false });
});
it("routes contact-list freetext through searchCrmContacts and keeps the legacy SQL hydration path", async () => {
await RowsApi.getAll({
entity,
tenantId: "tenant_1",
userId: "user_1",
urlSearchParams: new URLSearchParams("q=ada"),
});
expect(mocks.crmContactSearch.searchCrmContacts).toHaveBeenCalledWith({ tenantId: "tenant_1", query: "ada", limit: 1000 });
expect(mocks.rowSearch.searchRows).not.toHaveBeenCalled();
expect(mocks.rows.buildLegacyRowQueryWhere).toHaveBeenCalledWith(
expect.objectContaining({
rowWhere: { id: { in: ["contact_row_1"] } },
})
);
expect(mocks.rows.listLegacyRows).toHaveBeenCalled();
expect(mocks.rows.countLegacyRows).toHaveBeenCalled();
});
it("keeps non-contact freetext on the generic rows search helper", async () => {
const companyEntity = { ...entity, id: "entity_company", name: "company", slug: "companies", title: "Company" };
mocks.entities.getEntityByIdOrName.mockResolvedValue(companyEntity);
await RowsApi.getAll({
entity: companyEntity,
tenantId: "tenant_1",
userId: "user_1",
urlSearchParams: new URLSearchParams("q=ada"),
});
expect(mocks.rowSearch.searchRows).toHaveBeenCalledWith({ tenantId: "tenant_1", entityId: "entity_company", query: "ada", limit: 1000 });
expect(mocks.crmContactSearch.searchCrmContacts).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,46 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const rowsApiPath = path.resolve(process.cwd(), "apps/web/app/utils/api/.server/RowsApi.ts");
describe("RowsApi workflow outbox transaction boundary", () => {
it("creates rows, reloads rows, and records row.created workflow events inside the same transaction", () => {
const source = fs.readFileSync(rowsApiPath, "utf8");
const createSection = source.slice(source.indexOf("export async function create({"), source.indexOf("export async function createSimple({"));
expect(createSection).toContain("db.$transaction(async (tx) =>");
expect(createSection).toContain("const rowClient = tx as unknown as RowCreateClient");
expect(createSection).toContain("rowClient\n );");
expect(createSection).toContain("getLegacyRowById(item.id, rowClient, tenantId)");
expect(createSection).toContain("EventsService.createInTransaction(tx as Parameters<typeof EventsService.createInTransaction>[0]");
expect(createSection).toContain("EventsService.dispatchPostCommitSideEffects(rowCreatedEvent)");
expect(createSection).not.toContain('EventsService.create({\n request,\n event: "row.created"');
});
it("updates rows and records row.updated workflow events inside the same transaction", () => {
const source = fs.readFileSync(rowsApiPath, "utf8");
const updateSection = source.slice(source.indexOf("export async function update("), source.indexOf("export async function del("));
expect(updateSection).toContain("db.$transaction(async (tx) =>");
expect(updateSection).toContain("updateRowWithValuesInTransaction(");
expect(updateSection).toContain('event: "row.updated"');
expect(updateSection).toContain("EventsService.createInTransaction(tx as Parameters<typeof EventsService.createInTransaction>[0]");
expect(updateSection.indexOf("updateRowWithValuesInTransaction(")).toBeLessThan(updateSection.indexOf("EventsService.createInTransaction"));
expect(updateSection).toContain("EventsService.dispatchPostCommitSideEffects(rowUpdatedEvent)");
expect(updateSection).not.toContain('EventsService.create({\n request,\n event: "row.updated"');
});
it("deletes rows and records row.deleted workflow events inside the same transaction", () => {
const source = fs.readFileSync(rowsApiPath, "utf8");
const deleteSection = source.slice(source.indexOf("export async function del("), source.indexOf("export async function count({"));
expect(deleteSection).toContain("db.$transaction(async (tx) =>");
expect(deleteSection).toContain("hardDeleteRowById({ id, entityId: rowEntity.id, tenantId: row.tenantId ?? null }, tx as unknown as RowDeleteClient)");
expect(deleteSection).toContain('event: "row.deleted"');
expect(deleteSection).toContain("EventsService.createInTransaction(tx as Parameters<typeof EventsService.createInTransaction>[0]");
expect(deleteSection.indexOf("hardDeleteRowById(")).toBeLessThan(deleteSection.indexOf("EventsService.createInTransaction"));
expect(deleteSection).toContain("EventsService.dispatchPostCommitSideEffects(rowDeletedEvent)");
expect(deleteSection).not.toContain('EventsService.create({\n request,\n event: "row.deleted"');
});
});
@@ -0,0 +1,64 @@
import { getDefaultTenantTypes } from "../../db/tenants/tenantTypes.db.server";
import { SubscriptionProductDto } from "~/application/dtos/subscriptions/SubscriptionProductDto";
import { addTenantTypeToTenant, getTenant } from "../../db/tenants.db.server";
export namespace TenantTypesApi {
export async function setTenantTypes({
tenantId,
subscriptionProduct,
types,
}: {
tenantId: string;
subscriptionProduct?: SubscriptionProductDto;
types?: string[];
}) {
const tenant = await getTenant(tenantId);
if (!tenant) {
return;
}
if (types) {
await Promise.all(
types.map(async (typeId) => {
await addTenantTypeToTenant(tenant.id, { typeId });
})
);
return;
}
const defaultTenantTypes = await getDefaultTenantTypes();
const allTenantTypes = [...defaultTenantTypes, ...(subscriptionProduct?.assignsTenantTypes ?? [])].filter(
(v, i, a) => a.findIndex((t) => t.id === v.id) === i
);
await Promise.all(
allTenantTypes.map(async (tenantType) => {
if (!tenant.types.find((f) => f.id === tenantType.id)) {
await addTenantTypeToTenant(tenant.id, {
typeId: tenantType.id,
});
}
})
);
}
// export async function filterEntities({
// tenant,
// entities,
// entityGroups,
// }: {
// tenant: TenantSimple;
// entities: EntityWithDetails[];
// entityGroups?: EntityGroupWithDetails[];
// }) {
// const tenantTypes = await getAllTenantTypes();
// if (tenantTypes.length > 0) {
// const tenantEntities = await TenantEntitiesApi.getEntities({ inTypes: tenant.types, enabledOnly: true });
// entities = tenantEntities.allEntities;
// let newGroups: EntityGroupWithDetails[] = [];
// entityGroups?.forEach((group) => {
// group.entities = group.entities.filter((f) => entities.some((e) => e.id === f.id));
// if (group.entities.length > 0) {
// newGroups.push(group);
// }
// });
// entityGroups = newGroups;
// }
// }
}
@@ -0,0 +1,75 @@
import { getTranslations } from "~/locale/i18next.server";
import { findEntityByName } from "../../db/entities/entities.db.server";
import { createTenant } from "../../db/tenants.db.server";
import { getUser, updateUserDefaultTenantId } from "../../db/users.db.server";
import { getUserInfo } from "../../session.server";
import { createStripeCustomer } from "../../stripe.server";
import { RowsApi } from "./RowsApi";
import { TenantTypesApi } from "./TenantTypesApi";
import EventsService from "../../../modules/events/services/.server/EventsService";
import { AccountCreatedDto } from "~/modules/events/dtos/AccountCreatedDto";
import { autosubscribeToTrialOrFreePlan } from "../../services/.server/pricingService";
import { appPath } from "../../app/CanonicalUrlUtils";
export namespace TenantsApi {
export async function create({ request, form, name, slug }: { request: Request; form: FormData; name: string; slug?: string }) {
const { t } = await getTranslations(request);
let userInfo = await getUserInfo(request);
const user = await getUser(userInfo.userId);
if (!user) {
throw new Error("User not found");
}
if (!name) {
throw new Error("Name is required");
}
let stripeCustomerId: string | undefined;
if (process.env.STRIPE_SK) {
const stripeCustomer = await createStripeCustomer(user?.email, name);
if (!stripeCustomer) {
throw new Error("Could not create Stripe customer");
}
stripeCustomerId = stripeCustomer.id;
}
const tenant = await createTenant({ name, subscriptionCustomerId: stripeCustomerId, slug });
await updateUserDefaultTenantId({ defaultTenantId: tenant.id }, user.id);
await autosubscribeToTrialOrFreePlan({ request, t, tenantId: tenant.id, userId: user.id });
await EventsService.create({
request,
event: "account.created",
tenantId: tenant.id,
userId: user.id,
data: {
tenant: { id: tenant.id, name: tenant.name, slug: tenant.slug },
user: { id: user.id, email: user.email },
} satisfies AccountCreatedDto,
});
const tenantSettingsEntity = await findEntityByName({ tenantId: null, name: "tenantSettings" });
if (tenantSettingsEntity) {
try {
await RowsApi.createCustom({
request,
entity: tenantSettingsEntity,
tenantId: tenant.id,
t,
form,
row: undefined,
});
} catch (e: any) {
throw e;
}
}
await TenantTypesApi.setTenantTypes({ tenantId: tenant.id });
return {
redirectTo: appPath({ tenantSlug: tenant.slug, path: "dashboard", mode: "host-aware" }),
tenant,
user,
};
}
}
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { readRepoFile } from "~/utils/testing/repoPaths";
type BoundarySurface = {
path: string;
packageImports: string[];
forbiddenImports: string[];
};
const rowApiBoundarySurfaces: BoundarySurface[] = [
{
path: "apps/web/app/utils/api/.server/RowsApi.ts",
packageImports: [
"@toolctrl/db/rows.server",
"buildLegacyRowPropertyFiltersWhere",
"buildLegacyRowQueryWhere",
"changeLegacyRowOrder",
"countLegacyRows",
"createRowWithValues",
"hardDeleteRowById",
"getLegacyRow",
"getLegacyRowById",
"listLegacyRows",
"updateRowWithValues",
],
forbiddenImports: ["~/utils/db.server", "~/utils/db/entities/rows.db.server", "../../db/entities/rows.db.server"],
},
{
path: "apps/web/app/utils/api/.server/RowRelationshipsApi.ts",
packageImports: [
"@toolctrl/db/relationships.server",
"createLegacyRowRelationship",
"deleteLegacyRowRelationship",
"deleteLegacyRowRelationshipById",
"findLegacyEntityRelationship",
"findLegacyRowRelationshipById",
],
forbiddenImports: ["~/utils/db.server", "~/utils/db/entities/rowRelationships.db.server", "../../db/entities/rowRelationships.db.server"],
},
];
function readSource(relativePath: string) {
return readRepoFile(relativePath);
}
function importFrom(importPath: string) {
return [`from \"${importPath}\"`, `from '${importPath}'`];
}
describe("row API package boundary imports", () => {
it.each(rowApiBoundarySurfaces)("imports migrated row persistence helpers from package boundaries in $path", ({ path: relativePath, packageImports }) => {
const source = readSource(relativePath);
for (const packageImport of packageImports) {
expect(source, `${relativePath} should import ${packageImport}`).toContain(packageImport);
}
});
it.each(rowApiBoundarySurfaces)("does not reintroduce app-local row persistence imports in $path", ({ path: relativePath, forbiddenImports }) => {
const source = readSource(relativePath);
for (const forbiddenImport of forbiddenImports) {
for (const importStatement of importFrom(forbiddenImport)) {
expect(source, `${relativePath} should not import ${forbiddenImport}`).not.toContain(importStatement);
}
}
});
});
@@ -0,0 +1,20 @@
import { createHash } from "node:crypto";
const SENSITIVE_API_LOG_PARAM_PATTERN = /authorization|cookie|password|secret|token|api[-_]?key|key|email|session|jwt|code/i;
const SECRET_LIKE_API_LOG_VALUE_PATTERN = /\bBearer\s+\S+|\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9_]+|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/i;
export function getApiKeyLookupCacheKey(key: string) {
const digest = createHash("sha256").update(key).digest("hex");
return `apiKeyHash:${digest}`;
}
export function serializeApiLogSearchParams(request: Request) {
const searchParams = new URL(request.url).searchParams;
searchParams.delete("entity");
searchParams.delete("id");
const entries = Array.from(searchParams.entries()).map(([key, value]) => [
key,
SENSITIVE_API_LOG_PARAM_PATTERN.test(key) || SECRET_LIKE_API_LOG_VALUE_PATTERN.test(value) ? "[REDACTED]" : value,
]);
return JSON.stringify(Object.fromEntries(entries));
}
@@ -0,0 +1,48 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import { loadWebConfig } from "@toolctrl/config";
import { NOT_FOUND_ENVELOPE } from "~/utils/errors.server";
export const internalWebApiProxyHeader = "x-toolctrl-internal-web-api-proxy";
export const internalWebApiProxySignatureHeader = "x-toolctrl-internal-web-api-proxy-signature";
export function requireInternalWebApiProxyRequest(request: Request) {
if (request.headers.get(internalWebApiProxyHeader) !== "1" || !isValidInternalWebApiProxySignature(request)) {
throw Response.json(NOT_FOUND_ENVELOPE, { status: 404 });
}
}
export function createInternalWebApiProxySignature(input: { method: string; url: string | URL; secret: string }) {
const url = new URL(input.url);
const canonicalTarget = `${input.method.toUpperCase()}\n${url.pathname}${url.search}`;
return createHmac("sha256", input.secret).update(canonicalTarget).digest("base64url");
}
function getApiAccessToken() {
return loadWebConfig().config.apiAccessToken;
}
export function createInternalWebApiProxyHeaders(request: Request, secret = getApiAccessToken()) {
if (!secret) {
throw new Error("API_ACCESS_TOKEN is required for internal web API proxy requests.");
}
return {
[internalWebApiProxyHeader]: "1",
[internalWebApiProxySignatureHeader]: createInternalWebApiProxySignature({ method: request.method, url: request.url, secret }),
};
}
function isValidInternalWebApiProxySignature(request: Request) {
const secret = getApiAccessToken();
const signature = request.headers.get(internalWebApiProxySignatureHeader);
if (!secret || !signature) return false;
const expected = createInternalWebApiProxySignature({ method: request.method, url: request.url, secret });
return timingSafeEqualString(signature, expected);
}
function timingSafeEqualString(left: string, right: string) {
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
}
@@ -0,0 +1,18 @@
import { Colors } from "~/application/enums/shared/Colors";
function getMethodColor(method: string) {
if (method === "POST") {
return Colors.GREEN;
} else if (method === "GET") {
return Colors.BLUE;
} else if (method === "PUT") {
return Colors.ORANGE;
} else if (method === "DELETE") {
return Colors.RED;
}
return Colors.GRAY;
}
export default {
getMethodColor,
};
@@ -0,0 +1,13 @@
import { subscriptionRequiredCheckoutPath } from "./SubscriptionUrlUtils";
export function appTenantSubscriptionRequiredPath(params: { tenant?: string }) {
return subscriptionRequiredCheckoutPath(params.tenant ?? "");
}
export function appTenantPlanRequiredPath(params: { tenant?: string }) {
return `/plan-required/${encodeURIComponent(params.tenant ?? "")}`;
}
export function appTenantDeactivatedPath(params: { tenant?: string }) {
return `/deactivated/${encodeURIComponent(params.tenant ?? "")}`;
}
@@ -0,0 +1,21 @@
import { describe, expect, test } from "vitest";
import packageCanonicalUrlUtils, { adminPath as packageAdminPath, appPath as packageAppPath } from "@toolctrl/url";
import CanonicalUrlUtils, { adminPath, apiPath, appPath, appUrl, type AppPathOptions } from "./CanonicalUrlUtils";
describe("CanonicalUrlUtils compatibility shim", () => {
test("re-exports named helpers from @toolctrl/url", () => {
const appOptions: AppPathOptions = { tenantSlug: "acme/team", path: "/settings?tab=billing#plan", mode: "host-aware" };
expect(appPath(appOptions)).toBe(packageAppPath(appOptions));
expect(adminPath("/users", { mode: "host-aware" })).toBe(packageAdminPath("/users", { mode: "host-aware" }));
expect(apiPath("events", { mode: "host-aware", version: "v1" })).toBe(packageCanonicalUrlUtils.apiPath("events", { mode: "host-aware", version: "v1" }));
expect(appUrl({ rootDomain: "localhost:3000", protocol: "http", appSubdomain: "app", tenantSlug: "acme", path: "dashboard", mode: "host-aware" })).toBe(
packageCanonicalUrlUtils.appUrl({ rootDomain: "localhost:3000", protocol: "http", appSubdomain: "app", tenantSlug: "acme", path: "dashboard", mode: "host-aware" })
);
});
test("preserves the default export object for existing app imports", () => {
expect(CanonicalUrlUtils).toBe(packageCanonicalUrlUtils);
expect(CanonicalUrlUtils.appPath({ tenantSlug: "acme", path: "dashboard" })).toBe(packageCanonicalUrlUtils.appPath({ tenantSlug: "acme", path: "dashboard" }));
});
});
@@ -0,0 +1,23 @@
export {
adminPath,
adminUrl,
apiPath,
apiUrl,
appPath,
appUrl,
default,
} from "@toolctrl/url";
export type {
AbsoluteUrlOptions,
AdminPathOptions,
AdminUrlOptions,
ApiPathOptions,
ApiUrlOptions,
ApiVersion,
AppPathOptions,
AppUrlOptions,
CanonicalPathOptions,
CanonicalUrlMode,
PathInput,
} from "@toolctrl/url";
@@ -0,0 +1,32 @@
import type { DomainRoutingConfig } from "@toolctrl/config";
import { adminPath, appPath, type PathInput } from "./CanonicalUrlUtils";
export type PublicDomainRoutingConfig = Pick<DomainRoutingConfig, "protocol" | "webAppHost" | "adminAppHost" | "apiHost" | "appBaseDomain">;
function absoluteHostPath(host: string, path: string, protocol: DomainRoutingConfig["protocol"]) {
return `${protocol}://${host}${path}`;
}
export function absoluteAdminPath(routing: PublicDomainRoutingConfig | undefined, path?: PathInput) {
const targetPath = adminPath(path, { mode: "host-aware" });
return routing ? absoluteHostPath(routing.adminAppHost, targetPath, routing.protocol) : targetPath;
}
export function absoluteAppPath(routing: PublicDomainRoutingConfig | undefined, tenantSlug: string, path?: PathInput) {
const targetPath = appPath({ tenantSlug, path, mode: "host-aware" });
return routing ? absoluteHostPath(routing.webAppHost, targetPath, routing.protocol) : targetPath;
}
export function absoluteAppRootPath(routing: PublicDomainRoutingConfig | undefined) {
return routing ? absoluteHostPath(routing.webAppHost, "/", routing.protocol) : "/";
}
export function absolutePublicAppPath(routing: PublicDomainRoutingConfig | undefined, path: string) {
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
const appRoot = absoluteAppRootPath(routing);
return isAbsoluteHttpUrl(appRoot) ? new URL(normalizedPath, appRoot).toString() : normalizedPath;
}
export function isAbsoluteHttpUrl(value: string) {
return /^https?:\/\//i.test(value);
}
@@ -0,0 +1,33 @@
import {describe, test, expect} from 'vitest'
import { getStatChangePercentage, getStatChangeType } from "./DashboardUtils";
import { StatChange } from "~/application/dtos/stats/StatChange";
describe("getStatChangePercentage", () => {
test("returns 100 when total is less than or equal to zero", () => {
expect(getStatChangePercentage(5, 0)).toBe(100);
expect(getStatChangePercentage(5, -1)).toBe(100);
});
test("returns the correct percentage when total is greater than zero", () => {
expect(getStatChangePercentage(5, 10)).toBe(50);
expect(getStatChangePercentage(1, 4)).toBe(25);
expect(getStatChangePercentage(0, 5)).toBe(0);
});
});
describe("getStatChangeType", () => {
test("returns StatChange.Equal when change is zero", () => {
expect(getStatChangeType(0, 5)).toBe(StatChange.Equal);
});
test("returns StatChange.Increase when change is positive", () => {
expect(getStatChangeType(5, 10)).toBe(StatChange.Increase);
expect(getStatChangeType(2, 1)).toBe(StatChange.Increase);
expect(getStatChangeType(-2, -1)).toBe(StatChange.Increase);
});
test("returns StatChange.Decrease when change is negative", () => {
expect(getStatChangeType(-5, 10)).toBe(StatChange.Decrease);
expect(getStatChangeType(-2, 2)).toBe(StatChange.Decrease);
});
});
@@ -0,0 +1,16 @@
import { StatChange } from "~/application/dtos/stats/StatChange";
export function getStatChangePercentage(added: number, total: number): number {
if (total <= 0) {
return 100;
}
return Math.round((added * 100) / total);
}
export function getStatChangeType(added: number, total: number): StatChange {
const change = getStatChangePercentage(added, total);
if (change === 0) {
return StatChange.Equal;
}
return change > 0 ? StatChange.Increase : StatChange.Decrease;
}
@@ -0,0 +1,59 @@
import { describe, expect, test } from "vitest";
import { isAdminSurface, isAppOrAdminSurface, isAppSurface } from "./HostAwareSurface";
const routing = {
appBaseDomain: "example.test",
webAppHost: "app.example.test",
adminAppHost: "admin.example.test",
apiHost: "api.example.test",
protocol: "https",
} as const;
describe("HostAwareSurface", () => {
test("classifies canonical app and admin hosts by request origin", () => {
expect(
isAppSurface(
{
serverUrl: "https://app.example.test",
publicBaseUrl: "https://example.test",
routing,
},
"/acme/dashboard"
)
).toBe(true);
expect(
isAdminSurface(
{
serverUrl: "https://admin.example.test",
publicBaseUrl: "https://example.test",
routing,
},
"/dashboard"
)
).toBe(true);
});
test("keeps public routes on the web app host out of app chrome", () => {
const input = {
serverUrl: "https://app.example.test",
publicBaseUrl: "https://example.test",
routing,
};
expect(isAppSurface(input, "/docs/getting-started")).toBe(false);
expect(isAppOrAdminSurface(input, "/docs/getting-started")).toBe(false);
expect(isAppSurface(input, "/acme/dashboard")).toBe(true);
});
test("keeps admin host settings classified as admin chrome", () => {
const input = {
serverUrl: "https://admin.example.test",
publicBaseUrl: "https://example.test",
routing,
};
expect(isAdminSurface(input, "/settings/profile")).toBe(true);
expect(isAppOrAdminSurface(input, "/settings/profile")).toBe(true);
});
});
@@ -0,0 +1,38 @@
import type { PublicDomainRoutingConfig } from "./CrossHostUrls";
import { isPublicWebRoutePath } from "./PublicRouteSegments";
type SurfaceInput = {
publicBaseUrl?: string;
serverUrl?: string;
routing?: PublicDomainRoutingConfig;
};
function hostFromUrl(value?: string) {
if (!value) {
return undefined;
}
try {
return new URL(value).host;
} catch {
return undefined;
}
}
function currentHost(input: SurfaceInput) {
return hostFromUrl(input.serverUrl) ?? hostFromUrl(input.publicBaseUrl);
}
export function isAdminSurface(input: SurfaceInput, pathname: string) {
return currentHost(input) === input.routing?.adminAppHost;
}
export function isAppSurface(input: SurfaceInput, pathname: string) {
if (isPublicWebRoutePath(pathname)) {
return false;
}
return currentHost(input) === input.routing?.webAppHost;
}
export function isAppOrAdminSurface(input: SurfaceInput, pathname: string) {
return isAppSurface(input, pathname) || isAdminSurface(input, pathname);
}
@@ -0,0 +1,321 @@
import { describe, expect, test } from "vitest";
import {
getCanonicalHostRedirectUrl,
getHostAwareInternalPath,
getHostAwareInternalUrlPath,
getHostRoutingContext,
getHostRoutingContextFromRequest,
} from "./HostRouting.server";
import type { DomainRoutingConfig } from "@toolctrl/config";
const config: DomainRoutingConfig = {
appBaseDomain: "localhost:3000",
webAppHost: "app.localhost:3000",
adminAppHost: "admin.localhost:3000",
apiHost: "api.localhost:3000",
protocol: "http",
};
const env = {
APP_BASE_DOMAIN: config.appBaseDomain,
WEB_APP_HOST: config.webAppHost,
ADMIN_APP_HOST: config.adminAppHost,
API_HOST: config.apiHost,
APP_PROTOCOL: config.protocol,
};
describe("HostRouting", () => {
test("classifies canonical local hosts", () => {
expect(getHostRoutingContext("app.localhost:3000", config)).toBe("app");
expect(getHostRoutingContext("admin.localhost:3000", config)).toBe("admin");
expect(getHostRoutingContext("api.localhost:3000", config)).toBe("api");
expect(getHostRoutingContext("localhost:3000", config)).toBe("root");
});
test("maps host-aware app paths to canonical internal route paths", () => {
expect(getHostAwareInternalPath("/acme/dashboard", "app")).toBe("/acme/dashboard");
expect(getHostAwareInternalPath("/", "app")).toBe("/app");
});
test("maps app host request URLs without adding removed prefixes", () => {
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/acme/dashboard?tab=activity#events", config)).toBe("/acme/dashboard?tab=activity#events");
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/app/acme/dashboard?tab=activity#events", config)).toBe(
"/acme/dashboard?tab=activity#events"
);
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/app/acme/dashboard.data?_routes=routes%2Fapp.%24tenant%2Fdashboard", config)).toBe(
"/acme/dashboard.data?_routes=routes%2Fapp.%24tenant%2Fdashboard"
);
});
test("keeps public auth routes on app and admin hosts from becoming tenant routes", () => {
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/login?redirectTo=%2Facme%2Fdashboard", config)).toBe(
"/login?redirectTo=%2Facme%2Fdashboard"
);
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/auth/magic-link", config)).toBe("/auth/magic-link");
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/plan-required/acme", config)).toBe("/plan-required/acme");
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/subscribe/acme?plan=pro", config)).toBe("/subscribe/acme?plan=pro");
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/new-account", config)).toBe("/new-account");
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/preferences", config)).toBe("/preferences");
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/onboarding/session_123", config)).toBe("/onboarding/session_123");
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/feature-disabled?feature=summary", config)).toBe("/feature-disabled?feature=summary");
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/postman_collection.json", config)).toBe("/postman_collection.json");
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/sitemap.xml", config)).toBe("/sitemap.xml");
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/docs/en", config)).toBe("/docs/en");
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/healthcheck", config)).toBe("/healthcheck");
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/healthz", config)).toBe("/healthz");
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/readyz", config)).toBe("/readyz");
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/swagger", config)).toBe("/swagger");
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/unauthorized/app.settings.members", config)).toBe("/unauthorized/app.settings.members");
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/404", config)).toBe("/404");
expect(getHostAwareInternalUrlPath("http://admin.localhost:3000/login", config)).toBe("/login");
expect(getHostAwareInternalUrlPath("http://admin.localhost:3000/preferences", config)).toBe("/preferences");
expect(getHostAwareInternalUrlPath("http://admin.localhost:3000/onboarding/session_123", config)).toBe("/onboarding/session_123");
expect(getHostAwareInternalUrlPath("http://admin.localhost:3000/feature-disabled?feature=summary", config)).toBe("/feature-disabled?feature=summary");
expect(getHostAwareInternalUrlPath("http://admin.localhost:3000/postman_collection.json", config)).toBe("/postman_collection.json");
expect(getHostAwareInternalUrlPath("http://admin.localhost:3000/sitemap.xml", config)).toBe("/sitemap.xml");
});
test("keeps React Router manifest discovery on canonical hosts", () => {
expect(getHostAwareInternalUrlPath("http://admin.localhost:3000/__manifest?paths=%2Fmfa-challenge&version=abc", config)).toBe(
"/__manifest?paths=%2Fmfa-challenge&version=abc"
);
expect(getHostAwareInternalUrlPath("http://app.localhost:3000/__manifest?paths=%2Facme%2Fdashboard&version=abc", config)).toBe(
"/__manifest?paths=%2Facme%2Fdashboard&version=abc"
);
});
test("keeps canonical admin host routes on the admin host", () => {
expect(getHostAwareInternalUrlPath("http://admin.localhost:3000/dashboard?tab=users#active", config)).toBe("/dashboard?tab=users#active");
expect(getHostAwareInternalUrlPath("http://admin.localhost:3000/dashboard.data?_routes=routes%2Fdashboard", config)).toBe(
"/dashboard.data?_routes=routes%2Fdashboard"
);
expect(getHostAwareInternalUrlPath("http://admin.localhost:3000/mfa-challenge?redirectTo=%2Fdashboard", config)).toBe(
"/mfa-challenge?redirectTo=%2Fdashboard"
);
expect(getHostAwareInternalUrlPath("http://admin.localhost:3000/mfa-challenge.data?_routes=routes%2Fmfa-challenge", config)).toBe(
"/mfa-challenge.data?_routes=routes%2Fmfa-challenge"
);
expect(getHostAwareInternalUrlPath("http://admin.localhost:3000/internal/ai/chat", config)).toBe("/internal/ai/chat");
});
test("maps host-aware admin and API paths", () => {
expect(getHostAwareInternalPath("/accounts", "admin")).toBe("/accounts");
expect(getHostAwareInternalPath("/users", "admin")).toBe("/users");
expect(getHostAwareInternalPath("/health", "api")).toBe("/v1/health");
expect(getHostAwareInternalPath("/healthz", "api")).toBe("/healthz");
expect(getHostAwareInternalPath("/readyz", "api")).toBe("/readyz");
expect(getHostAwareInternalPath("/v1/health", "api")).toBe("/v1/health");
expect(getHostAwareInternalPath("/api/v1/health", "api")).toBe("/v1/health");
});
test("maps API host request URLs without path double-prefixing and preserves suffixes", () => {
expect(getHostAwareInternalUrlPath("http://api.localhost:3000/health?check=deep#status", config)).toBe(
"/v1/health?check=deep#status"
);
expect(getHostAwareInternalUrlPath("http://api.localhost:3000/v1/health?check=deep#status", config)).toBe(
"/v1/health?check=deep#status"
);
expect(getHostAwareInternalUrlPath("http://api.localhost:3000/api/v1/health?check=deep#status", config)).toBe(
"/v1/health?check=deep#status"
);
});
test("maps admin host request URLs without double-prefixing", () => {
expect(getHostAwareInternalUrlPath("http://admin.localhost:3000/accounts?tab=users#active", config)).toBe("/accounts?tab=users#active");
expect(getHostAwareInternalUrlPath("http://admin.localhost:3000/users?tab=users#active", config)).toBe("/users?tab=users#active");
});
test("uses forwarded host when deriving request context behind a proxy", () => {
const request = new Request("http://localhost:3000/acme/dashboard", {
headers: {
host: "localhost:3000",
"x-forwarded-host": " app.localhost:3000 , api.localhost:3000",
},
});
expect(getHostRoutingContextFromRequest(request, env)).toBe("app");
});
test("uses forwarded host for internal URL mapping behind a proxy", () => {
const request = new Request("http://localhost:3000/acme/dashboard?tab=activity#events", {
headers: {
host: "localhost:3000",
"x-forwarded-host": " app.localhost:3000 , api.localhost:3000",
},
});
expect(getHostAwareInternalUrlPath(request, config)).toBe("/acme/dashboard?tab=activity#events");
});
test("uses forwarded host for canonical redirects behind a proxy", () => {
const request = new Request("http://localhost:3000/settings?tab=users#active", {
headers: {
host: "localhost:3000",
"x-forwarded-host": " admin.localhost:3000 , app.localhost:3000",
},
});
expect(getCanonicalHostRedirectUrl(request, config)).toBeNull();
});
test("uses the first forwarded host value for routing", () => {
const request = new Request("http://localhost:3000/acme/dashboard?tab=activity#events", {
headers: {
host: "localhost:3000",
"x-forwarded-host": " admin.localhost:3000 , api.localhost:3000",
},
});
expect(getHostRoutingContextFromRequest(request, env)).toBe("admin");
expect(getCanonicalHostRedirectUrl(request, config)).toBe("http://app.localhost:3000/acme/dashboard?tab=activity#events");
});
test("does not let forwarded host spoof an already canonical direct host", () => {
const request = new Request("http://app.localhost:3000/dashboard?tab=activity#events", {
headers: {
host: "app.localhost:3000",
"x-forwarded-host": "admin.localhost:3000",
},
});
expect(getHostRoutingContextFromRequest(request, env)).toBe("app");
expect(getCanonicalHostRedirectUrl(request, config)).toBe("http://admin.localhost:3000/dashboard?tab=activity#events");
});
test("redirects root-host canonical admin and tenant paths to their canonical hosts", () => {
expect(getCanonicalHostRedirectUrl("http://localhost:3000/dashboard?tab=activity#events", config)).toBe(
"http://admin.localhost:3000/dashboard?tab=activity#events"
);
expect(getCanonicalHostRedirectUrl("http://localhost:3000/accounts", config)).toBe("http://admin.localhost:3000/accounts");
expect(getCanonicalHostRedirectUrl("http://localhost:3000/acme/dashboard?tab=activity#events", config)).toBe(
"http://app.localhost:3000/acme/dashboard?tab=activity#events"
);
expect(getCanonicalHostRedirectUrl("http://localhost:3000/app/acme/dashboard?tab=activity#events", config)).toBe(
"http://app.localhost:3000/acme/dashboard?tab=activity#events"
);
expect(getCanonicalHostRedirectUrl("http://localhost:3000/api/v1/health?check=deep#status", config)).toBe(
"http://api.localhost:3000/v1/health?check=deep#status"
);
expect(getCanonicalHostRedirectUrl("http://localhost:3000/admin/settings?tab=users#active", config)).toBeNull();
expect(getCanonicalHostRedirectUrl("http://localhost:3000/admin", config)).toBeNull();
});
test("does not redirect a canonical route to itself when adminAppHost equals webAppHost (no 308 loop)", () => {
// Single-Host-Staging / Fehlkonfig: adminAppHost == webAppHost. /dashboard (Admin-Surface) wuerde sonst
// kanonisch auf denselben Host+Pfad umgeleitet -> 308-Endlosschleife. Der Self-Redirect-Guard liefert null.
const sameHostConfig: DomainRoutingConfig = { ...config, adminAppHost: config.webAppHost };
expect(getCanonicalHostRedirectUrl("http://app.localhost:3000/dashboard?tab=activity#events", sameHostConfig)).toBeNull();
expect(getCanonicalHostRedirectUrl("http://app.localhost:3000/accounts/users", sameHostConfig)).toBeNull();
// Getrennte Hosts (Prod) bleiben unveraendert -> Redirect feuert weiterhin.
expect(getCanonicalHostRedirectUrl("http://app.localhost:3000/dashboard?tab=activity#events", config)).toBe(
"http://admin.localhost:3000/dashboard?tab=activity#events"
);
});
test("redirects canonical route paths off the wrong canonical host", () => {
expect(getCanonicalHostRedirectUrl("http://app.localhost:3000/dashboard?tab=activity#events", config)).toBe(
"http://admin.localhost:3000/dashboard?tab=activity#events"
);
expect(getCanonicalHostRedirectUrl("http://app.localhost:3000/accounts/users?filter=active", config)).toBe(
"http://admin.localhost:3000/accounts/users?filter=active"
);
expect(getCanonicalHostRedirectUrl("http://app.localhost:3000/internal/imports/dry-run", config)).toBeNull();
expect(getCanonicalHostRedirectUrl("http://api.localhost:3000/dashboard?tab=activity#events", config)).toBe(
"http://admin.localhost:3000/dashboard?tab=activity#events"
);
expect(getCanonicalHostRedirectUrl("http://admin.localhost:3000/acme/dashboard?tab=activity#events", config)).toBe(
"http://app.localhost:3000/acme/dashboard?tab=activity#events"
);
expect(getCanonicalHostRedirectUrl("http://admin.localhost:3000/acme", config)).toBe("http://app.localhost:3000/acme");
expect(getCanonicalHostRedirectUrl("http://api.localhost:3000/acme", config)).toBe("http://app.localhost:3000/acme");
expect(getCanonicalHostRedirectUrl("http://api.localhost:3000/acme/dashboard?tab=activity#events", config)).toBe(
"http://app.localhost:3000/acme/dashboard?tab=activity#events"
);
expect(getCanonicalHostRedirectUrl("http://admin.localhost:3000/acme/portals/customer", config)).toBeNull();
expect(getCanonicalHostRedirectUrl("http://admin.localhost:3000/acme/widgets", config)).toBeNull();
expect(getCanonicalHostRedirectUrl("http://admin.localhost:3000/acme/workflow-engine", config)).toBeNull();
expect(getCanonicalHostRedirectUrl("http://admin.localhost:3000/acme/contact", config)).toBe("http://app.localhost:3000/acme/contact");
expect(getCanonicalHostRedirectUrl("http://admin.localhost:3000/v1/users?cursor=next", config)).toBe("http://api.localhost:3000/v1/users?cursor=next");
expect(getCanonicalHostRedirectUrl("http://app.localhost:3000/v1/users?cursor=next", config)).toBe("http://api.localhost:3000/v1/users?cursor=next");
expect(getCanonicalHostRedirectUrl("http://app.localhost:3000/api/v1/health?check=deep#status", config)).toBe(
"http://api.localhost:3000/v1/health?check=deep#status"
);
expect(getCanonicalHostRedirectUrl("http://app.localhost:3000/app/acme/dashboard?tab=activity#events", config)).toBe(
"http://app.localhost:3000/acme/dashboard?tab=activity#events"
);
expect(getCanonicalHostRedirectUrl("http://admin.localhost:3000/api/v1/health?check=deep#status", config)).toBe(
"http://api.localhost:3000/v1/health?check=deep#status"
);
expect(getCanonicalHostRedirectUrl("http://app.localhost:3000/admin/settings?tab=users#active", config)).toBeNull();
});
test("redirects API host unversioned aliases to canonical v1 paths", () => {
expect(getCanonicalHostRedirectUrl("http://api.localhost:3000/health?check=deep#status", config)).toBe(
"http://api.localhost:3000/v1/health?check=deep#status"
);
expect(getCanonicalHostRedirectUrl("http://api.localhost:3000/accounts?cursor=next", config)).toBe(
"http://api.localhost:3000/v1/accounts?cursor=next"
);
expect(getCanonicalHostRedirectUrl("http://api.localhost:3000/auth/login", config)).toBe("http://api.localhost:3000/v1/auth/login");
expect(getCanonicalHostRedirectUrl("http://api.localhost:3000/analytics/events", config)).toBe("http://api.localhost:3000/v1/analytics/events");
expect(getCanonicalHostRedirectUrl("http://api.localhost:3000/entities/contact/rows", config)).toBe(
"http://api.localhost:3000/v1/entities/contact/rows"
);
expect(getCanonicalHostRedirectUrl("http://api.localhost:3000/api-keys/validate", config)).toBe(
"http://api.localhost:3000/v1/api-keys/validate"
);
});
test("does not canonicalize unsupported API host aliases", () => {
expect(getCanonicalHostRedirectUrl("http://api.localhost:3000/webhooks/inbound", config)).toBe("http://app.localhost:3000/webhooks/inbound");
expect(getCanonicalHostRedirectUrl("http://api.localhost:3000/imports/dry-run", config)).toBe("http://app.localhost:3000/imports/dry-run");
expect(getCanonicalHostRedirectUrl("http://api.localhost:3000/workflows/run/workflow_1", config)).toBeNull();
});
test("keeps public root-host routes on the root host", () => {
expect(getCanonicalHostRedirectUrl("http://localhost:3000/login?redirectTo=%2Facme%2Fdashboard", config)).toBeNull();
});
test("redirects public web routes away from the API host", () => {
expect(getCanonicalHostRedirectUrl("http://api.localhost:3000/login?redirectTo=%2Facme%2Fdashboard", config)).toBe(
"http://app.localhost:3000/login?redirectTo=%2Facme%2Fdashboard"
);
expect(getCanonicalHostRedirectUrl("http://api.localhost:3000/healthcheck", config)).toBe("http://app.localhost:3000/healthcheck");
});
test("redirects app-host reserved admin slugs instead of treating them as tenants", () => {
expect(getCanonicalHostRedirectUrl("http://app.localhost:3000/crm/dashboard", config)).toBeNull();
expect(getCanonicalHostRedirectUrl("http://app.localhost:3000/accounts/users", config)).toBe("http://admin.localhost:3000/accounts/users");
});
test("does not canonicalize disabled plugin route trees to active hosts", () => {
expect(getCanonicalHostRedirectUrl("http://localhost:3000/crm/dashboard", config)).toBeNull();
expect(getCanonicalHostRedirectUrl("http://localhost:3000/internal/ai/chat", config)).toBeNull();
expect(getCanonicalHostRedirectUrl("http://localhost:3000/internal/api/__workflows/jobs", config)).toBeNull();
expect(getCanonicalHostRedirectUrl("http://localhost:3000/internal/api/workflows/run/workflow_1", config)).toBeNull();
expect(getCanonicalHostRedirectUrl("http://localhost:3000/workflows/run/workflow_1", config)).toBeNull();
expect(getCanonicalHostRedirectUrl("http://localhost:3000/admin/internal/ai/chat", config)).toBeNull();
expect(getCanonicalHostRedirectUrl("http://localhost:3000/admin/__workflows/jobs", config)).toBeNull();
expect(getCanonicalHostRedirectUrl("http://localhost:3000/knowledge-base/articles", config)).toBeNull();
expect(getCanonicalHostRedirectUrl("http://localhost:3000/help/getting-started", config)).toBeNull();
expect(getCanonicalHostRedirectUrl("http://localhost:3000/mcp", config)).toBeNull();
expect(getCanonicalHostRedirectUrl("http://api.localhost:3000/help/getting-started", config)).toBeNull();
expect(getCanonicalHostRedirectUrl("http://api.localhost:3000/mcp", config)).toBeNull();
expect(getCanonicalHostRedirectUrl("http://api.localhost:3000/internal/ai/chat", config)).toBeNull();
expect(getCanonicalHostRedirectUrl("http://api.localhost:3000/__workflows/jobs", config)).toBeNull();
expect(getCanonicalHostRedirectUrl("http://app.localhost:3000/knowledge-base/articles", config)).toBeNull();
expect(getCanonicalHostRedirectUrl("http://app.localhost:3000/acme/internal/ai/chat", config)).toBeNull();
expect(getCanonicalHostRedirectUrl("http://admin.localhost:3000/acme/crm", config)).toBeNull();
expect(getCanonicalHostRedirectUrl("http://admin.localhost:3000/acme/__widgets/widget_1", config)).toBeNull();
expect(getCanonicalHostRedirectUrl("http://admin.localhost:3000/acme/internal/ai/chat", config)).toBeNull();
expect(getCanonicalHostRedirectUrl("http://admin.localhost:3000/__workflows/jobs", config)).toBeNull();
expect(getCanonicalHostRedirectUrl("http://localhost:3000/acme/internal/ai/chat", config)).toBeNull();
});
test("builds canonical redirect URLs for versioned API paths", () => {
expect(getCanonicalHostRedirectUrl("http://api.localhost:3000/v1/health?check=deep#status", config)).toBeNull();
expect(getCanonicalHostRedirectUrl("http://localhost:3000/v1/health?check=deep#status", config)).toBe(
"http://api.localhost:3000/v1/health?check=deep#status"
);
});
});
@@ -0,0 +1,497 @@
import { getDomainRoutingConfig, hostMatches, normalizeHost, type DomainRoutingConfig, type DomainRoutingEnv } from "@toolctrl/config";
import { adminPath, apiPath, appPath } from "./CanonicalUrlUtils";
import {
canonicalAdminRouteSlugs,
disabledPluginRouteSlugs,
isPublicWebRoutePath,
isWebRoutePath,
normalizeRoutePath,
pathMatchesRoutePrefix,
reservedTenantChildRouteSlugs,
reservedTenantRouteSlugs,
} from "./PublicRouteSegments";
export type HostRoutingContext = "app" | "admin" | "api" | "root" | "unknown";
type UrlInput = string | URL | Request;
export function getHostRoutingContext(host: string | undefined, config: DomainRoutingConfig = getDomainRoutingConfig()): HostRoutingContext {
if (hostMatches(host, config.webAppHost)) {
return "app";
}
if (hostMatches(host, config.adminAppHost)) {
return "admin";
}
if (hostMatches(host, config.apiHost)) {
return "api";
}
if (hostMatches(host, config.appBaseDomain)) {
return "root";
}
return "unknown";
}
export function getHostRoutingContextFromRequest(request: Request, env?: DomainRoutingEnv): HostRoutingContext {
const config = getDomainRoutingConfig(env);
return getHostRoutingContext(getRequestRoutingHost(request, new URL(request.url), config), config);
}
export function getHostAwareInternalPath(pathname: string, context: HostRoutingContext): string {
if (isFrameworkInternalRoute(pathname)) {
return pathname.startsWith("/") ? pathname : `/${pathname}`;
}
if (context === "admin" && isCanonicalAdminHostRoute(pathname)) {
return pathname.startsWith("/") ? pathname : `/${pathname}`;
}
if ((context === "app" || context === "admin") && isPublicWebRoute(pathname)) {
return pathname.startsWith("/") ? pathname : `/${pathname}`;
}
if (context === "app") {
const [tenantSlug, ...rest] = trimPath(pathname).split("/").filter(Boolean);
if (!tenantSlug) {
return "/app";
}
return appPath({ tenantSlug, path: rest.join("/"), mode: "host-aware" });
}
if (context === "admin") {
return adminPath(trimPath(pathname), { mode: "host-aware" });
}
if (context === "api") {
const normalized = trimPath(pathname);
if (normalized === "healthz" || normalized === "readyz") {
return `/${normalized}`;
}
return normalized.startsWith("v1/") || normalized === "v1" ? `/${normalized}` : apiPath(normalized, { mode: "host-aware" });
}
return pathname.startsWith("/") ? pathname : `/${pathname}`;
}
export function getHostAwareInternalUrlPath(input: UrlInput, config: DomainRoutingConfig = getDomainRoutingConfig()): string {
const url = toUrl(input);
const context = getHostRoutingContext(getHostFromInput(input, url, config), config);
const internalPath = getHostAwareInternalPathWithoutDoublePrefix(url.pathname, context);
return `${internalPath}${url.search}${url.hash}`;
}
export function getCanonicalHostRedirectUrl(input: UrlInput, config: DomainRoutingConfig = getDomainRoutingConfig()): string | null {
const url = toUrl(input);
const currentHost = getHostFromInput(input, url, config);
const context = getHostRoutingContext(currentHost, config);
// Self-Redirect-Guard: Zeigt der kanonische Redirect auf denselben Host UND denselben Pfad, waere es eine
// 308-Endlosschleife (passiert z.B. wenn adminAppHost == webAppHost — Single-Host-Staging/Dev oder eine
// Fehlkonfiguration). In dem Fall NICHT umleiten. In getrennten Prod-Hosts (app. != admin.) greift der
// Guard nie, das Verhalten bleibt unveraendert.
const isSelfRedirect = (origin: string, pathname: string) => {
let targetHost: string;
try {
targetHost = new URL(origin).host;
} catch {
return false;
}
return hostMatches(currentHost, targetHost) && pathname === url.pathname;
};
const crossHostRedirect = getCrossHostCanonicalRedirect(url.pathname, context, config);
if (crossHostRedirect && !isSelfRedirect(crossHostRedirect.origin, crossHostRedirect.pathname)) {
return `${crossHostRedirect.origin}${crossHostRedirect.pathname}${url.search}${url.hash}`;
}
const rootHostRedirect = getRootHostCanonicalRedirect(url.pathname, context, config);
if (rootHostRedirect && !isSelfRedirect(rootHostRedirect.origin, rootHostRedirect.pathname)) {
return `${rootHostRedirect.origin}${rootHostRedirect.pathname}${url.search}${url.hash}`;
}
return null;
}
export function isCanonicalHostForContext(
host: string | undefined,
context: Exclude<HostRoutingContext, "unknown">,
config: DomainRoutingConfig = getDomainRoutingConfig()
) {
const normalizedHost = normalizeHost(host);
if (!normalizedHost) {
return false;
}
return getHostRoutingContext(normalizedHost, config) === context;
}
function trimPath(pathname: string) {
return pathname.replace(/^\/+|\/+$/g, "");
}
function isPublicWebRoute(pathname: string) {
return isWebRoutePath(pathname);
}
function isFrameworkInternalRoute(pathname: string) {
const normalizedPathname = pathname.startsWith("/") ? pathname : `/${pathname}`;
return normalizedPathname === "/__manifest";
}
const activeWebInternalRoutePrefixes = ["/internal/imports/dry-run"] as const;
function isActiveWebInternalRoute(pathname: string) {
const routePathname = normalizeRoutePath(pathname);
return activeWebInternalRoutePrefixes.some((prefix) => pathMatchesRoutePrefix(routePathname, prefix));
}
export function isCanonicalAdminHostRoute(pathname: string) {
const routePathname = normalizeRoutePath(pathname);
if (isActiveWebInternalRoute(routePathname)) {
return false;
}
if (isDisabledAdminHostRoute(routePathname)) {
return false;
}
const firstSegment = trimPath(routePathname).split("/")[0];
return Boolean(firstSegment && (canonicalAdminRouteSlugs as readonly string[]).includes(firstSegment));
}
function isCanonicalApiHostRoute(pathname: string) {
const normalizedPathname = pathname.startsWith("/") ? pathname : `/${pathname}`;
const routePathname = normalizedPathname.replace(/\.data$/, "");
return routePathname === "/v1" || routePathname.startsWith("/v1/");
}
function isLegacyApiV1HostRoute(pathname: string) {
const normalizedPathname = pathname.startsWith("/") ? pathname : `/${pathname}`;
const routePathname = normalizedPathname.replace(/\.data$/, "");
return routePathname === "/api/v1" || routePathname.startsWith("/api/v1/");
}
function isLegacyAppHostRoute(pathname: string) {
const normalizedPathname = pathname.startsWith("/") ? pathname : `/${pathname}`;
const routePathname = normalizedPathname.replace(/\.data$/, "");
return routePathname.startsWith("/app/") && routePathname.split("/").filter(Boolean).length >= 2;
}
function isRemovedAdminPrefixRoute(pathname: string) {
const routePathname = normalizeRoutePath(pathname);
return trimPath(routePathname).split("/").filter(Boolean)[0] === "admin";
}
function canonicalTenantPathFromLegacyApp(pathname: string) {
const normalizedPathname = pathname.startsWith("/") ? pathname : `/${pathname}`;
const routePathname = normalizedPathname.replace(/\.data$/, "");
const canonicalPathname = routePathname.replace(/^\/app(?=\/)/, "");
return normalizedPathname.endsWith(".data") ? `${canonicalPathname}.data` : canonicalPathname;
}
function canonicalApiPathFromLegacyApiV1(pathname: string) {
const normalizedPathname = pathname.startsWith("/") ? pathname : `/${pathname}`;
const routePathname = normalizedPathname.replace(/\.data$/, "");
const canonicalPathname = routePathname.replace(/^\/api(?=\/v1(?:\/|$))/, "");
return normalizedPathname.endsWith(".data") ? `${canonicalPathname}.data` : canonicalPathname;
}
function isApiOperationalRoute(pathname: string) {
const normalizedPathname = pathname.startsWith("/") ? pathname : `/${pathname}`;
const routePathname = normalizedPathname.replace(/\.data$/, "");
return routePathname === "/healthz" || routePathname === "/readyz";
}
function getRootHostCanonicalRedirect(pathname: string, context: HostRoutingContext, config: DomainRoutingConfig) {
if (context !== "root") {
return null;
}
const normalizedPathname = pathname.startsWith("/") ? pathname : `/${pathname}`;
if (isLegacyApiV1HostRoute(normalizedPathname)) {
return {
origin: `${config.protocol}://${config.apiHost}`,
pathname: canonicalApiPathFromLegacyApiV1(normalizedPathname),
};
}
if (isLegacyAppHostRoute(normalizedPathname)) {
const canonicalPathname = canonicalTenantPathFromLegacyApp(normalizedPathname);
if (isDisabledTenantHostRoute(canonicalPathname)) {
return null;
}
return {
origin: `${config.protocol}://${config.webAppHost}`,
pathname: canonicalPathname,
};
}
if (isRemovedAdminPrefixRoute(normalizedPathname)) {
return null;
}
if (normalizedPathname === "/" || isPublicWebRoutePath(normalizedPathname) || isFrameworkInternalRoute(normalizedPathname)) {
return null;
}
if (isDisabledPluginHostRoute(normalizedPathname)) {
return null;
}
if (isActiveWebInternalRoute(normalizedPathname)) {
return {
origin: `${config.protocol}://${config.webAppHost}`,
pathname: normalizedPathname,
};
}
if (isCanonicalAdminHostRoute(normalizedPathname)) {
return {
origin: `${config.protocol}://${config.adminAppHost}`,
pathname: normalizedPathname,
};
}
if (isCanonicalApiHostRoute(normalizedPathname)) {
return {
origin: `${config.protocol}://${config.apiHost}`,
pathname: normalizedPathname,
};
}
return {
origin: `${config.protocol}://${config.webAppHost}`,
pathname: normalizedPathname,
};
}
function isDisabledPluginHostRoute(pathname: string) {
return isDisabledAdminHostRoute(pathname) || isDisabledTenantHostRoute(pathname);
}
const disabledAdminHostRoutePrefixes = ["/__workflows", "/internal/ai", "/internal/api/__workflows", "/internal/api/workflows", "/workflows"] as const;
const disabledTenantHostRouteChildPrefixes = [
"/__portals",
"/__widgets",
"/__workflows",
"/chat",
"/crm",
"/email-marketing",
"/internal/ai",
"/portals",
"/sites",
"/widgets",
"/workflow-engine",
] as const;
function isDisabledAdminHostRoute(pathname: string) {
const routePathname = normalizeRoutePath(pathname);
const firstSegment = trimPath(routePathname).split("/")[0];
if (firstSegment && (disabledPluginRouteSlugs as readonly string[]).includes(firstSegment)) {
return true;
}
return disabledAdminHostRoutePrefixes.some((prefix) => pathMatchesRoutePrefix(routePathname, prefix));
}
function isDisabledTenantHostRoute(pathname: string) {
const routePathname = normalizeRoutePath(pathname);
const [, ...childSegments] = trimPath(routePathname).split("/").filter(Boolean);
if (childSegments.length === 0) {
return false;
}
const childPathname = `/${childSegments.join("/")}`;
return disabledTenantHostRouteChildPrefixes.some((prefix) => pathMatchesRoutePrefix(childPathname, prefix));
}
function getCrossHostCanonicalRedirect(pathname: string, context: HostRoutingContext, config: DomainRoutingConfig) {
const normalizedPathname = pathname.startsWith("/") ? pathname : `/${pathname}`;
if (isLegacyApiV1HostRoute(normalizedPathname)) {
return {
origin: `${config.protocol}://${config.apiHost}`,
pathname: canonicalApiPathFromLegacyApiV1(normalizedPathname),
};
}
if (isLegacyAppHostRoute(normalizedPathname)) {
const canonicalPathname = canonicalTenantPathFromLegacyApp(normalizedPathname);
if (isDisabledTenantHostRoute(canonicalPathname)) {
return null;
}
return {
origin: `${config.protocol}://${config.webAppHost}`,
pathname: canonicalPathname,
};
}
if (context === "api" && isUnversionedApiHostAlias(normalizedPathname)) {
return {
origin: `${config.protocol}://${config.apiHost}`,
pathname: apiPath(normalizedPathname, { mode: "host-aware" }),
};
}
if ((normalizedPathname === "/" || isPublicWebRoutePath(normalizedPathname)) && context === "api" && !isApiOperationalRoute(normalizedPathname)) {
return {
origin: `${config.protocol}://${config.webAppHost}`,
pathname: normalizedPathname,
};
}
if (normalizedPathname === "/" || isPublicWebRoutePath(normalizedPathname) || isFrameworkInternalRoute(normalizedPathname)) {
return null;
}
if (isDisabledPluginHostRoute(normalizedPathname)) {
return null;
}
if (isActiveWebInternalRoute(normalizedPathname)) {
return context === "app"
? null
: {
origin: `${config.protocol}://${config.webAppHost}`,
pathname: normalizedPathname,
};
}
if (isCanonicalApiHostRoute(normalizedPathname) && context !== "api") {
return {
origin: `${config.protocol}://${config.apiHost}`,
pathname: normalizedPathname,
};
}
if (isCanonicalAdminHostRoute(normalizedPathname) && context !== "admin") {
return {
origin: `${config.protocol}://${config.adminAppHost}`,
pathname: normalizedPathname,
};
}
if ((context === "admin" || context === "api") && isCanonicalTenantHostRoute(normalizedPathname)) {
return {
origin: `${config.protocol}://${config.webAppHost}`,
pathname: normalizedPathname,
};
}
return null;
}
function getHostAwareInternalPathWithoutDoublePrefix(pathname: string, context: HostRoutingContext): string {
const normalizedPathname = pathname.startsWith("/") ? pathname : `/${pathname}`;
if (context === "app" && isLegacyAppHostRoute(normalizedPathname)) {
return getHostAwareInternalPath(canonicalTenantPathFromLegacyApp(normalizedPathname), context);
}
return getHostAwareInternalPath(normalizedPathname, context);
}
function isCanonicalTenantHostRoute(pathname: string) {
const routePathname = normalizeRoutePath(pathname);
const [, firstSegment, secondSegment] = routePathname.match(/^\/([^/?#]+)(?:\/([^/?#]+))?/) ?? [];
if (isDisabledTenantHostRoute(routePathname)) {
return false;
}
if (!firstSegment || isCanonicalAdminHostRoute(routePathname) || isWebRoutePath(routePathname) || firstSegment === "v1") {
return false;
}
if ((reservedTenantRouteSlugs as readonly string[]).includes(firstSegment)) {
return false;
}
if (!secondSegment) {
return true;
}
const canonicalTenantSegments = new Set([
"$entity",
"apps",
"dashboard",
"g",
"internal",
"notifications",
"settings",
]);
if ((reservedTenantChildRouteSlugs as readonly string[]).includes(secondSegment) && !canonicalTenantSegments.has(secondSegment)) {
return false;
}
return Boolean(secondSegment && (canonicalTenantSegments.has(secondSegment) || !secondSegment.startsWith("__")));
}
function isUnversionedApiHostAlias(pathname: string) {
if (isApiOperationalRoute(pathname) || isCanonicalApiHostRoute(pathname) || isFrameworkInternalRoute(pathname)) {
return false;
}
const normalizedPathname = pathname.startsWith("/") ? pathname : `/${pathname}`;
const firstSegment = trimPath(normalizedPathname).split("/")[0];
if (!firstSegment) {
return false;
}
return [
"accounts",
"ai",
"analytics",
"api-keys",
"auth",
"billing",
"docs",
"entities",
"events",
"exports",
"health",
"maintenance",
"notifications",
"openapi.json",
"relationships",
"session",
"usage",
"workspaces",
].includes(firstSegment);
}
function toUrl(input: UrlInput) {
if (input instanceof URL) {
return input;
}
if (input instanceof Request) {
return new URL(input.url);
}
return new URL(input);
}
function getHostFromInput(input: UrlInput, url: URL, config: DomainRoutingConfig) {
if (input instanceof Request) {
return getRequestRoutingHost(input, url, config);
}
return url.host;
}
export function getRequestRoutingHost(request: Request, url: URL = new URL(request.url), config: DomainRoutingConfig = getDomainRoutingConfig()) {
const directHost = request.headers.get("host") ?? url.host;
const directContext = getHostRoutingContext(directHost, config);
if (directContext === "app" || directContext === "admin" || directContext === "api") {
return directHost;
}
const forwardedHost = request.headers
.get("x-forwarded-host")
?.split(",")
.map((value) => value.trim())
.find(Boolean);
if (forwardedHost) {
return forwardedHost;
}
return directHost;
}
@@ -0,0 +1,58 @@
import { describe, expect, test } from "vitest";
import {
isAbsoluteWebHostPath,
isPublicWebRoutePath,
isWebRoutePath,
reservedTenantChildRouteSlugs,
reservedTenantRouteSlugs,
} from "./PublicRouteSegments";
describe("public route segments", () => {
test("classifies top-level public routes that must not become tenant slugs", () => {
for (const pathname of [
"/docs",
"/healthcheck",
"/healthz",
"/readyz",
"/mfa-challenge",
"/unauthorized/foo",
"/401",
"/403",
"/404",
"/500",
]) {
expect(isPublicWebRoutePath(pathname), pathname).toBe(true);
}
});
test("keeps protected top-level web routes routable without classifying them public", () => {
for (const pathname of ["/swagger", "/postman_collection.json"]) {
expect(isWebRoutePath(pathname), pathname).toBe(true);
expect(isPublicWebRoutePath(pathname), pathname).toBe(false);
}
});
test("does not classify disabled or plugin-owned route trees as routable web paths", () => {
for (const pathname of ["/mcp", "/help/getting-started", "/webhooks/events/accounts/created", "/public/contact/row_1"]) {
expect(isWebRoutePath(pathname), pathname).toBe(false);
expect(isPublicWebRoutePath(pathname), pathname).toBe(false);
expect(isAbsoluteWebHostPath(pathname), pathname).toBe(false);
}
});
test("requires segment boundaries for public prefixes", () => {
for (const pathname of ["/login-extra", "/settingsx", "/api/workflows/run/workflow_1", "/api/workflowsx/run"]) {
expect(isPublicWebRoutePath(pathname), pathname).toBe(false);
}
});
test("classifies settings as a canonical host route, not a public web route", () => {
expect(isPublicWebRoutePath("/settings/profile")).toBe(false);
expect(isAbsoluteWebHostPath("/settings/profile")).toBe(false);
});
test("exports reserved slugs for route collision checks", () => {
expect(reservedTenantRouteSlugs).toEqual(expect.arrayContaining(["accounts", "analytics", "api", "crm", "docs", "help", "healthcheck", "healthz", "readyz", "swagger", "mcp", "mfa-challenge"]));
expect(reservedTenantChildRouteSlugs).toEqual(expect.arrayContaining(["settings", "crm", "email-marketing", "workflow-engine", "g", "chat", "internal", "widgets"]));
});
});
@@ -0,0 +1,147 @@
export const publicWebRouteExactPaths = ["/sitemap.xml"] as const;
export const protectedWebRouteExactPaths = ["/postman_collection.json"] as const;
export const publicWebRoutePrefixes = [
"/401",
"/403",
"/404",
"/500",
"/auth/",
"/deactivated/",
"/docs",
"/feature-disabled",
"/forgot-password",
"/healthcheck",
"/healthz",
"/invitation/",
"/login",
"/logout",
"/mfa-challenge",
"/new-account",
"/oauth/",
"/onboarding/",
"/plan-required/",
"/preferences",
"/privacy-policy",
"/readyz",
"/register",
"/reset",
"/select-tenant",
"/sitemap.xml",
"/subscribe/",
"/terms-and-conditions",
"/unauthorized",
"/verify/",
] as const;
export const protectedWebRoutePrefixes = ["/swagger"] as const;
export const absoluteWebHostRoutePrefixes = publicWebRoutePrefixes;
export const canonicalAdminRouteSlugs = [
"accounts",
"analytics",
"api",
"audit-trails",
"dashboard",
"email-sequences",
"entities",
"events",
"feature-flags",
"internal",
"marketing-funnels",
"marketing-integrations",
"marketing-leads",
"metrics",
"mfa-challenge",
"navigation",
"notifications",
"onboarding",
"settings",
"setup",
] as const;
export const disabledPluginRouteSlugs = [
"ai",
"chat",
"crm",
"email-marketing",
"help",
"help-desk",
"knowledge-base",
"mcp",
"portals",
"prompts",
"workflow-engine",
] as const;
export const reservedTenantRouteSlugs = [
"admin",
"api",
"app",
"null",
"undefined",
"v1",
...canonicalAdminRouteSlugs,
...disabledPluginRouteSlugs,
...new Set(
[...publicWebRoutePrefixes, ...protectedWebRoutePrefixes]
.flatMap((prefix) => prefix.split("/").filter(Boolean).slice(0, 1))
.concat(protectedWebRouteExactPaths.flatMap((path) => path.split("/").filter(Boolean).slice(0, 1)))
.filter(Boolean)
),
] as const;
export const reservedTenantChildRouteSlugs = [
"__portals",
"__widgets",
"__workflows",
"chat",
"crm",
"dashboard",
"email-marketing",
"g",
"internal",
"notifications",
"portals",
"settings",
"sites",
"widgets",
"workflow-engine",
] as const;
export function pathMatchesRoutePrefix(pathname: string, prefix: string) {
const normalizedPrefix = prefix.replace(/\/$/, "");
return pathname === normalizedPrefix || pathname.startsWith(`${normalizedPrefix}/`);
}
export function normalizeRoutePath(pathname: string) {
const normalizedPathname = pathname.startsWith("/") ? pathname : `/${pathname}`;
return normalizedPathname.replace(/\.data$/, "");
}
export function isPublicWebRoutePath(pathname: string) {
const routePathname = normalizeRoutePath(pathname);
if ((publicWebRouteExactPaths as readonly string[]).includes(routePathname)) {
return true;
}
return publicWebRoutePrefixes.some((prefix) => pathMatchesRoutePrefix(routePathname, prefix));
}
export function isWebRoutePath(pathname: string) {
const routePathname = normalizeRoutePath(pathname);
if (isPublicWebRoutePath(routePathname)) {
return true;
}
if ((protectedWebRouteExactPaths as readonly string[]).includes(routePathname)) {
return true;
}
return protectedWebRoutePrefixes.some((prefix) => pathMatchesRoutePrefix(routePathname, prefix));
}
export function isAbsoluteWebHostPath(pathname: string) {
const routePathname = normalizeRoutePath(pathname);
if ((publicWebRouteExactPaths as readonly string[]).includes(routePathname)) {
return true;
}
return absoluteWebHostRoutePrefixes.some((prefix) => pathMatchesRoutePrefix(routePathname, prefix));
}
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { safeRedirectPath } from "./RedirectSafety.server";
describe("safeRedirectPath", () => {
it("rejects removed route aliases and canonical API paths", () => {
expect(safeRedirectPath("/app/acme/dashboard")).toBe("/");
expect(safeRedirectPath("/admin/settings")).toBe("/");
expect(safeRedirectPath("/api/v1/health")).toBe("/");
expect(safeRedirectPath("/v1/health")).toBe("/");
});
it("keeps same-host application paths", () => {
expect(safeRedirectPath("/dashboard?tab=activity#top")).toBe("/dashboard?tab=activity#top");
});
it("rejects control characters and encoded slash or backslash redirects", () => {
expect(safeRedirectPath("/dashboard%0d%0aLocation:%20//evil.example")).toBe("/");
expect(safeRedirectPath("/%2f%2fevil.example")).toBe("/");
expect(safeRedirectPath("/%5c%5cevil.example")).toBe("/");
expect(safeRedirectPath("/dashboard\u0000")).toBe("/");
});
});
@@ -0,0 +1,51 @@
type SafeRedirectOptions = {
fallbackPath?: string;
};
export function safeRedirectPath(value: string | null | undefined, options: SafeRedirectOptions = {}) {
const fallbackPath = options.fallbackPath ?? "/";
if (!value) {
return fallbackPath;
}
const trimmed = value.trim();
if (
!trimmed ||
!trimmed.startsWith("/") ||
trimmed.startsWith("//") ||
trimmed.includes("\\") ||
/[\u0000-\u001f\u007f]/.test(trimmed) ||
/%(?:2f|5c|00|0a|0d)/i.test(trimmed)
) {
return fallbackPath;
}
try {
const parsed = new URL(trimmed, "http://local.invalid");
if (isRemovedRoutePath(parsed.pathname)) {
return fallbackPath;
}
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
} catch {
return fallbackPath;
}
}
export function safeRedirectPathFromRequest(request: Request, value: string | null | undefined) {
const url = new URL(request.url);
return safeRedirectPath(value, { fallbackPath: `${url.pathname}${url.search}${url.hash}` });
}
export function isRemovedRoutePath(pathname: string) {
const normalizedPathname = pathname.replace(/\/+$/g, "") || "/";
return (
normalizedPathname === "/app" ||
normalizedPathname.startsWith("/app/") ||
normalizedPathname === "/admin" ||
normalizedPathname.startsWith("/admin/") ||
normalizedPathname === "/api" ||
normalizedPathname.startsWith("/api/") ||
normalizedPathname === "/v1" ||
normalizedPathname.startsWith("/v1/")
);
}
@@ -0,0 +1,26 @@
const validateEmail = (value: string | undefined) => {
if (!value) {
return false;
}
return /^\w+([\\.-]?\w+)*@\w+([\\.-]?\w+)*(\.\w{2,3})+$/.test(value);
};
const validateUrl = (value: string | undefined) => {
if (!value) {
return false;
}
return /^(?:(ftp|http|https)?:\/\/)?(?:[\w-]+\.)+([a-z]|[A-Z]|[0-9]){2,}(?:\/.*)?$/gi.test(value);
};
const validatePhone = (value: string | undefined) => {
if (!value) {
return false;
}
return /^\+?(\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}$/.test(value);
};
export default {
validateEmail,
validateUrl,
validatePhone,
};
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { subscriptionCheckoutPath, subscriptionRequiredCheckoutPath } from "./SubscriptionUrlUtils";
describe("subscription URL helpers", () => {
it("builds canonical public checkout routes", () => {
expect(subscriptionCheckoutPath("acme")).toBe("/subscribe/acme");
expect(subscriptionCheckoutPath("acme", "{CHECKOUT_SESSION_ID}/success")).toBe("/subscribe/acme/{CHECKOUT_SESSION_ID}/success");
expect(subscriptionRequiredCheckoutPath("acme")).toBe("/subscribe/acme?error=subscription_required");
});
it("encodes tenant slugs as one route segment", () => {
expect(subscriptionCheckoutPath(" Acme Team/West ")).toBe("/subscribe/Acme%20Team%2FWest");
expect(() => subscriptionCheckoutPath(" ")).toThrow("tenantSlug is required");
});
});
@@ -0,0 +1,23 @@
export function subscriptionCheckoutPath(tenantSlug: string, path?: string) {
const tenantSegment = tenantSlug.trim();
if (!tenantSegment) {
throw new Error("tenantSlug is required");
}
const encodedTenant = encodeURIComponent(tenantSegment);
const normalizedPath = path?.trim();
if (!normalizedPath) {
return `/subscribe/${encodedTenant}`;
}
if (normalizedPath.startsWith("?") || normalizedPath.startsWith("#")) {
return `/subscribe/${encodedTenant}${normalizedPath}`;
}
return `/subscribe/${encodedTenant}/${normalizedPath.replace(/^\/+|\/+$/g, "")}`;
}
export function subscriptionRequiredCheckoutPath(tenantSlug: string) {
return subscriptionCheckoutPath(tenantSlug, "?error=subscription_required");
}
@@ -0,0 +1,67 @@
import { vi, describe, it, expect } from "vitest";
import SubscriptionUtils from "./SubscriptionUtils";
import { SubscriptionBillingPeriod } from "~/application/enums/subscriptions/SubscriptionBillingPeriod";
import { Prisma } from "@toolctrl/db/prisma-browser";
import DateUtils from "../shared/DateUtils";
describe("getProductTitle", () => {
const t = vi.fn().mockImplementation((v: string, options?: Record<string, unknown>) => {
if (v === "settings.subscription.endsRelative") {
return `Ends ${options?.date}`;
}
return v;
}) as any;
const item = {
subscriptionProduct: { title: "Product 1" },
prices: [{ subscriptionPrice: { price: new Prisma.Decimal(10), billingPeriod: SubscriptionBillingPeriod.MONTHLY } }],
endsAt: new Date(),
} as unknown as any;
it("returns expected product title", () => {
const expectedTitle = "Product 1 $10.00 - pricing.MONTHLY (Ends 1 day ago)";
DateUtils.dateAgo = vi.fn().mockImplementation(() => "1 day ago");
const title = SubscriptionUtils.getProductTitle({ t, item });
expect(title).toEqual(expectedTitle);
expect(t).toHaveBeenCalledWith("settings.subscription.endsRelative", { date: "1 day ago" });
});
});
describe("getPriceDescription", () => {
const t = vi.fn().mockImplementation((v: string) => v) as any;
const item = {
subscriptionProduct: { title: "Product 1" },
price: new Prisma.Decimal(10),
billingPeriod: SubscriptionBillingPeriod.MONTHLY,
} as unknown as any;
it("returns expected price description", () => {
const expectedDescription = "Product 1 - $10.00 - pricing.MONTHLY";
// @ts-ignore
const description = SubscriptionUtils.getPriceDescription(t, item);
expect(description).toEqual(expectedDescription);
});
});
describe("getBillingPeriodDescription", () => {
const billingPeriod = SubscriptionBillingPeriod.MONTHLY;
it("returns expected billing period description", () => {
const expectedDescription = "pricing.MONTHLY";
const t = vi.fn().mockImplementation((v: string) => v) as any;
const description = SubscriptionUtils.getBillingPeriodDescription(t, billingPeriod);
expect(description).toEqual(expectedDescription);
});
});
describe("getBillingPeriodShortDescription", () => {
const t = vi.fn().mockImplementation((v: string) => v) as any;
it("maps all supported period labels through pricing keys", () => {
expect(SubscriptionUtils.getBillingPeriodShortDescription(t, SubscriptionBillingPeriod.ONCE)).toEqual("pricing.once");
expect(SubscriptionUtils.getBillingPeriodShortDescription(t, SubscriptionBillingPeriod.MONTHLY)).toEqual("pricing.MONTHLYShort");
expect(SubscriptionUtils.getBillingPeriodShortDescription(t, SubscriptionBillingPeriod.YEARLY)).toEqual("pricing.YEARLYShort");
expect(SubscriptionUtils.getBillingPeriodShortDescription(t, SubscriptionBillingPeriod.QUARTERLY)).toEqual("pricing.QUARTERLYShort");
expect(SubscriptionUtils.getBillingPeriodShortDescription(t, SubscriptionBillingPeriod.SEMI_ANNUAL)).toEqual("pricing.SEMIANNUALShort");
});
});
@@ -0,0 +1,61 @@
import { TFunction } from "i18next";
import { SubscriptionBillingPeriod } from "~/application/enums/subscriptions/SubscriptionBillingPeriod";
import { SubscriptionPriceWithProduct } from "../db/subscriptionProducts.db.server";
import DateUtils from "../shared/DateUtils";
import { getFormattedPriceInCurrency } from "../helpers/PricingHelper";
import { TenantSubscriptionProductWithDetails } from "../db/subscriptions/tenantSubscriptionProducts.db.server";
function formatSubscriptionPrice(price: number, currency?: string | null) {
return getFormattedPriceInCurrency({ price, currency: currency ?? "usd" });
}
function getProductTitle({ t, item }: { t: TFunction; item: TenantSubscriptionProductWithDetails }) {
let title =
t(item.subscriptionProduct.title) +
" " +
item.prices
.map(
(f) =>
`${formatSubscriptionPrice(Number(f.subscriptionPrice?.price ?? 0), f.subscriptionPrice?.currency)} - ${getBillingPeriodDescription(
t,
f.subscriptionPrice!.billingPeriod
)}`
)
.join(", ");
if (item.endsAt) {
title += ` (${t("settings.subscription.endsRelative", { date: DateUtils.dateAgo(item.endsAt) })})`;
}
return title;
}
function getPriceDescription(t: TFunction, item: SubscriptionPriceWithProduct) {
return `${t(item.subscriptionProduct.title)} - ${formatSubscriptionPrice(Number(item.price), item.currency)} - ${getBillingPeriodDescription(t, item.billingPeriod)}`;
}
function getBillingPeriodDescription(t: TFunction, billingPeriod: SubscriptionBillingPeriod) {
return t("pricing." + SubscriptionBillingPeriod[billingPeriod].toString()).toString();
}
function getBillingPeriodShortDescription(t: TFunction, billingPeriod: SubscriptionBillingPeriod) {
switch (billingPeriod) {
case SubscriptionBillingPeriod.MONTHLY:
return t("pricing.MONTHLYShort").toString();
case SubscriptionBillingPeriod.YEARLY:
return t("pricing.YEARLYShort").toString();
case SubscriptionBillingPeriod.QUARTERLY:
return t("pricing.QUARTERLYShort").toString();
case SubscriptionBillingPeriod.SEMI_ANNUAL:
return t("pricing.SEMIANNUALShort").toString();
case SubscriptionBillingPeriod.ONCE:
return t("pricing.once").toString();
default:
return getBillingPeriodDescription(t, billingPeriod);
}
}
export default {
getProductTitle,
getPriceDescription,
getBillingPeriodDescription,
getBillingPeriodShortDescription,
};
@@ -0,0 +1,14 @@
import { TFunction } from "i18next";
import { TenantWithUsage } from "../db/tenants.db.server";
import SubscriptionUtils from "./SubscriptionUtils";
function getProducts({ t, item }: { t: TFunction; item: TenantWithUsage }) {
if (item.subscription?.products) {
return item.subscription.products.map((item) => SubscriptionUtils.getProductTitle({ t, item })).join(", ");
}
return "";
}
export default {
getProducts,
};
@@ -0,0 +1,77 @@
import { describe, test, expect } from "vitest";
import UrlUtils from "./UrlUtils";
describe("UrlUtils", () => {
test("stripTrailingSlash should remove the trailing slash from a string", () => {
const input = "foo/bar/";
const expectedOutput = "foo/bar";
const actualOutput = UrlUtils.stripTrailingSlash(input);
expect(actualOutput).toEqual(expectedOutput);
});
test("currentTenantUrl should return a URL with the tenant parameter", () => {
const params = { tenant: "foo" };
const expectedOutput = "/foo/";
const actualOutput = UrlUtils.currentTenantUrl(params);
expect(actualOutput).toEqual(expectedOutput);
});
test("currentTenantUrl should normalize child paths through canonical legacy builder", () => {
expect(UrlUtils.currentTenantUrl({ tenant: "foo" }, "/settings/profile/")).toEqual("/foo/settings/profile");
});
test("currentEntityUrl should return a URL with the tenant and entity parameters", () => {
const params = { tenant: "foo", entity: "bar" };
const expectedOutput = "/foo/bar";
const actualOutput = UrlUtils.currentEntityUrl(params);
expect(actualOutput).toEqual(expectedOutput);
});
test("replaceVariables should replace the :tenant variable in a URL with the tenant parameter", () => {
const params = { tenant: "foo" };
const input = "/api/:tenant/foo";
const expectedOutput = "/api/foo/foo";
const actualOutput = UrlUtils.replaceVariables(params, input);
expect(actualOutput).toEqual(expectedOutput);
});
test("slugify should convert a string into a URL-friendly slug", () => {
const input = "Hello, world!";
const expectedOutput = "hello-world";
const actualOutput = UrlUtils.slugify(input);
expect(actualOutput).toEqual(expectedOutput);
});
test("getParentRoute should return the parent route of a given URL", () => {
const input = "/foo/bar/baz";
const expectedOutput = "/foo/bar";
const actualOutput = UrlUtils.getParentRoute(input);
expect(actualOutput).toEqual(expectedOutput);
});
test("getModulePath should keep legacy tenant module rules through canonical builders", () => {
expect(UrlUtils.getModulePath({ tenant: "foo" }, "entities/contact")).toEqual("/foo/settings/entities/contact");
expect(UrlUtils.getModulePath({ tenant: "foo" }, "api/keys")).toEqual("/foo/settings/api/keys");
expect(UrlUtils.getModulePath({ tenant: "foo" }, "workflow-engine/workflows")).toEqual("/foo/workflow-engine/workflows");
expect(UrlUtils.getModulePath({}, "accounts")).toEqual("/accounts");
});
describe("feature URL contracts", () => {
const tenantParams = { tenant: "acme" };
test("Rows and Groups keep tenant and grouped entity paths", () => {
expect(UrlUtils.currentTenantUrl(tenantParams, "contacts")).toEqual("/acme/contacts");
expect(UrlUtils.currentTenantUrl(tenantParams, "g/sales/contacts")).toEqual("/acme/g/sales/contacts");
expect(UrlUtils.currentEntityUrl({ ...tenantParams, entity: "contacts" })).toEqual("/acme/contacts");
});
test("Workflow Engine remains a direct tenant module instead of settings", () => {
expect(UrlUtils.getModulePath(tenantParams, "workflow-engine/workflows")).toEqual("/acme/workflow-engine/workflows");
});
test("Portals and Sites remain direct tenant modules", () => {
expect(UrlUtils.getModulePath(tenantParams, "portals/customer/pages")).toEqual("/acme/portals/customer/pages");
expect(UrlUtils.getModulePath(tenantParams, "sites/storefront/site_123/settings")).toEqual("/acme/sites/storefront/site_123/settings");
});
});
});
@@ -0,0 +1,105 @@
import { Params } from "react-router";
import slugify from "@sindresorhus/slugify";
import { adminPath, appPath } from "./CanonicalUrlUtils";
const stripTrailingSlash = (str: string) => {
return str.endsWith("/") ? str.slice(0, -1) : str;
};
const currentTenantUrl = (params: Params, path?: string) => {
const { tenant } = params;
if (!tenant) {
return "/";
}
return path ? appPath({ tenantSlug: tenant, path, mode: "host-aware" }) : `${appPath({ tenantSlug: tenant, mode: "host-aware" })}/`;
};
const currentEntityUrl = (params: Params) => {
const currentTenant = stripTrailingSlash(currentTenantUrl(params));
return `${currentTenant}/${params.entity}`;
};
const replaceVariables = (params: Params, path?: string) => {
return path?.replace(":tenant", params.tenant ?? "");
};
// https://mhagemann.medium.com/the-ultimate-way-to-slugify-a-url-string-in-javascript-b8e4a0d849e1
// const slugify = (str: string, max: number = 100) => {
// const a = "àáâäæãåāăąçćčđďèéêëēėęěğǵḧîïíīįìıİłḿñńǹňôöòóœøōõőṕŕřßśšşșťțûüùúūǘůűųẃẍÿýžźż·/_,:;";
// const b = "aaaaaaaaaacccddeeeeeeeegghiiiiiiiilmnnnnoooooooooprrsssssttuuuuuuuuuwxyyzzz------";
// const p = new RegExp(a.split("").join("|"), "g");
// return (
// str
// .toString()
// .toLowerCase()
// .replace(/\s+/g, "-") // Replace spaces with -
// .replace(p, (c) => b.charAt(a.indexOf(c))) // Replace special characters
// .replace(/&/g, "-and-") // Replace & with 'and'
// // .replace(/[^\w\-]+/g, "") // Remove all non-word characters
// // eslint-disable-next-line no-useless-escape
// .replace(/\-\-+/g, "-") // Replace multiple - with single -
// .replace(/^-+/, "") // Trim - from start of text
// .replace(/-+$/, "")
// ); // Trim - from end of text
// };
function getParentRoute(pathname: string) {
const url = stripTrailingSlash(pathname);
const parentRoute = url.substring(0, url.lastIndexOf("/"));
return parentRoute;
}
function getModulePath(params: Params, path: string) {
if (params.tenant) {
let modulePath = path;
if (path.startsWith("entities")) {
modulePath = `settings/${path}`;
} else if (path.startsWith("api")) {
modulePath = `settings/${path}`;
} else if (path.startsWith("workflow-engine")) {
modulePath = path;
} else if (path.startsWith("logs")) {
modulePath = `settings/${path}`;
}
return appPath({ tenantSlug: params.tenant, path: modulePath, mode: "host-aware" });
}
return adminPath(path, { mode: "host-aware" });
}
function join(...paths: string[]) {
return paths.join("/").replace(/\/+/g, "/");
}
function searchParamsToString({ type, searchParams }: { type: "string" | "object"; searchParams: URLSearchParams }) {
searchParams.delete("entity");
searchParams.delete("id");
function paramsToObject(entries: any) {
const result: any = {};
for (const [key, value] of entries) {
// each 'entry' is a [key, value] tupple
result[key] = value;
}
return result;
}
const entries = searchParams.entries();
if (type === "object") {
//{abc:"foo",def:"[asf]",xyz:"5"}
const asObject = paramsToObject(entries);
return JSON.stringify(asObject);
}
// abc=foo&def=[asf]&xyz=5
return searchParams.toString();
}
export default {
currentTenantUrl,
currentEntityUrl,
stripTrailingSlash,
slugify,
replaceVariables,
getParentRoute,
getModulePath,
join,
searchParamsToString,
};
@@ -0,0 +1,82 @@
import type { TenantUser } from "@toolctrl/db/prisma-browser";
import { TenantUserType } from "~/application/enums/tenants/TenantUserType";
export const avatarText = (user: { firstName: string | null; lastName: string | null; email: string }): string => {
if (user) {
if (user.firstName && user.lastName) {
if (user.firstName.length > 0 && user.lastName.length > 0) {
return (user.firstName[0] + user.lastName[0]).toUpperCase();
} else if (user.firstName.length > 1) {
return user.firstName.substring(0, 2).toUpperCase();
} else if (user.email.length > 1) {
return user.email.substring(0, 2).toUpperCase();
}
} else if (user.firstName) {
return user.firstName.substring(0, 2).toUpperCase();
} else if (user.email) {
return user.email.substring(0, 2).toUpperCase();
}
}
return "--";
};
export function getTenantPrefix(item: { name: string }) {
const words = item.name.split(" ");
if (words.length > 1) {
return (words[0].substring(0, 1) + words[1].substring(0, 1)).toUpperCase();
}
if (item.name.length > 1) {
return item.name.substring(0, 2).toUpperCase();
}
return item.name.substring(0, 1).toUpperCase();
}
export const profileName = (
user: { firstName: string | null; lastName: string | null; email: string | null } | null | undefined,
options: { email?: boolean } = {}
): string => {
let profile = "";
if (user) {
if (user.firstName && user.lastName) {
profile = user.firstName + " " + user.lastName;
} else if (user.firstName) {
profile = user.firstName;
} else {
profile = user.email || "";
}
}
if (options.email && (user?.firstName || user?.lastName) && user?.email) {
return profile + " (" + user?.email + ")";
}
return profile;
};
export const getUserRoleClass = (item: TenantUser) => {
switch (item.type as TenantUserType) {
case TenantUserType.OWNER:
return "bg-secondary text-foreground border border-border";
case TenantUserType.ADMIN:
return "bg-rose-50 border border-rose-200";
case TenantUserType.MEMBER:
return "bg-blue-50 border border-blue-200";
}
};
export const fullName = (user: { firstName: string | null; lastName: string | null; email: string } | null): string => {
if (user) {
if (user.firstName && user.lastName) {
return user.firstName + " " + user.lastName;
} else {
return user.firstName ?? "";
}
}
return "";
};
export default {
avatarText,
profileName,
getTenantPrefix,
getUserRoleClass,
fullName,
};
@@ -0,0 +1,8 @@
export {
avatarText,
fullName,
getTenantPrefix,
getUserRoleClass,
profileName,
} from "./UserDisplayUtils";
export { default } from "./UserDisplayUtils";
@@ -0,0 +1,93 @@
import type { TFunction } from "i18next";
import MailChecker from "mailchecker";
import { validatePassword as validatePolicyPassword } from "~/services/auth/password-policy.server";
export const validateEmail = (email: unknown) => {
const regexp = new RegExp(
// eslint-disable-next-line no-useless-escape
/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
);
if (typeof email !== "string" || email.length < 5 || !regexp.test(email) || email.length > 100) {
return false;
}
if (!MailChecker.isValid(email)) {
return false;
}
return true;
};
/**
* Presence-only check used by the login form to short-circuit before the
* password-verify call. NOT a strength check — strength is enforced by
* validatePolicyPassword() at password-write time so legacy users with
* pre-policy passwords can still sign in. Anything outside the login form
* MUST use {@link validatePasswords} or the underlying policy directly.
*/
export const validatePassword = (password: unknown) => {
if (typeof password !== "string" || password.length < 4) {
return false;
}
return true;
};
/**
* Confirm-then-validate helper for password-WRITE endpoints (register,
* password reset, invitation acceptance). Runs the central policy; returns
* undefined when the password is acceptable, or the first translated error
* string when it isn't. Async because the policy may consult HIBP.
*/
export const validatePasswords = async ({
password,
passwordConfirm,
email,
t,
}: {
password?: string;
passwordConfirm?: string;
email?: string;
t: TFunction;
}): Promise<string | undefined> => {
if (!password || !passwordConfirm) {
return t("shared.isRequired", { 0: t("account.shared.password") });
}
if (password !== passwordConfirm) {
return t("account.shared.passwordMismatch");
}
const result = await validatePolicyPassword(password, email ? { email } : {});
if (!result.valid) {
const firstError = result.errors[0];
if (firstError) {
return t(firstError.code, firstError.args ?? {});
}
return t("auth:errors.invalidPassword");
}
return undefined;
};
export const isSuspicious = ({
email,
firstName,
lastName,
honeypot,
}: {
email: string;
firstName?: string;
lastName?: string;
honeypot?: string;
}): string | undefined => {
if (honeypot) {
return "Honeypot";
} else if (email === firstName) {
return "Email as first name";
} else if (email === lastName) {
return "Email as last name";
}
return undefined;
};
export default {
validateEmail,
validatePassword,
validatePasswords,
isSuspicious,
};
@@ -0,0 +1,154 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import type { TFunction } from "i18next";
import type { AuditExportFilters, AuditExportRow, AuditIntegritySummary } from "~/routes/admin/audit-export.helpers.server";
import {
buildAuditPdfLabels,
buildAuditPdfReportInput,
renderAuditExportPdfResponse,
toAuditPdfEvent,
toAuditPdfIntegrity,
} from "./pdfRender";
function makeRow(over: Partial<AuditExportRow> = {}): AuditExportRow {
return {
id: "audit_1",
createdAt: new Date("2026-05-17T00:00:00.000Z"),
tenantId: "tenant_1",
actorType: "user",
actorId: "user_1",
actorEmail: null,
actorIp: null,
actorUserAgent: null,
action: "widget.created",
targetType: "widget",
targetId: "widget_1",
requestId: null,
sourceModule: "crm",
outcome: "success",
dataSensitivity: "internal",
regulatoryScope: ["soc2"],
metadata: null,
previousHash: null,
eventHash: "h1",
...over,
};
}
const filters: AuditExportFilters = {
from: new Date("2026-05-01T00:00:00.000Z"),
to: new Date("2026-05-17T00:00:00.000Z"),
regulatoryScope: ["soc2"],
actorType: null,
outcome: null,
tenantId: "tenant_1",
};
const integrity: AuditIntegritySummary = { ok: true, inspected: 1, firstBreakId: null, reason: null };
describe("toAuditPdfEvent / toAuditPdfIntegrity", () => {
it("maps an AuditExportRow into AuditPdfEvent unchanged", () => {
const row = makeRow();
expect(toAuditPdfEvent(row)).toMatchObject({
id: row.id,
createdAt: row.createdAt,
tenantId: row.tenantId,
action: row.action,
eventHash: row.eventHash,
});
});
it("maps integrity 1:1", () => {
expect(toAuditPdfIntegrity(integrity)).toEqual(integrity);
});
});
describe("buildAuditPdfReportInput", () => {
it("combines filters + rows + integrity into the renderer input shape", () => {
const rows = [makeRow()];
const input = buildAuditPdfReportInput({ tenantId: "tenant_1", filters, integrity, rows, generatedBy: "ops-bot" });
expect(input.tenantId).toBe("tenant_1");
expect(input.from).toBe("2026-05-01T00:00:00.000Z");
expect(input.to).toBe("2026-05-17T00:00:00.000Z");
expect(input.filterSummary).toContain("regulatoryScope=soc2");
expect(input.events).toHaveLength(1);
expect(input.integrity.ok).toBe(true);
expect(input.generatedBy).toBe("ops-bot");
});
it("passes localized label overrides to the renderer input", () => {
const t = ((key: string, args?: Record<string, unknown>) => {
if (key === "settings:auditExport.pdf.events") {
return `translated events ${args?.count}`;
}
if (key === "settings:auditExport.pdf.integrity.ok") {
return `translated ok ${args?.inspected}`;
}
if (key === "settings:auditExport.pdf.integrity.broken") {
return `translated broken ${args?.firstBreakId}`;
}
return `translated:${key}`;
}) as TFunction;
const input = buildAuditPdfReportInput({
tenantId: "tenant_1",
filters,
integrity,
rows: [makeRow()],
labels: buildAuditPdfLabels(t),
});
expect(input.labels?.tenant).toBe("translated:settings:auditExport.pdf.tenant");
expect(input.labels?.events?.(3)).toBe("translated events 3");
expect(input.labels?.integrityOk?.(1)).toBe("translated ok 1");
expect(input.labels?.integrityBroken?.({ inspected: 1, firstBreakId: "audit_1", reason: "hash" })).toBe("translated broken audit_1");
expect(input.labels?.columns?.createdAt).toBe("translated:settings:auditExport.pdf.columns.createdAt");
});
});
describe("renderAuditExportPdfResponse", () => {
it("returns a Response with PDF content-type and chain-status header", async () => {
const rows = [makeRow()];
const response = await renderAuditExportPdfResponse(
{ tenantId: "tenant_1", filters, integrity, rows },
{ filenamePrefix: "audit-export-admin" }
);
expect(response.headers.get("Content-Type")).toBe("application/pdf");
expect(response.headers.get("X-Audit-Chain-Status")).toBe("ok");
const disposition = response.headers.get("Content-Disposition") ?? "";
expect(disposition).toContain("audit-export-admin");
expect(disposition).toContain("tenant_1");
const bytes = new Uint8Array(await response.arrayBuffer());
expect(bytes.length).toBeGreaterThan(0);
expect(Buffer.from(bytes.slice(0, 4)).toString("utf8")).toBe("%PDF");
});
it("marks the chain status as broken when integrity.ok=false", async () => {
const rows = [makeRow()];
const response = await renderAuditExportPdfResponse(
{
tenantId: "tenant_1",
filters,
integrity: { ok: false, inspected: 1, firstBreakId: "audit_1", reason: "hash-mismatch" },
rows,
},
{ filenamePrefix: "audit-export-admin" }
);
expect(response.headers.get("X-Audit-Chain-Status")).toBe("broken");
});
it("falls back to 'platform' in the filename when tenantId is null", async () => {
const rows: AuditExportRow[] = [];
const response = await renderAuditExportPdfResponse(
{
tenantId: null,
filters: { ...filters, tenantId: null },
integrity: { ok: true, inspected: 0, firstBreakId: null, reason: null },
rows,
},
{ filenamePrefix: "audit-export-admin" }
);
expect(response.headers.get("Content-Disposition") ?? "").toContain("audit-export-admin-platform-");
});
});
@@ -0,0 +1,152 @@
/**
* Phase 10 Task #67 — Inline PDF helper for the admin/tenant audit-export
* routes.
*
* Wraps `serializeAuditEventsToPdf` from `@toolctrl/exports/audit-pdf`
* with the same row + integrity shape the admin route already computes
* for the CSV path. Wired into the export route via `?format=pdf`
* (the default remains CSV).
*
* See docs/improvement-plan/10-compliance-polish/compliance-audit-export.md.
*/
import {
AUDIT_PDF_CONTENT_TYPE,
AUDIT_PDF_HARD_ROW_LIMIT,
serializeAuditEventsToPdf,
summarizeAuditPdfFilters,
type AuditPdfEvent,
type AuditPdfIntegrity,
type AuditPdfLabelOverrides,
type AuditPdfReportInput,
} from "@toolctrl/exports/audit-pdf";
import type { TFunction } from "i18next";
import type { AuditExportFilters, AuditExportRow, AuditIntegritySummary } from "~/routes/admin/audit-export.helpers.server";
export { AUDIT_PDF_CONTENT_TYPE, AUDIT_PDF_HARD_ROW_LIMIT };
/**
* Map an in-memory AuditExportRow (from the existing CSV pipeline) to
* the AuditPdfEvent shape consumed by @toolctrl/exports/audit-pdf.
*
* `metadata` is opaque JSON in Prisma — we keep it as-is; the renderer
* never inlines metadata into the report body (privacy / page-density
* concern).
*/
export function toAuditPdfEvent(row: AuditExportRow): AuditPdfEvent {
return {
id: row.id,
createdAt: row.createdAt,
tenantId: row.tenantId,
actorType: row.actorType,
actorId: row.actorId,
actorEmail: row.actorEmail,
actorIp: row.actorIp,
actorUserAgent: row.actorUserAgent,
action: row.action,
targetType: row.targetType,
targetId: row.targetId,
requestId: row.requestId,
sourceModule: row.sourceModule,
outcome: row.outcome,
dataSensitivity: row.dataSensitivity,
regulatoryScope: row.regulatoryScope,
metadata: row.metadata,
previousHash: row.previousHash,
eventHash: row.eventHash,
};
}
/** Map our AuditIntegritySummary onto AuditPdfIntegrity (1:1 shape). */
export function toAuditPdfIntegrity(integrity: AuditIntegritySummary): AuditPdfIntegrity {
return {
ok: integrity.ok,
inspected: integrity.inspected,
firstBreakId: integrity.firstBreakId,
reason: integrity.reason,
};
}
export type AuditPdfRenderRequest = {
tenantId: string | null;
filters: AuditExportFilters;
integrity: AuditIntegritySummary;
rows: ReadonlyArray<AuditExportRow>;
generatedBy?: string;
generatedAt?: Date;
labels?: AuditPdfLabelOverrides;
};
export function buildAuditPdfLabels(t: TFunction): AuditPdfLabelOverrides {
return {
title: t("settings:auditExport.pdf.title"),
tenant: t("settings:auditExport.pdf.tenant"),
platformTenant: t("settings:auditExport.pdf.platformTenant"),
range: t("settings:auditExport.pdf.range"),
openRange: t("settings:auditExport.pdf.openRange"),
filters: t("settings:auditExport.pdf.filters"),
noFilters: t("settings:auditExport.pdf.noFilters"),
generatedBy: t("settings:auditExport.pdf.generatedBy"),
generatedAt: t("settings:auditExport.pdf.generatedAt"),
hashChainIntegrity: t("settings:auditExport.pdf.hashChainIntegrity"),
events: (count) => t("settings:auditExport.pdf.events", { count }),
integrityOk: (inspected) => t("settings:auditExport.pdf.integrity.ok", { inspected }),
integrityBroken: ({ inspected, firstBreakId, reason }) =>
t("settings:auditExport.pdf.integrity.broken", { inspected, firstBreakId, reason }),
columns: {
createdAt: t("settings:auditExport.pdf.columns.createdAt"),
actorType: t("settings:auditExport.pdf.columns.actorType"),
actorId: t("settings:auditExport.pdf.columns.actorId"),
action: t("settings:auditExport.pdf.columns.action"),
targetType: t("settings:auditExport.pdf.columns.targetType"),
outcome: t("settings:auditExport.pdf.columns.outcome"),
regulatoryScope: t("settings:auditExport.pdf.columns.regulatoryScope"),
eventHash: t("settings:auditExport.pdf.columns.eventHash"),
},
};
}
export function buildAuditPdfReportInput(req: AuditPdfRenderRequest): AuditPdfReportInput {
return {
tenantId: req.tenantId,
from: req.filters.from?.toISOString() ?? null,
to: req.filters.to?.toISOString() ?? null,
filterSummary: summarizeAuditPdfFilters({
regulatoryScope: req.filters.regulatoryScope,
actorType: req.filters.actorType,
outcome: req.filters.outcome,
}),
integrity: toAuditPdfIntegrity(req.integrity),
events: req.rows.map(toAuditPdfEvent),
...(req.generatedBy ? { generatedBy: req.generatedBy } : {}),
...(req.generatedAt ? { generatedAt: req.generatedAt } : {}),
...(req.labels ? { labels: req.labels } : {}),
};
}
/**
* Render the audit-export response as a PDF stream. Returns a
* Web-Response with the appropriate Content-Disposition + X-Audit-Chain-Status
* headers so the route can return it directly.
*/
export async function renderAuditExportPdfResponse(
req: AuditPdfRenderRequest,
options: { filenamePrefix: string }
): Promise<Response> {
const input = buildAuditPdfReportInput(req);
const bytes = await serializeAuditEventsToPdf(input);
const filenameSlug = req.tenantId ?? "platform";
const fromSlug = req.filters.from ? req.filters.from.toISOString().slice(0, 10) : "all";
const toSlug = req.filters.to ? req.filters.to.toISOString().slice(0, 10) : "all";
// Convert Uint8Array (Node Buffer slice) to ArrayBuffer for Response body
// typing — node's Buffer is fine but Response wants a BodyInit. Slicing
// via .buffer keeps the bytes intact.
const body = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
return new Response(body, {
headers: {
"Content-Type": AUDIT_PDF_CONTENT_TYPE,
"Content-Disposition": `attachment; filename="${options.filenamePrefix}-${filenameSlug}-${fromSlug}_${toSlug}.pdf"`,
"X-Audit-Chain-Status": req.integrity.ok ? "ok" : "broken",
},
});
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { extractWebActorIp, extractWebActorUserAgent, extractWebRequestId, getWebAuditContext, withAuditContext } from "./withAuditContext.js";
function makeRequest(headers: Record<string, string>): Request {
return new Request("https://example.com/", { headers });
}
describe("extractWebActorIp", () => {
it("prefers cf-connecting-ip > x-real-ip > x-forwarded-for first hop", () => {
expect(extractWebActorIp(makeRequest({ "cf-connecting-ip": "1.1.1.1", "x-forwarded-for": "2.2.2.2" }))).toBe("1.1.1.1");
expect(extractWebActorIp(makeRequest({ "x-real-ip": "2.2.2.2" }))).toBe("2.2.2.2");
expect(extractWebActorIp(makeRequest({ "x-forwarded-for": "3.3.3.3, 8.8.8.8" }))).toBe("3.3.3.3");
expect(extractWebActorIp(makeRequest({}))).toBeNull();
});
});
describe("extractWebActorUserAgent", () => {
it("returns capped trimmed value or null", () => {
expect(extractWebActorUserAgent(makeRequest({ "user-agent": " ua " }))).toBe("ua");
expect(extractWebActorUserAgent(makeRequest({ "user-agent": "x".repeat(600) })!)?.length).toBe(512);
expect(extractWebActorUserAgent(makeRequest({}))).toBeNull();
});
});
describe("extractWebRequestId", () => {
it("returns trimmed value or null for oversize/missing", () => {
expect(extractWebRequestId(makeRequest({ "x-request-id": "req_1" }))).toBe("req_1");
expect(extractWebRequestId(makeRequest({ "x-request-id": "x".repeat(200) }))).toBeNull();
expect(extractWebRequestId(makeRequest({}))).toBeNull();
});
});
describe("withAuditContext", () => {
it("binds the context for the duration of the callback", async () => {
expect(getWebAuditContext()).toBeNull();
const captured = await withAuditContext({ request: makeRequest({ "cf-connecting-ip": "5.5.5.5", "user-agent": "Test/1.0", "x-request-id": "req_1" }) }, async () => getWebAuditContext());
expect(captured).toEqual({ actorIp: "5.5.5.5", actorUserAgent: "Test/1.0", requestId: "req_1" });
expect(getWebAuditContext()).toBeNull();
});
it("passes through when request is undefined", async () => {
const captured = await withAuditContext({}, async () => getWebAuditContext());
expect(captured).toBeNull();
});
});
@@ -0,0 +1,70 @@
import { AsyncLocalStorage } from "node:async_hooks";
/**
* Phase 10 Task #67 — Audit auto-capture (React-Router web).
*
* The Hono API has its own AsyncLocalStorage-backed audit context (see
* apps/api/src/middleware/audit-context.ts). The React-Router runtime
* has no global middleware hook for loader/action invocation, so we
* expose a thin wrapper that loaders + actions can opt into via
* `withAuditContext(args, async () => { ... })`.
*
* The helper extracts actor IP / user agent / request id from the
* inbound Request headers and binds them into a request-scoped store
* so any `createAuditEvent` invocation deeper in the call stack can
* read them.
*/
export type WebAuditContext = {
actorIp: string | null;
actorUserAgent: string | null;
requestId: string | null;
};
const webAuditContextStore = new AsyncLocalStorage<WebAuditContext>();
const FORWARDED_HEADERS = ["cf-connecting-ip", "x-real-ip", "x-forwarded-for"] as const;
export function extractWebActorIp(request: Request): string | null {
for (const name of FORWARDED_HEADERS) {
const raw = request.headers.get(name);
if (typeof raw !== "string") continue;
const first = raw.split(",")[0]?.trim();
if (first && first.length > 0 && first.length <= 64) return first;
}
return null;
}
export function extractWebActorUserAgent(request: Request): string | null {
const raw = request.headers.get("user-agent");
if (typeof raw !== "string") return null;
const trimmed = raw.trim();
if (trimmed.length === 0) return null;
return trimmed.length > 512 ? trimmed.slice(0, 512) : trimmed;
}
export function extractWebRequestId(request: Request): string | null {
const raw = request.headers.get("x-request-id");
if (typeof raw !== "string") return null;
const trimmed = raw.trim();
return trimmed.length === 0 || trimmed.length > 128 ? null : trimmed;
}
/**
* Wrap a loader/action body with an audit context bound from the inbound
* request. Falls through transparently when no Request is available.
*/
export async function withAuditContext<T>(args: { request: Request } | { request?: Request }, fn: () => Promise<T>): Promise<T> {
const request = (args as { request?: Request }).request;
if (!request) return fn();
const ctx: WebAuditContext = {
actorIp: extractWebActorIp(request),
actorUserAgent: extractWebActorUserAgent(request),
requestId: extractWebRequestId(request),
};
return webAuditContextStore.run(ctx, fn);
}
export function getWebAuditContext(): WebAuditContext | null {
return webAuditContextStore.getStore() ?? null;
}
@@ -0,0 +1,58 @@
import { describe, expect, test } from "vitest";
import type { WebConfig } from "@toolctrl/config";
import { getPublicAuthProviders, getServerAuthProviders } from "./authProviders.server";
const baseWebConfig: WebConfig = {
appName: "Test App",
nodeEnv: "production",
listen: { host: "0.0.0.0", port: 3000 },
routing: {
appBaseDomain: "example.com",
webAppHost: "app.example.com",
adminAppHost: "admin.example.com",
apiHost: "api.example.com",
protocol: "https",
},
stripe: {},
crisp: {},
captcha: {},
analytics: {},
novu: {},
mailerSend: {},
ipProviders: {},
portalServer: {},
magicLink: {
enabled: true,
tokenTtlMinutes: 15,
},
} as unknown as WebConfig;
function requestFor(host = "https://app.example.com/dashboard") {
return new Request(host);
}
describe("authProviders.server", () => {
test("reports magic-link as enabled and configured when enabled in web config", () => {
const providers = getServerAuthProviders({ request: requestFor(), webConfig: baseWebConfig });
expect(providers.magicLink).toEqual({ enabled: true, configured: true });
});
test("public providers only expose the supported magic-link method", () => {
const publicProviders = getPublicAuthProviders({ request: requestFor(), webConfig: baseWebConfig });
expect(Object.keys(publicProviders)).toEqual(["magicLink"]);
expect(publicProviders.magicLink).toEqual({ enabled: true, configured: true });
});
test("normalizes disabled magic-link to a safe disabled unconfigured provider", () => {
const webConfig = {
...baseWebConfig,
magicLink: { enabled: false, tokenTtlMinutes: undefined },
} as WebConfig;
const providers = getServerAuthProviders({ request: requestFor(), webConfig });
expect(providers.magicLink).toEqual({ enabled: false, configured: false });
});
});
@@ -0,0 +1,40 @@
import { loadRequiredWebConfig, type WebConfig } from "@toolctrl/config";
type AuthProviderRequestOptions = {
request?: Request;
webConfig?: WebConfig;
};
// EU-Sterilisation (AD/EU-Stack): social OAuth login (GitHub/Google) is removed.
// Authentication is Email+Password, Magic-Link and TOTP only. Keep this provider
// registry so additional EU-compatible methods can slot in later.
export type PublicAuthProviders = {
magicLink: {
enabled: boolean;
configured: boolean;
};
};
export type ServerAuthProviders = {
magicLink: {
enabled: boolean;
configured: boolean;
};
};
export function getPublicAuthProviders(options: AuthProviderRequestOptions): PublicAuthProviders {
const providers = getServerAuthProviders(options);
return {
magicLink: providers.magicLink,
};
}
export function getServerAuthProviders({ webConfig = loadRequiredWebConfig() }: AuthProviderRequestOptions): ServerAuthProviders {
return {
magicLink: {
enabled: webConfig.magicLink.enabled,
configured: webConfig.magicLink.enabled,
},
};
}
@@ -0,0 +1,116 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createSharedStore } from "@toolctrl/shared-store";
import {
checkMfaChallengeRateLimit,
mfaChallengeRateLimitKey,
recordFailedMfaChallengeAttempt,
resetMfaChallengeRateLimit,
resetMfaChallengeRateLimiterForTests,
__resetMfaChallengeRateLimitStoreForTests,
__setMfaChallengeRateLimitStoreForTests,
} from "./challenge-rate-limit.server";
beforeEach(() => {
__setMfaChallengeRateLimitStoreForTests(createSharedStore({ namespace: "test-mfa-challenge" }));
});
afterEach(async () => {
await resetMfaChallengeRateLimiterForTests();
__resetMfaChallengeRateLimitStoreForTests();
});
describe("MFA challenge rate limiter", () => {
it("keys attempts by user and client IP", () => {
const request = new Request("https://admin.example.test/mfa-challenge", {
headers: { "x-forwarded-for": "203.0.113.5, 10.0.0.1" },
});
expect(mfaChallengeRateLimitKey(request, "user_1")).toBe("user_1:203.0.113.5");
});
it("falls back from blank forwarded headers to real-ip and then unknown", () => {
expect(
mfaChallengeRateLimitKey(
new Request("https://admin.example.test/mfa-challenge", {
headers: { "x-forwarded-for": " ", "x-real-ip": "198.51.100.7" },
}),
"user_1",
),
).toBe("user_1:198.51.100.7");
expect(mfaChallengeRateLimitKey(new Request("https://admin.example.test/mfa-challenge"), "user_1")).toBe(
"user_1:unknown",
);
});
it("blocks after repeated failed attempts and can be reset", async () => {
const key = "user_1:203.0.113.5";
const now = new Date("2026-05-11T12:00:00.000Z");
for (let index = 0; index < 5; index += 1) {
expect((await checkMfaChallengeRateLimit(key, now)).allowed).toBe(true);
await recordFailedMfaChallengeAttempt(key, now);
}
const blocked = await checkMfaChallengeRateLimit(key, now);
expect(blocked.allowed).toBe(false);
if (!blocked.allowed) {
expect(blocked.retryAfterSeconds).toBe(300);
}
await resetMfaChallengeRateLimit(key);
expect((await checkMfaChallengeRateLimit(key, now)).allowed).toBe(true);
});
it("treats non-numeric stored counters as zero remaining attempts", async () => {
const store = createSharedStore({ namespace: "test-mfa-challenge-nan" });
__setMfaChallengeRateLimitStoreForTests(store);
const key = "user_1:203.0.113.5";
const now = new Date("2026-05-11T12:00:00.000Z");
await store.set(`rl:fixed:mfa-challenge:${key}`, "NaN", { ttlSeconds: 300 });
await expect(checkMfaChallengeRateLimit(key, now)).resolves.toEqual({
allowed: true,
remaining: 5,
resetAt: new Date("2026-05-11T12:05:00.000Z"),
});
});
it("resetMfaChallengeRateLimiterForTests clears cached fixed-window keys", async () => {
const key = "user_1:203.0.113.5";
const now = new Date("2026-05-11T12:00:00.000Z");
await recordFailedMfaChallengeAttempt(key, now);
expect((await checkMfaChallengeRateLimit(key, now)).allowed).toBe(true);
await resetMfaChallengeRateLimiterForTests();
const freshStore = createSharedStore({ namespace: "test-mfa-challenge" });
__setMfaChallengeRateLimitStoreForTests(freshStore);
await recordFailedMfaChallengeAttempt(key, now);
expect((await checkMfaChallengeRateLimit(key, now)).allowed).toBe(true);
});
it("uses the redis adapter when REDIS_URL is set", () => {
const originalRedisUrl = process.env.REDIS_URL;
try {
process.env.REDIS_URL = "redis://does-not-exist:6379";
const store = createSharedStore({ namespace: "mfa-redis-selection", redisUrl: process.env.REDIS_URL });
expect(store.adapter.constructor.name).toBe("RedisAdapter");
void store.close();
} finally {
if (originalRedisUrl === undefined) delete process.env.REDIS_URL;
else process.env.REDIS_URL = originalRedisUrl;
}
});
it("uses the memory adapter when REDIS_URL is unset", () => {
const originalRedisUrl = process.env.REDIS_URL;
delete process.env.REDIS_URL;
try {
const store = createSharedStore({ namespace: "mfa-memory-selection" });
expect(store.adapter.constructor.name).toBe("MemoryAdapter");
} finally {
if (originalRedisUrl !== undefined) process.env.REDIS_URL = originalRedisUrl;
}
});
});
@@ -0,0 +1,102 @@
import {
createFixedWindowRateLimiter,
type FixedWindowRateLimiter,
type SharedStore,
} from "@toolctrl/shared-store";
import { getSharedStore } from "~/shared-store.server";
const MFA_CHALLENGE_LIMIT = 5;
const MFA_CHALLENGE_WINDOW_SECONDS = 5 * 60;
export type MfaChallengeRateLimitResult =
| { allowed: true; remaining: number; resetAt: Date }
| { allowed: false; retryAfterSeconds: number; resetAt: Date };
type Limiter = {
limiter: FixedWindowRateLimiter;
store: SharedStore;
};
let cached: Limiter | null = null;
let storeFactory: () => SharedStore = () => getSharedStore();
function getLimiter(): Limiter {
if (cached !== null) return cached;
const store = storeFactory();
cached = {
store,
limiter: createFixedWindowRateLimiter({
store,
prefix: "mfa-challenge",
windowSeconds: MFA_CHALLENGE_WINDOW_SECONDS,
max: MFA_CHALLENGE_LIMIT,
}),
};
return cached;
}
export function mfaChallengeRateLimitKey(request: Request, userId: string) {
const forwardedFor = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
const ip = forwardedFor || request.headers.get("x-real-ip")?.trim() || "unknown";
return `${userId}:${ip}`;
}
export async function checkMfaChallengeRateLimit(key: string, now: Date = new Date()): Promise<MfaChallengeRateLimitResult> {
const { store } = getLimiter();
const suffix = `rl:fixed:mfa-challenge:${key}`;
const raw = await store.get(suffix);
const count = raw === null ? 0 : Number.parseInt(raw, 10) || 0;
if (count >= MFA_CHALLENGE_LIMIT) {
return {
allowed: false,
retryAfterSeconds: MFA_CHALLENGE_WINDOW_SECONDS,
resetAt: new Date(now.getTime() + MFA_CHALLENGE_WINDOW_SECONDS * 1000),
};
}
return {
allowed: true,
remaining: MFA_CHALLENGE_LIMIT - count,
resetAt: new Date(now.getTime() + MFA_CHALLENGE_WINDOW_SECONDS * 1000),
};
}
export async function recordFailedMfaChallengeAttempt(key: string, _now: Date = new Date()): Promise<void> {
const { limiter } = getLimiter();
await limiter.consume(key);
}
export async function resetMfaChallengeRateLimit(key: string): Promise<void> {
const { limiter } = getLimiter();
await limiter.reset(key);
}
export async function resetMfaChallengeRateLimiterForTests(): Promise<void> {
const previous = cached;
cached = null;
if (previous) {
try {
for await (const fullKey of previous.store.adapter.scan(
`shared-store:${previous.store.namespace}:rl:fixed:mfa-challenge:*`,
)) {
const prefix = `shared-store:${previous.store.namespace}:`;
if (fullKey.startsWith(prefix)) {
await previous.store.del(fullKey.slice(prefix.length));
}
}
} catch {
// Best-effort; isolated tests inject a fresh store via the seam.
}
}
}
/** Test seam: inject a custom SharedStore. */
export function __setMfaChallengeRateLimitStoreForTests(store: SharedStore): void {
cached = null;
storeFactory = () => store;
}
/** Test seam: revert to the default web shared-store. */
export function __resetMfaChallengeRateLimitStoreForTests(): void {
cached = null;
storeFactory = () => getSharedStore();
}
@@ -0,0 +1,197 @@
// @vitest-environment node
import { describe, expect, test } from "vitest";
import type { UserTotpStatus } from "~/utils/db/users.db.server";
import type { MfaStepUpCheckResult } from "./step-up.server";
import {
ADMIN_MFA_CHALLENGE_PATH,
enforceAdminMfa,
type AdminMfaEnforcementResult,
} from "./enforcement.server";
const enabledTotp: UserTotpStatus = {
enabled: true,
pending: false,
enabledAt: new Date("2026-05-08T14:00:00.000Z"),
pendingCreatedAt: null,
};
const pendingOnlyTotp: UserTotpStatus = {
enabled: false,
pending: true,
enabledAt: null,
pendingCreatedAt: new Date("2026-05-08T14:00:00.000Z"),
};
const freshStepUp: MfaStepUpCheckResult = {
verified: true,
state: { userId: "admin-1", method: "totp", verifiedAt: "2026-05-08T14:01:00.000Z" },
ageMs: 1_000,
};
function enforce(overrides: Partial<Parameters<typeof enforceAdminMfa>[0]> = {}): AdminMfaEnforcementResult {
return enforceAdminMfa({
requestUrl: "https://admin.example.com/users?tenant=tenant-1#section",
userId: "admin-1",
isAdmin: true,
totpStatus: enabledTotp,
stepUp: freshStepUp,
...overrides,
});
}
describe("enforceAdminMfa", () => {
test("allows admin users with enabled TOTP and fresh step-up", () => {
expect(enforce()).toEqual({
allowed: true,
reason: "allowed",
redirectTo: "/users?tenant=tenant-1#section",
});
});
test("allows admins without active TOTP so the profile enrollment path is opt-in and non-blocking", () => {
expect(enforce({ totpStatus: null })).toEqual({
allowed: true,
reason: "allowed",
redirectTo: "/users?tenant=tenant-1#section",
});
expect(enforce({ totpStatus: { enabled: false, pending: false, enabledAt: null, pendingCreatedAt: null } })).toEqual({
allowed: true,
reason: "allowed",
redirectTo: "/users?tenant=tenant-1#section",
});
expect(enforce({ totpStatus: pendingOnlyTotp })).toEqual({
allowed: true,
reason: "allowed",
redirectTo: "/users?tenant=tenant-1#section",
});
});
test("requires challenge when step-up is missing", () => {
expect(enforce({ stepUp: { verified: false, reason: "missing" } })).toEqual({
allowed: false,
reason: "step-up-missing",
redirectTo: `${ADMIN_MFA_CHALLENGE_PATH}?redirectTo=%2Fusers%3Ftenant%3Dtenant-1%23section`,
});
});
test("accepts URL objects when deriving safe redirects and challenge targets", () => {
expect(enforce({ requestUrl: new URL("https://admin.example.com/users?tenant=tenant-1#section") })).toEqual({
allowed: true,
reason: "allowed",
redirectTo: "/users?tenant=tenant-1#section",
});
expect(
enforce({
requestUrl: new URL("https://admin.example.com/users?tenant=tenant-1#section"),
stepUp: { verified: false, reason: "missing" },
}),
).toEqual({
allowed: false,
reason: "step-up-missing",
redirectTo: `${ADMIN_MFA_CHALLENGE_PATH}?redirectTo=%2Fusers%3Ftenant%3Dtenant-1%23section`,
});
});
test("allows the admin MFA challenge path when requestUrl is already a URL object", () => {
expect(
enforce({
requestUrl: new URL("https://admin.example.com/mfa-challenge?redirectTo=%2Fusers"),
stepUp: { verified: false, reason: "missing" },
}),
).toEqual({
allowed: true,
reason: "allowed",
redirectTo: "/mfa-challenge?redirectTo=%2Fusers",
});
});
test("requires challenge when step-up is stale", () => {
expect(enforce({ stepUp: { verified: false, reason: "stale" } })).toEqual({
allowed: false,
reason: "step-up-stale",
redirectTo: `${ADMIN_MFA_CHALLENGE_PATH}?redirectTo=%2Fusers%3Ftenant%3Dtenant-1%23section`,
});
});
test("uses canonical admin redirects on the admin subdomain", () => {
expect(enforce({ stepUp: { verified: false, reason: "missing" }, redirectMode: "host-aware" })).toEqual({
allowed: false,
reason: "step-up-missing",
redirectTo: "/mfa-challenge?redirectTo=%2Fusers%3Ftenant%3Dtenant-1%23section",
});
expect(enforce({ redirectMode: "host-aware" })).toEqual({
allowed: true,
reason: "allowed",
redirectTo: "/users?tenant=tenant-1#section",
});
expect(enforce({ totpStatus: null, redirectMode: "host-aware" })).toEqual({
allowed: true,
reason: "allowed",
redirectTo: "/users?tenant=tenant-1#section",
});
});
test("allows the admin MFA enrollment route before TOTP is enabled", () => {
expect(enforce({ requestUrl: "https://admin.example.com/settings/profile?mfa=setup", totpStatus: pendingOnlyTotp, redirectMode: "host-aware" })).toEqual({
allowed: true,
reason: "allowed",
redirectTo: "/settings/profile?mfa=setup",
});
});
test("allows the admin MFA challenge route without existing step-up", () => {
expect(enforce({ requestUrl: "https://admin.example.com/mfa-challenge?redirectTo=%2Fusers", stepUp: { verified: false, reason: "missing" } })).toEqual({
allowed: true,
reason: "allowed",
redirectTo: "/mfa-challenge?redirectTo=%2Fusers",
});
});
test("denies non-admin and anonymous requests before challenge bypass is possible", () => {
expect(enforce({ isAdmin: false, stepUp: freshStepUp })).toEqual({
allowed: false,
reason: "missing-admin",
redirectTo: "/",
});
expect(enforce({ userId: null, isAdmin: true, stepUp: freshStepUp })).toEqual({
allowed: false,
reason: "missing-user",
redirectTo: "/login",
});
});
test("sanitizes redirect targets derived from external and malformed request URLs", () => {
expect(enforce({ requestUrl: "https://evil.example/phish?next=https://admin.example.com/settings" })).toEqual({
allowed: true,
reason: "allowed",
redirectTo: "/phish?next=https://admin.example.com/settings",
});
expect(enforce({ requestUrl: "not a url", stepUp: { verified: false, reason: "malformed" } })).toEqual({
allowed: false,
reason: "step-up-malformed",
redirectTo: `${ADMIN_MFA_CHALLENGE_PATH}?redirectTo=%2F`,
});
});
test("maps malformed and wrong-user step-up states to fail-closed reasons", () => {
expect(enforce({ stepUp: { verified: false, reason: "malformed" } })).toMatchObject({
allowed: false,
reason: "step-up-malformed",
});
expect(enforce({ stepUp: { verified: false, reason: "wrong-user" } })).toMatchObject({
allowed: false,
reason: "step-up-wrong-user",
});
});
test("result payloads contain no TOTP secrets, recovery codes, hashes, cookies, tokens, or passwords", () => {
const result = enforce({
requestUrl: "https://admin.example.com/dashboard?tab=secrets",
stepUp: { verified: false, reason: "missing" },
});
expect(JSON.stringify(result)).not.toMatch(/totpSecret|recoveryCode|hash|cookie|token|password|encrypted/i);
});
});
@@ -0,0 +1,87 @@
import type { UserTotpStatus } from "~/utils/db/users.db.server";
import type { MfaStepUpCheckReason, MfaStepUpCheckResult } from "./step-up.server";
export { ADMIN_MFA_CHALLENGE_PATH, ADMIN_MFA_DEFAULT_REDIRECT_PATH, ADMIN_MFA_ENROLLMENT_PATH } from "./paths";
import { ADMIN_MFA_CHALLENGE_PATH, ADMIN_MFA_DEFAULT_REDIRECT_PATH } from "./paths";
export type AdminMfaEnforcementFailureReason =
| "missing-user"
| "missing-admin"
| "missing-totp-status"
| "totp-not-enabled"
| "step-up-missing"
| "step-up-malformed"
| "step-up-wrong-user"
| "step-up-stale";
export type AdminMfaEnforcementReason = "allowed" | AdminMfaEnforcementFailureReason;
export type AdminMfaEnforcementResult =
| { allowed: true; reason: "allowed"; redirectTo: string }
| { allowed: false; reason: AdminMfaEnforcementFailureReason; redirectTo: string };
export type AdminMfaEnforcementInput = {
requestUrl: string | URL;
userId?: string | null;
isAdmin?: boolean | null;
totpStatus?: UserTotpStatus | null;
stepUp: MfaStepUpCheckResult;
redirectMode?: "host-aware";
appFallbackPath?: string;
};
function isAdminMfaChallengePath(requestUrl: string | URL): boolean {
try {
const url = requestUrl instanceof URL ? requestUrl : new URL(requestUrl);
return url.pathname === ADMIN_MFA_CHALLENGE_PATH;
} catch {
return false;
}
}
function safePathFromRequestUrl(requestUrl: string | URL): string {
try {
const url = requestUrl instanceof URL ? requestUrl : new URL(requestUrl);
return `${url.pathname}${url.search}${url.hash}`;
} catch {
return ADMIN_MFA_DEFAULT_REDIRECT_PATH;
}
}
function challengeRedirectTo(requestUrl: string | URL): string {
const redirectTo = safePathFromRequestUrl(requestUrl);
const params = new URLSearchParams({ redirectTo });
return `${ADMIN_MFA_CHALLENGE_PATH}?${params.toString()}`;
}
function stepUpReason(reason: MfaStepUpCheckReason): AdminMfaEnforcementFailureReason {
switch (reason) {
case "missing":
return "step-up-missing";
case "malformed":
return "step-up-malformed";
case "wrong-user":
return "step-up-wrong-user";
case "stale":
return "step-up-stale";
}
}
export function enforceAdminMfa(input: AdminMfaEnforcementInput): AdminMfaEnforcementResult {
if (!input.userId) {
return { allowed: false, reason: "missing-user", redirectTo: "/login" };
}
if (input.isAdmin !== true) {
return { allowed: false, reason: "missing-admin", redirectTo: input.appFallbackPath ?? "/" };
}
if (!input.totpStatus || !input.totpStatus.enabled) {
return { allowed: true, reason: "allowed", redirectTo: safePathFromRequestUrl(input.requestUrl) };
}
if (!input.stepUp.verified && !isAdminMfaChallengePath(input.requestUrl)) {
return { allowed: false, reason: stepUpReason(input.stepUp.reason), redirectTo: challengeRedirectTo(input.requestUrl) };
}
return { allowed: true, reason: "allowed", redirectTo: safePathFromRequestUrl(input.requestUrl) };
}
@@ -0,0 +1,5 @@
import { adminPath } from "~/utils/app/CanonicalUrlUtils";
export const ADMIN_MFA_ENROLLMENT_PATH = adminPath("settings/profile", { mode: "host-aware" });
export const ADMIN_MFA_CHALLENGE_PATH = adminPath("mfa-challenge", { mode: "host-aware" });
export const ADMIN_MFA_DEFAULT_REDIRECT_PATH = adminPath(undefined, { mode: "host-aware" });
@@ -0,0 +1,158 @@
// @vitest-environment node
import { beforeEach, describe, expect, test } from "vitest";
const codePattern = /^[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{4}-[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{4}-[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{4}$/;
describe("recovery-codes.server", () => {
beforeEach(() => {
process.env.CRYPTO_SECRET = "test-recovery-code-secret";
});
test("generates the default count of unique human-readable codes with hash-only storage entries", async () => {
const { DEFAULT_RECOVERY_CODE_COUNT, generateRecoveryCodes } = await import("./recovery-codes.server");
const generatedAt = new Date("2026-05-08T14:00:00.000Z");
const result = generateRecoveryCodes({ now: generatedAt });
expect(result.codes).toHaveLength(DEFAULT_RECOVERY_CODE_COUNT);
expect(new Set(result.codes).size).toBe(DEFAULT_RECOVERY_CODE_COUNT);
expect(result.codes).toEqual(Array.from({ length: DEFAULT_RECOVERY_CODE_COUNT }, () => expect.stringMatching(codePattern)));
expect(result.generatedAt).toBe(generatedAt);
expect(result.entries).toHaveLength(DEFAULT_RECOVERY_CODE_COUNT);
for (const [index, entry] of result.entries.entries()) {
expect(entry).toEqual({ hash: expect.stringMatching(/^[a-f0-9]{64}$/), usedAt: null, createdAt: generatedAt });
expect(entry.hash).not.toBe(result.codes[index]);
expect(JSON.stringify(entry)).not.toContain(result.codes[index]);
}
});
test("normalizes code input and verifies hashes with constant-length safe comparison", async () => {
const { hashRecoveryCode, normalizeRecoveryCode, verifyRecoveryCodeHash } = await import("./recovery-codes.server");
const code = "2345-6789-ABCD";
const hash = hashRecoveryCode(code);
expect(normalizeRecoveryCode(" 2345 6789 abcd ")).toBe(code);
expect(hash).toMatch(/^[a-f0-9]{64}$/);
expect(verifyRecoveryCodeHash("23456789abcd", hash!)).toBe(true);
expect(verifyRecoveryCodeHash("2345-6789-ABCE", hash!)).toBe(false);
expect(verifyRecoveryCodeHash("2345-6789-ABCD", "abcd")).toBe(false);
});
test("rejects malformed input before hashing or verification", async () => {
const { hashRecoveryCode, normalizeRecoveryCode, verifyRecoveryCodeHash } = await import("./recovery-codes.server");
expect(normalizeRecoveryCode("")).toBeNull();
expect(normalizeRecoveryCode("1234-5678-ABCD")).toBeNull();
expect(normalizeRecoveryCode("2345-6789-ABC")).toBeNull();
expect(normalizeRecoveryCode("2345-6789-ABCI")).toBeNull();
expect(hashRecoveryCode("2345-6789-ABCI")).toBeNull();
expect(verifyRecoveryCodeHash("2345-6789-ABCI", "00".repeat(32))).toBe(false);
});
test("consumes a valid recovery code once and rejects replay", async () => {
const { consumeRecoveryCode, generateRecoveryCodes } = await import("./recovery-codes.server");
const generated = generateRecoveryCodes({ count: 2, now: new Date("2026-05-08T14:00:00.000Z") });
const firstUseAt = new Date("2026-05-08T14:05:00.000Z");
const replayAt = new Date("2026-05-08T14:06:00.000Z");
const firstUse = consumeRecoveryCode(generated.entries, generated.codes[0], { now: firstUseAt });
const replay = consumeRecoveryCode(firstUse.entries, generated.codes[0], { now: replayAt });
const wrongCode = consumeRecoveryCode(firstUse.entries, "2345-6789-ABCD", { now: replayAt });
expect(firstUse.matched).toBe(true);
expect(firstUse.usedAt).toBe(firstUseAt);
expect(firstUse.entries[0].usedAt).toBe(firstUseAt);
expect(firstUse.entries[1].usedAt).toBeNull();
expect(replay).toEqual({ matched: false, entries: firstUse.entries, usedAt: null });
expect(wrongCode).toEqual({ matched: false, entries: firstUse.entries, usedAt: null });
});
test("generation does not loop indefinitely: succeeds on the first attempt under healthy entropy", async () => {
const { generateRecoveryCodes, DEFAULT_RECOVERY_CODE_COUNT } = await import("./recovery-codes.server");
const result = generateRecoveryCodes();
expect(result.codes).toHaveLength(DEFAULT_RECOVERY_CODE_COUNT);
expect(new Set(result.codes).size).toBe(DEFAULT_RECOVERY_CODE_COUNT);
});
test("generation completes in bounded time even when asked for the maximum count (no rejection-sampling loop)", async () => {
const { generateRecoveryCodes } = await import("./recovery-codes.server");
// The legacy implementation rebuilt a `Set` with the codes and looped
// until uniqueness — pathological under bad entropy. We assert a strict
// wall-clock bound to catch any regression that re-introduces unbounded
// looping.
const start = Date.now();
const result = generateRecoveryCodes({ count: 50 });
const elapsed = Date.now() - start;
expect(result.codes).toHaveLength(50);
expect(new Set(result.codes).size).toBe(50);
expect(elapsed).toBeLessThan(500);
});
test("status helpers redact raw codes and hashes when serialized", async () => {
const { consumeRecoveryCode, generateRecoveryCodes, getRecoveryCodeStatus } = await import("./recovery-codes.server");
const generatedAt = new Date("2026-05-08T14:00:00.000Z");
const usedAt = new Date("2026-05-08T14:10:00.000Z");
const generated = generateRecoveryCodes({ count: 3, now: generatedAt });
const consumed = consumeRecoveryCode(generated.entries, generated.codes[1], { now: usedAt });
const status = getRecoveryCodeStatus(consumed.entries);
const serialized = JSON.stringify(status);
expect(status).toEqual({
configured: true,
totalCount: 3,
remainingCount: 2,
usedCount: 1,
generatedAt,
lastUsedAt: usedAt,
});
for (const code of generated.codes) {
expect(serialized).not.toContain(code);
}
for (const entry of generated.entries) {
expect(serialized).not.toContain(entry.hash);
}
expect(serialized).not.toMatch(/hash|code|secret/i);
});
test("status helper reports an unconfigured state for null or undefined entries", async () => {
const { getRecoveryCodeStatus } = await import("./recovery-codes.server");
expect(getRecoveryCodeStatus(null)).toEqual({
configured: false,
totalCount: 0,
remainingCount: 0,
usedCount: 0,
generatedAt: null,
lastUsedAt: null,
});
expect(getRecoveryCodeStatus(undefined, { generatedAt: null, lastUsedAt: null })).toEqual({
configured: false,
totalCount: 0,
remainingCount: 0,
usedCount: 0,
generatedAt: null,
lastUsedAt: null,
});
});
test("consumeRecoveryCode rejects malformed codes and uses the current time when no now option is supplied", async () => {
const { consumeRecoveryCode, generateRecoveryCodes } = await import("./recovery-codes.server");
const generated = generateRecoveryCodes({ count: 1, now: new Date("2026-05-08T14:00:00.000Z") });
expect(consumeRecoveryCode(generated.entries, "bad-code")).toEqual({
matched: false,
entries: generated.entries,
usedAt: null,
});
const consumed = consumeRecoveryCode(generated.entries, generated.codes[0]);
expect(consumed.matched).toBe(true);
expect(consumed.usedAt).toBeInstanceOf(Date);
expect(consumed.entries[0].usedAt).toBe(consumed.usedAt);
});
});
@@ -0,0 +1,206 @@
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { loadRequiredWebConfig } from "@toolctrl/config";
export const DEFAULT_RECOVERY_CODE_COUNT = 10;
export const DEFAULT_RECOVERY_CODE_SEGMENTS = 3;
export const DEFAULT_RECOVERY_CODE_SEGMENT_LENGTH = 4;
export const RECOVERY_CODE_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
export type RecoveryCodeHashEntry = {
hash: string;
usedAt: Date | null;
createdAt: Date;
};
export type RecoveryCodeGeneration = {
codes: string[];
entries: RecoveryCodeHashEntry[];
generatedAt: Date;
};
export type RecoveryCodeStatus = {
configured: boolean;
totalCount: number;
remainingCount: number;
usedCount: number;
generatedAt: Date | null;
lastUsedAt: Date | null;
};
type GenerateRecoveryCodesOptions = {
count?: number;
segments?: number;
segmentLength?: number;
now?: Date;
};
type NormalizeOptions = {
segments?: number;
segmentLength?: number;
};
function assertPositiveInteger(value: number, name: string) {
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer`);
}
}
function getRecoveryCodeSecret() {
const { cryptoSecret, sessionSecret } = loadRequiredWebConfig();
const secret = cryptoSecret || sessionSecret;
if (!secret) {
throw new Error("CRYPTO_SECRET or SESSION_SECRET env variable not set");
}
return secret;
}
function randomAlphabetCharacter() {
const byteLimit = Math.floor(256 / RECOVERY_CODE_ALPHABET.length) * RECOVERY_CODE_ALPHABET.length;
while (true) {
const value = randomBytes(1)[0];
if (value < byteLimit) {
return RECOVERY_CODE_ALPHABET[value % RECOVERY_CODE_ALPHABET.length];
}
}
}
export function normalizeRecoveryCode(input: string, options: NormalizeOptions = {}) {
const segments = options.segments ?? DEFAULT_RECOVERY_CODE_SEGMENTS;
const segmentLength = options.segmentLength ?? DEFAULT_RECOVERY_CODE_SEGMENT_LENGTH;
assertPositiveInteger(segments, "segments");
assertPositiveInteger(segmentLength, "segmentLength");
const compact = input.trim().toUpperCase().replace(/[\s-]/g, "");
const expectedLength = segments * segmentLength;
if (compact.length !== expectedLength || [...compact].some((character) => !RECOVERY_CODE_ALPHABET.includes(character))) {
return null;
}
return Array.from({ length: segments }, (_, index) =>
compact.slice(index * segmentLength, (index + 1) * segmentLength)
).join("-");
}
export function generateRecoveryCode(options: NormalizeOptions = {}) {
const segments = options.segments ?? DEFAULT_RECOVERY_CODE_SEGMENTS;
const segmentLength = options.segmentLength ?? DEFAULT_RECOVERY_CODE_SEGMENT_LENGTH;
assertPositiveInteger(segments, "segments");
assertPositiveInteger(segmentLength, "segmentLength");
return Array.from({ length: segments }, () =>
Array.from({ length: segmentLength }, () => randomAlphabetCharacter()).join("")
).join("-");
}
export function hashRecoveryCode(code: string) {
const normalized = normalizeRecoveryCode(code);
if (!normalized) {
return null;
}
return createHmac("sha256", getRecoveryCodeSecret()).update(normalized).digest("hex");
}
function hashMatches(candidateHash: string, storedHash: string) {
const candidateBuffer = Buffer.from(candidateHash, "hex");
const storedBuffer = Buffer.from(storedHash, "hex");
if (candidateBuffer.length !== storedBuffer.length) {
return false;
}
return timingSafeEqual(candidateBuffer, storedBuffer);
}
export function verifyRecoveryCodeHash(code: string, storedHash: string) {
const candidateHash = hashRecoveryCode(code);
if (!candidateHash) {
return false;
}
return hashMatches(candidateHash, storedHash);
}
export function generateRecoveryCodes(options: GenerateRecoveryCodesOptions = {}): RecoveryCodeGeneration {
const count = options.count ?? DEFAULT_RECOVERY_CODE_COUNT;
const segments = options.segments ?? DEFAULT_RECOVERY_CODE_SEGMENTS;
const segmentLength = options.segmentLength ?? DEFAULT_RECOVERY_CODE_SEGMENT_LENGTH;
assertPositiveInteger(count, "count");
assertPositiveInteger(segments, "segments");
assertPositiveInteger(segmentLength, "segmentLength");
const generatedAt = options.now ?? new Date();
// 32^12 (~10^18) code space means in-batch collisions are astronomically
// unlikely. Generate `count` codes upfront and only retry once if we ever
// see a duplicate (instead of looping forever on every call).
const MAX_RETRIES = 2;
let codeList: string[] = [];
for (let attempt = 0; attempt < MAX_RETRIES; attempt += 1) {
const candidates = Array.from({ length: count }, () => generateRecoveryCode({ segments, segmentLength }));
if (new Set(candidates).size === count) {
codeList = candidates;
break;
}
}
if (codeList.length !== count) {
throw new Error("Failed to generate unique recovery codes after retry; suspected entropy source failure.");
}
return {
codes: codeList,
entries: codeList.map((code) => ({
hash: hashRecoveryCode(code)!,
usedAt: null,
createdAt: generatedAt,
})),
generatedAt,
};
}
export function consumeRecoveryCode(
entries: RecoveryCodeHashEntry[],
code: string,
options: { now?: Date } = {}
): { matched: boolean; entries: RecoveryCodeHashEntry[]; usedAt: Date | null } {
const candidateHash = hashRecoveryCode(code);
if (!candidateHash) {
return { matched: false, entries, usedAt: null };
}
const usedAt = options.now ?? new Date();
let matched = false;
const nextEntries = entries.map((entry) => {
if (entry.usedAt || matched || !hashMatches(candidateHash, entry.hash)) {
return entry;
}
matched = true;
return { ...entry, usedAt };
});
return { matched, entries: matched ? nextEntries : entries, usedAt: matched ? usedAt : null };
}
export function getRecoveryCodeStatus(
entries: RecoveryCodeHashEntry[] | null | undefined,
options: { generatedAt?: Date | null; lastUsedAt?: Date | null } = {}
): RecoveryCodeStatus {
const configured = Boolean(entries?.length);
const totalCount = entries?.length ?? 0;
const usedEntries = entries?.filter((entry) => entry.usedAt) ?? [];
const remainingCount = entries?.filter((entry) => !entry.usedAt).length ?? 0;
const lastEntryUsedAt = usedEntries.reduce<Date | null>((latest, entry) => {
if (!entry.usedAt) {
return latest;
}
if (!latest || entry.usedAt.getTime() > latest.getTime()) {
return entry.usedAt;
}
return latest;
}, null);
return {
configured,
totalCount,
remainingCount,
usedCount: usedEntries.length,
generatedAt: options.generatedAt ?? (configured ? entries?.[0]?.createdAt ?? null : null),
lastUsedAt: options.lastUsedAt ?? lastEntryUsedAt,
};
}
@@ -0,0 +1,150 @@
// @vitest-environment node
import { beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("~/utils/session.server", async () => {
const { createCookieSessionStorage } = await import("react-router");
return {
storage: createCookieSessionStorage({
cookie: { name: "test_session", secrets: ["test-session-secret"], path: "/", httpOnly: true, sameSite: "lax" },
}),
};
});
import { storage } from "~/utils/session.server";
import {
checkMfaStepUpState,
clearMfaStepUpState,
getMfaStepUpState,
MFA_STEP_UP_MAX_AGE_MS,
MFA_STEP_UP_SESSION_KEY,
setMfaStepUpState,
} from "./step-up.server";
async function createSession() {
return storage.getSession();
}
describe("step-up.server", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("records and verifies fresh MFA step-up state for the same user", async () => {
const session = await createSession();
const verifiedAt = new Date("2026-05-08T14:00:00.000Z");
const state = setMfaStepUpState(session, {
userId: "admin-1",
method: "totp",
redirectTo: "/admin?tab=security",
now: verifiedAt,
});
const result = checkMfaStepUpState(session, "admin-1", { now: new Date(verifiedAt.getTime() + 30_000) });
expect(state).toEqual({
userId: "admin-1",
method: "totp",
verifiedAt: verifiedAt.toISOString(),
redirectTo: "/admin?tab=security",
});
expect(getMfaStepUpState(session)).toEqual(state);
expect(result).toEqual({ verified: true, state, ageMs: 30_000 });
});
test("records step-up state without optional redirect metadata", async () => {
const session = await createSession();
const verifiedAt = new Date("2026-05-08T14:00:00.000Z");
const state = setMfaStepUpState(session, {
userId: "admin-1",
method: "recovery-code",
now: verifiedAt,
});
expect(state).toEqual({
userId: "admin-1",
method: "recovery-code",
verifiedAt: verifiedAt.toISOString(),
});
expect(getMfaStepUpState(session)).toEqual(state);
});
test("fails closed for stale and future timestamps", async () => {
const staleSession = await createSession();
const futureSession = await createSession();
const now = new Date("2026-05-08T14:10:00.000Z");
setMfaStepUpState(staleSession, {
userId: "admin-1",
method: "recovery-code",
now: new Date(now.getTime() - MFA_STEP_UP_MAX_AGE_MS - 1),
});
setMfaStepUpState(futureSession, {
userId: "admin-1",
method: "totp",
now: new Date(now.getTime() + 1),
});
expect(checkMfaStepUpState(staleSession, "admin-1", { now })).toEqual({ verified: false, reason: "stale" });
expect(checkMfaStepUpState(futureSession, "admin-1", { now })).toEqual({ verified: false, reason: "stale" });
});
test("does not authorize a different user", async () => {
const session = await createSession();
setMfaStepUpState(session, { userId: "admin-1", method: "totp", now: new Date("2026-05-08T14:00:00.000Z") });
expect(checkMfaStepUpState(session, "admin-2", { now: new Date("2026-05-08T14:01:00.000Z") })).toEqual({
verified: false,
reason: "wrong-user",
});
});
test("treats missing and malformed state as unverified without throwing", async () => {
const missingSession = await createSession();
const malformedPayloads = [
{ method: "totp", verifiedAt: "2026-05-08T14:00:00.000Z" },
{ userId: "", method: "totp", verifiedAt: "2026-05-08T14:00:00.000Z" },
"not-json-object",
{ userId: "admin-1", method: "sms", verifiedAt: "2026-05-08T14:00:00.000Z" },
{ userId: "admin-1", verifiedAt: "2026-05-08T14:00:00.000Z" },
{ userId: "admin-1", method: "totp", verifiedAt: "not-a-date" },
{ userId: "admin-1", method: "totp", verifiedAt: "2026-05-08T14:00:00.000Z", redirectTo: 123 },
];
expect(checkMfaStepUpState(missingSession, "admin-1")).toEqual({ verified: false, reason: "missing" });
for (const payload of malformedPayloads) {
const session = await createSession();
session.set(MFA_STEP_UP_SESSION_KEY, payload);
expect(() => getMfaStepUpState(session)).not.toThrow();
expect(getMfaStepUpState(session)).toBeNull();
expect(checkMfaStepUpState(session, "admin-1")).toEqual({ verified: false, reason: "malformed" });
}
});
test("clears existing MFA step-up state", async () => {
const session = await createSession();
setMfaStepUpState(session, { userId: "admin-1", method: "totp", now: new Date("2026-05-08T14:00:00.000Z") });
clearMfaStepUpState(session);
expect(getMfaStepUpState(session)).toBeNull();
expect(checkMfaStepUpState(session, "admin-1")).toEqual({ verified: false, reason: "missing" });
});
test("serialized step-up state contains no cookies, tokens, hashes, passwords, or factor secrets", async () => {
const session = await createSession();
const state = setMfaStepUpState(session, {
userId: "admin-1",
method: "recovery-code",
redirectTo: "/admin",
now: new Date("2026-05-08T14:00:00.000Z"),
});
const serialized = JSON.stringify(state);
expect(serialized).toContain("admin-1");
expect(serialized).toContain("recovery-code");
expect(serialized).not.toMatch(/cookie|csrf|token|secret|hash|password|encrypted|totpSecret|recoveryCodes/i);
});
});
@@ -0,0 +1,109 @@
import type { Session } from "react-router";
export const MFA_STEP_UP_SESSION_KEY = "adminMfaStepUp";
export const MFA_STEP_UP_MAX_AGE_MS = 10 * 60 * 1000;
export type MfaStepUpMethod = "totp" | "recovery-code";
export type MfaStepUpState = {
userId: string;
method: MfaStepUpMethod;
verifiedAt: string;
redirectTo?: string;
};
export type MfaStepUpCheckReason = "missing" | "malformed" | "wrong-user" | "stale";
export type MfaStepUpCheckResult =
| { verified: true; state: MfaStepUpState; ageMs: number }
| { verified: false; reason: MfaStepUpCheckReason };
const MFA_STEP_UP_METHODS = new Set<MfaStepUpMethod>(["totp", "recovery-code"]);
function isMfaStepUpMethod(method: unknown): method is MfaStepUpMethod {
return typeof method === "string" && MFA_STEP_UP_METHODS.has(method as MfaStepUpMethod);
}
function parseStepUpState(value: unknown): MfaStepUpState | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const candidate = value as Partial<Record<keyof MfaStepUpState, unknown>>;
if (typeof candidate.userId !== "string" || candidate.userId.length === 0) {
return null;
}
if (!isMfaStepUpMethod(candidate.method)) {
return null;
}
if (typeof candidate.verifiedAt !== "string" || Number.isNaN(Date.parse(candidate.verifiedAt))) {
return null;
}
if (candidate.redirectTo !== undefined && typeof candidate.redirectTo !== "string") {
return null;
}
return {
userId: candidate.userId,
method: candidate.method,
verifiedAt: candidate.verifiedAt,
...(candidate.redirectTo !== undefined ? { redirectTo: candidate.redirectTo } : {}),
};
}
export function getMfaStepUpState(session: Session): MfaStepUpState | null {
return parseStepUpState(session.get(MFA_STEP_UP_SESSION_KEY));
}
export function setMfaStepUpState(
session: Session,
input: {
userId: string;
method: MfaStepUpMethod;
redirectTo?: string;
now?: Date;
},
): MfaStepUpState {
const state: MfaStepUpState = {
userId: input.userId,
method: input.method,
verifiedAt: (input.now ?? new Date()).toISOString(),
...(input.redirectTo !== undefined ? { redirectTo: input.redirectTo } : {}),
};
session.set(MFA_STEP_UP_SESSION_KEY, state);
return state;
}
export function clearMfaStepUpState(session: Session) {
session.unset(MFA_STEP_UP_SESSION_KEY);
}
export function checkMfaStepUpState(
session: Session,
userId: string,
options: { now?: Date; maxAgeMs?: number } = {},
): MfaStepUpCheckResult {
const rawValue = session.get(MFA_STEP_UP_SESSION_KEY);
if (rawValue === undefined || rawValue === null) {
return { verified: false, reason: "missing" };
}
const state = parseStepUpState(rawValue);
if (!state) {
return { verified: false, reason: "malformed" };
}
if (state.userId !== userId) {
return { verified: false, reason: "wrong-user" };
}
const nowMs = (options.now ?? new Date()).getTime();
const ageMs = nowMs - Date.parse(state.verifiedAt);
const maxAgeMs = options.maxAgeMs ?? MFA_STEP_UP_MAX_AGE_MS;
if (ageMs < 0 || ageMs > maxAgeMs) {
return { verified: false, reason: "stale" };
}
return { verified: true, state, ageMs };
}
@@ -0,0 +1,154 @@
/**
* D1 (Trust-Device) — DB-gestützter, widerrufbarer Geräte-Marker. Das Cookie trägt einen opaken,
* signierten Token; Autorität ist die TrustedDevice-Tabelle (Hash, userId, Ablauf, Widerruf).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@toolctrl/config", async () => {
const actual = await vi.importActual<typeof import("@toolctrl/config")>("@toolctrl/config");
return {
...actual,
loadRequiredWebConfig: () => ({ nodeEnv: "test", sessionSecret: "test-session-secret-please-change" }),
};
});
vi.mock("~/utils/db.server", () => ({
db: {
trustedDevice: {
create: vi.fn(),
findUnique: vi.fn(),
findMany: vi.fn(),
updateMany: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("~/services/auth/sessionLifecycle.server", () => ({
buildSessionDeviceContext: vi.fn().mockResolvedValue({ ipHash: null, userAgent: "UA", approxLocation: "DE" }),
}));
import { db } from "~/utils/db.server";
import {
__resetTrustedDeviceCookieForTests,
clearTrustedDeviceCookie,
forgetCurrentTrustedDevice,
isTrustedDevice,
rememberDeviceForUser,
revokeAllTrustedDevices,
revokeTrustedDevice,
TRUSTED_DEVICE_COOKIE_NAME,
} from "./trusted-device.server";
const create = vi.mocked(db.trustedDevice.create);
const findUnique = vi.mocked(db.trustedDevice.findUnique);
const updateMany = vi.mocked(db.trustedDevice.updateMany);
function activeRow(overrides: Record<string, unknown> = {}) {
return {
id: "td-1",
userId: "user-1",
tokenHash: "hash",
createdAt: new Date(),
lastUsedAt: new Date(),
expiresAt: new Date(Date.now() + 1000 * 60 * 60),
revokedAt: null,
ipHash: null,
userAgent: "UA",
deviceLabel: null,
approxLocation: "DE",
...overrides,
};
}
async function rememberAndGetRequest(userId = "user-1") {
create.mockResolvedValue(activeRow() as never);
const setCookie = await rememberDeviceForUser({ request: new Request("https://app.example.com/login-mfa"), userId });
const pair = setCookie.split(";")[0];
return new Request("https://app.example.com/login", { headers: { Cookie: pair } });
}
beforeEach(() => {
vi.clearAllMocks();
__resetTrustedDeviceCookieForTests();
});
afterEach(() => __resetTrustedDeviceCookieForTests());
describe("trusted-device (DB-backed)", () => {
it("remembers a device: writes a row and returns a Set-Cookie", async () => {
create.mockResolvedValue(activeRow() as never);
const setCookie = await rememberDeviceForUser({ request: new Request("https://app.example.com/login-mfa"), userId: "user-1" });
expect(create).toHaveBeenCalledTimes(1);
expect(setCookie).toContain(TRUSTED_DEVICE_COOKIE_NAME);
// Nur der Hash, nie der rohe Token, wird persistiert.
const data = (create.mock.calls[0][0] as { data: { tokenHash: string } }).data;
expect(data.tokenHash).toMatch(/^[0-9a-f]{64}$/);
});
it("round-trips: a remembered device is trusted for the same user", async () => {
const request = await rememberAndGetRequest("user-1");
findUnique.mockResolvedValue(activeRow() as never);
await expect(isTrustedDevice(request, "user-1")).resolves.toBe(true);
});
it("touches lastUsedAt (throttled) when the marker was last used long ago", async () => {
const request = await rememberAndGetRequest("user-1");
findUnique.mockResolvedValue(activeRow({ lastUsedAt: new Date(0) }) as never);
vi.mocked(db.trustedDevice.update).mockResolvedValue(activeRow() as never);
await expect(isTrustedDevice(request, "user-1")).resolves.toBe(true);
expect(db.trustedDevice.update).toHaveBeenCalledTimes(1);
});
it("rejects a marker that belongs to a different user", async () => {
const request = await rememberAndGetRequest("user-1");
findUnique.mockResolvedValue(activeRow({ userId: "user-2" }) as never);
await expect(isTrustedDevice(request, "user-1")).resolves.toBe(false);
});
it("rejects an expired marker", async () => {
const request = await rememberAndGetRequest("user-1");
findUnique.mockResolvedValue(activeRow({ expiresAt: new Date(Date.now() - 1000) }) as never);
await expect(isTrustedDevice(request, "user-1")).resolves.toBe(false);
});
it("rejects a revoked marker", async () => {
const request = await rememberAndGetRequest("user-1");
findUnique.mockResolvedValue(activeRow({ revokedAt: new Date() }) as never);
await expect(isTrustedDevice(request, "user-1")).resolves.toBe(false);
});
it("returns false (and skips the DB) without a cookie", async () => {
await expect(isTrustedDevice(new Request("https://app.example.com/login"), "user-1")).resolves.toBe(false);
expect(findUnique).not.toHaveBeenCalled();
});
it("rejects a tampered cookie (broken signature) without hitting the DB", async () => {
const request = await rememberAndGetRequest("user-1");
const tampered = new Request("https://app.example.com/login", { headers: { Cookie: `${request.headers.get("Cookie")}TAMPER` } });
findUnique.mockClear();
await expect(isTrustedDevice(tampered, "user-1")).resolves.toBe(false);
expect(findUnique).not.toHaveBeenCalled();
});
it("revokes a single device scoped to the owner", async () => {
updateMany.mockResolvedValue({ count: 1 } as never);
await expect(revokeTrustedDevice({ userId: "user-1", deviceId: "td-1" })).resolves.toBe(true);
expect(updateMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ id: "td-1", userId: "user-1", revokedAt: null }) }));
});
it("revokes all devices of a user", async () => {
updateMany.mockResolvedValue({ count: 3 } as never);
await expect(revokeAllTrustedDevices("user-1")).resolves.toBe(3);
});
it("forgets the current device: revokes its row and clears the cookie", async () => {
const request = await rememberAndGetRequest("user-1");
updateMany.mockResolvedValue({ count: 1 } as never);
const setCookie = await forgetCurrentTrustedDevice(request);
expect(updateMany).toHaveBeenCalledTimes(1);
expect(setCookie).toMatch(/Max-Age=0|Expires=Thu, 01 Jan 1970/);
});
it("clears the cookie without DB access", async () => {
const cleared = await clearTrustedDeviceCookie();
expect(cleared).toContain(TRUSTED_DEVICE_COOKIE_NAME);
});
});
@@ -0,0 +1,150 @@
import { createHash, randomBytes } from "node:crypto";
import { createCookie } from "react-router";
import { loadRequiredWebConfig } from "@toolctrl/config";
import { db } from "~/utils/db.server";
import { buildSessionDeviceContext } from "~/services/auth/sessionLifecycle.server";
// D1 (Trust-Device) — "diesem Geraet vertrauen" beim 2FA-Login. Der Marker UEBERLEBT den Logout
// (deshalb NICHT die per-Session `UserSession.trustedAt`), ist aber WIDERRUFBAR: ein eigenes, langlebiges
// Cookie (`RJ_trusted_device`) traegt einen opaken Zufalls-Token, persistiert wird nur dessen SHA-256-Hash
// in der Tabelle `TrustedDevice`. Autoritaet ist die Tabelle (Revoke einzeln / "ueberall vergessen" ueber
// Settings > Sicherheit) -> erfuellt den DoD-Screen "Trust-Device-Revoke" (R-P2a). Das Cookie ist nur der
// Traeger; ohne passende, aktive Zeile gilt das Geraet als NICHT vertrauenswuerdig.
export const TRUSTED_DEVICE_COOKIE_NAME = "RJ_trusted_device";
export const TRUSTED_DEVICE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; // 30 Tage
const TRUSTED_DEVICE_TOKEN_BYTES = 32;
const TOUCH_THROTTLE_MS = 5 * 60 * 1000; // lastUsedAt hoechstens alle 5min fortschreiben
export type TrustedDeviceRow = Awaited<ReturnType<typeof db.trustedDevice.create>>;
let cachedCookie: ReturnType<typeof createCookie> | null = null;
function getTrustedDeviceCookie() {
if (cachedCookie) {
return cachedCookie;
}
const webConfig = loadRequiredWebConfig();
cachedCookie = createCookie(TRUSTED_DEVICE_COOKIE_NAME, {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: webConfig.nodeEnv === "production",
maxAge: TRUSTED_DEVICE_MAX_AGE_SECONDS,
secrets: webConfig.sessionSecret ? [webConfig.sessionSecret] : [],
});
return cachedCookie;
}
function generateTrustedDeviceToken(): string {
return randomBytes(TRUSTED_DEVICE_TOKEN_BYTES).toString("base64url");
}
function hashTrustedDeviceToken(rawToken: string): string {
return createHash("sha256").update(rawToken).digest("hex");
}
function isActive(row: Pick<TrustedDeviceRow, "revokedAt" | "expiresAt"> | null, now = Date.now()): boolean {
return Boolean(row && row.revokedAt === null && row.expiresAt.getTime() > now);
}
async function readRawTokenFromCookie(request: Request): Promise<string | null> {
try {
const parsed = await getTrustedDeviceCookie().parse(request.headers.get("Cookie"));
return typeof parsed === "string" && parsed.length > 0 ? parsed : null;
} catch {
return null;
}
}
/**
* Markiert das aktuelle Geraet fuer `userId` als vertrauenswuerdig: legt die Tabellen-Zeile mit
* (pseudonymisiertem) Geraete-Kontext an und gibt den Set-Cookie-String mit dem rohen Token zurueck.
*/
export async function rememberDeviceForUser({ request, userId, now = new Date() }: { request: Request; userId: string; now?: Date }): Promise<string> {
const rawToken = generateTrustedDeviceToken();
const context = await buildSessionDeviceContext(request);
await db.trustedDevice.create({
data: {
userId,
tokenHash: hashTrustedDeviceToken(rawToken),
expiresAt: new Date(now.getTime() + TRUSTED_DEVICE_MAX_AGE_SECONDS * 1000),
ipHash: context.ipHash,
userAgent: context.userAgent,
approxLocation: context.approxLocation,
},
});
return getTrustedDeviceCookie().serialize(rawToken);
}
/**
* Prueft, ob der Request ein vertrauenswuerdiges Geraet fuer `userId` traegt: Cookie-Token -> Hash ->
* aktive Tabellen-Zeile mit passender userId. Best-effort: jeder Fehler gilt als "nicht vertraut".
* Schreibt lastUsedAt gedrosselt fort.
*/
export async function isTrustedDevice(request: Request, userId: string, now: Date = new Date()): Promise<boolean> {
try {
const rawToken = await readRawTokenFromCookie(request);
if (!rawToken) {
return false;
}
const row = await db.trustedDevice.findUnique({ where: { tokenHash: hashTrustedDeviceToken(rawToken) } });
if (!row || row.userId !== userId || !isActive(row, now.getTime())) {
return false;
}
if (now.getTime() - row.lastUsedAt.getTime() >= TOUCH_THROTTLE_MS) {
await db.trustedDevice.update({ where: { id: row.id }, data: { lastUsedAt: now } }).catch(() => undefined);
}
return true;
} catch {
return false;
}
}
/** Aktive vertrauenswuerdige Geraete eines Nutzers (Geraete-Liste in Settings > Sicherheit). */
export async function listTrustedDevices(userId: string): Promise<TrustedDeviceRow[]> {
return db.trustedDevice.findMany({
where: { userId, revokedAt: null, expiresAt: { gt: new Date() } },
orderBy: { lastUsedAt: "desc" },
});
}
/** Widerruft genau ein eigenes vertrauenswuerdiges Geraet (Eigentuemerschaft ueber userId gesichert). */
export async function revokeTrustedDevice({ userId, deviceId }: { userId: string; deviceId: string }): Promise<boolean> {
const result = await db.trustedDevice.updateMany({
where: { id: deviceId, userId, revokedAt: null },
data: { revokedAt: new Date() },
});
return result.count > 0;
}
/** "Alle Geraete vergessen": widerruft alle aktiven Trust-Marker des Nutzers. */
export async function revokeAllTrustedDevices(userId: string): Promise<number> {
const result = await db.trustedDevice.updateMany({
where: { userId, revokedAt: null },
data: { revokedAt: new Date() },
});
return result.count;
}
/**
* Vergisst das aktuelle Geraet: widerruft die zum Cookie gehoerende Zeile (falls vorhanden) und gibt
* den Set-Cookie-String zurueck, der den Trust-Marker im Browser loescht.
*/
export async function forgetCurrentTrustedDevice(request: Request): Promise<string> {
const rawToken = await readRawTokenFromCookie(request);
if (rawToken) {
await db.trustedDevice.updateMany({ where: { tokenHash: hashTrustedDeviceToken(rawToken), revokedAt: null }, data: { revokedAt: new Date() } }).catch(() => undefined);
}
return clearTrustedDeviceCookie();
}
/** Set-Cookie-String, der den Trust-Marker im Browser loescht (ohne DB-Zugriff). */
export async function clearTrustedDeviceCookie(): Promise<string> {
return getTrustedDeviceCookie().serialize("", { maxAge: 0, expires: new Date(0) });
}
/** Test-Naht: erzwingt eine Neuauflage des Cookies (z.B. nach geaenderter Config im Test). */
export function __resetTrustedDeviceCookieForTests() {
cachedCookie = null;
}
@@ -0,0 +1,89 @@
// @vitest-environment node
import argon2 from "argon2";
import bcryptjs from "bcryptjs";
import { describe, expect, test, vi } from "vitest";
import { hashPassword, isKnownHashFormat, verifyPassword } from "./passwordHasher.server.js";
const PLAINTEXT = "correct-horse-battery-staple";
const WRONG = "incorrect-zebra-cell-paperclip";
describe("passwordHasher.server", () => {
test("hashPassword produces an argon2id hash that round-trips", async () => {
const hash = await hashPassword(PLAINTEXT);
expect(hash.startsWith("$argon2id$")).toBe(true);
expect(await argon2.verify(hash, PLAINTEXT)).toBe(true);
});
test("verifyPassword accepts a valid argon2 hash without proposing a rehash", async () => {
const hash = await hashPassword(PLAINTEXT);
const result = await verifyPassword(PLAINTEXT, hash);
expect(result.valid).toBe(true);
expect(result.rehashedHash).toBeUndefined();
});
test("verifyPassword rejects a wrong password against an argon2 hash", async () => {
const hash = await hashPassword(PLAINTEXT);
const result = await verifyPassword(WRONG, hash);
expect(result.valid).toBe(false);
expect(result.rehashedHash).toBeUndefined();
});
test("verifyPassword accepts a valid bcrypt hash AND proposes an argon2 rehash", async () => {
const legacyHash = await bcryptjs.hash(PLAINTEXT, 4);
expect(legacyHash.startsWith("$2")).toBe(true);
const result = await verifyPassword(PLAINTEXT, legacyHash);
expect(result.valid).toBe(true);
expect(result.rehashedHash).toBeDefined();
expect(result.rehashedHash!.startsWith("$argon2id$")).toBe(true);
// The proposed rehash must itself verify the same plaintext.
expect(await argon2.verify(result.rehashedHash!, PLAINTEXT)).toBe(true);
});
test("verifyPassword rejects a wrong password against a bcrypt hash and does NOT propose a rehash", async () => {
const legacyHash = await bcryptjs.hash(PLAINTEXT, 4);
const result = await verifyPassword(WRONG, legacyHash);
expect(result.valid).toBe(false);
expect(result.rehashedHash).toBeUndefined();
});
test("verifyPassword rejects unknown / empty hashes without throwing", async () => {
await expect(verifyPassword(PLAINTEXT, "")).resolves.toEqual({ valid: false });
await expect(verifyPassword(PLAINTEXT, "garbage")).resolves.toEqual({ valid: false });
await expect(verifyPassword(PLAINTEXT, "$pbkdf2-sha256$i=29000,l=32$abc$def")).resolves.toEqual({ valid: false });
});
test("verifyPassword swallows decryption errors and reports invalid", async () => {
// An argon2-prefixed but structurally invalid hash makes argon2.verify throw.
const result = await verifyPassword(PLAINTEXT, "$argon2id$v=19$m=19456,t=2,p=1$THISisNOTaValidSalt$norIsThisAValidHash");
expect(result.valid).toBe(false);
});
test("verifyPassword reports invalid when argon2 verification throws", async () => {
const verifySpy = vi.spyOn(argon2, "verify").mockRejectedValueOnce(new Error("argon2 unavailable"));
await expect(verifyPassword(PLAINTEXT, "$argon2id$valid-looking-prefix")).resolves.toEqual({ valid: false });
expect(verifySpy).toHaveBeenCalled();
verifySpy.mockRestore();
});
test("verifyPassword reports invalid when legacy bcrypt comparison throws", async () => {
const compareSpy = vi.spyOn(bcryptjs, "compare").mockRejectedValueOnce(new Error("bcrypt unavailable"));
const legacyHash = "$2b$10$abcdefghijklmnopqrstuu5sBKjRarAkPvaemc5yQMk3QHsOJXxUy";
await expect(verifyPassword(PLAINTEXT, legacyHash)).resolves.toEqual({ valid: false });
expect(compareSpy).toHaveBeenCalledWith(PLAINTEXT, legacyHash);
compareSpy.mockRestore();
});
test("isKnownHashFormat recognises argon2, bcrypt, and unknown shapes", async () => {
expect(isKnownHashFormat(await hashPassword(PLAINTEXT))).toBe("argon2");
expect(isKnownHashFormat(await bcryptjs.hash(PLAINTEXT, 4))).toBe("bcrypt");
expect(isKnownHashFormat("$2a$10$abc")).toBe("bcrypt");
expect(isKnownHashFormat("$2y$12$abc")).toBe("bcrypt");
expect(isKnownHashFormat("$argon2i$v=19$...")).toBe("argon2");
expect(isKnownHashFormat("plaintext")).toBe("unknown");
expect(isKnownHashFormat("")).toBe("unknown");
});
});
@@ -0,0 +1,87 @@
// Password hashing + verification. Argon2id is the only path for new hashes;
// bcrypt remains as a verify-only fallback for legacy rows. A successful
// bcrypt verify produces an argon2 rehash that the caller is responsible for
// persisting — that's how we drain the bcrypt corpus without a one-shot
// bulk-rehash (which isn't possible since bcrypt is one-way).
import argon2 from "argon2";
import bcryptjs from "bcryptjs";
// OWASP 2024 recommendation for argon2id at modest server cost.
// memoryCost is in KiB (19456 = 19 MiB).
const ARGON2_OPTIONS = {
type: argon2.argon2id,
memoryCost: 19456,
timeCost: 2,
parallelism: 1,
} as const;
const ARGON2_PREFIX_RE = /^\$argon2(?:i|d|id)\$/;
const BCRYPT_PREFIX_RE = /^\$2[abxy]\$/;
export type VerifyPasswordResult = {
valid: boolean;
/**
* Present only when the input was successfully verified against a bcrypt
* legacy hash. The caller MUST persist this value back into the user row
* (or whatever store the hash came from) so the next login takes the
* argon2 path. Absent for argon2 hits and for any failed verify.
*/
rehashedHash?: string;
};
export async function hashPassword(plain: string): Promise<string> {
return await argon2.hash(plain, ARGON2_OPTIONS);
}
/**
* Verify `plain` against an existing hash. Detects bcrypt vs argon2 by hash
* prefix; on a successful bcrypt verify, also computes an argon2 rehash for
* the caller to persist.
*
* Always rejects unknown hash formats. Treats decryption errors (e.g.
* corrupted hashes) as a failed verify rather than surfacing the underlying
* exception, so the call site can show a generic "invalid credentials"
* message without leaking format details.
*/
export async function verifyPassword(plain: string, hash: string | null | undefined): Promise<VerifyPasswordResult> {
// Phase 01 / #20 — `User.passwordHash` is nullable for magic-link-only
// accounts. Callers that compare a submitted password against the row
// hash now pass `string | null`; treat null/empty as "no comparable
// hash" → invalid, never throw.
if (typeof hash !== "string" || hash.length === 0) {
return { valid: false };
}
if (ARGON2_PREFIX_RE.test(hash)) {
try {
const valid = await argon2.verify(hash, plain);
return { valid };
} catch {
return { valid: false };
}
}
if (BCRYPT_PREFIX_RE.test(hash)) {
let valid = false;
try {
valid = await bcryptjs.compare(plain, hash);
} catch {
return { valid: false };
}
if (!valid) {
return { valid: false };
}
const rehashedHash = await hashPassword(plain);
return { valid: true, rehashedHash };
}
return { valid: false };
}
/**
* True iff the hash format is something verifyPassword understands. Useful
* for migration scripts that want to identify legacy bcrypt rows.
*/
export function isKnownHashFormat(hash: string): "argon2" | "bcrypt" | "unknown" {
if (ARGON2_PREFIX_RE.test(hash)) return "argon2";
if (BCRYPT_PREFIX_RE.test(hash)) return "bcrypt";
return "unknown";
}
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { resolveAssetBase } from "./assetCdnBase";
describe("resolveAssetBase", () => {
it("returns '/' when ASSET_CDN_URL is unset", () => {
expect(resolveAssetBase({}, "production")).toBe("/");
});
it("returns '/' when ASSET_CDN_URL is empty or whitespace", () => {
expect(resolveAssetBase({ ASSET_CDN_URL: "" }, "production")).toBe("/");
expect(resolveAssetBase({ ASSET_CDN_URL: " " }, "production")).toBe("/");
});
it("returns '/' in development regardless of ASSET_CDN_URL (HMR / dev server)", () => {
expect(resolveAssetBase({ ASSET_CDN_URL: "https://cdn.example.com" }, "development")).toBe("/");
});
it("returns origin with trailing slash for a valid https URL", () => {
expect(resolveAssetBase({ ASSET_CDN_URL: "https://cdn.boiler.saas-ctrl.com" }, "production")).toBe(
"https://cdn.boiler.saas-ctrl.com/"
);
});
it("normalises trailing-slash variants to the same value", () => {
expect(resolveAssetBase({ ASSET_CDN_URL: "https://cdn.example.com/" }, "production")).toBe(
"https://cdn.example.com/"
);
});
it("strips paths from the CDN URL so vite controls the asset subdirectory", () => {
expect(resolveAssetBase({ ASSET_CDN_URL: "https://cdn.example.com/some/path" }, "production")).toBe(
"https://cdn.example.com/"
);
});
it("falls back to '/' for malformed URLs", () => {
expect(resolveAssetBase({ ASSET_CDN_URL: "not-a-url" }, "production")).toBe("/");
});
it("treats an absent mode as non-development", () => {
expect(resolveAssetBase({ ASSET_CDN_URL: "https://cdn.example.com" })).toBe("https://cdn.example.com/");
});
});
@@ -0,0 +1,32 @@
// Resolve the Vite `base` value from the ASSET_CDN_URL env variable.
//
// Kept in its own module (no React Router / Vite plugin imports) so the
// pure logic can be unit-tested under vitest without booting the full
// `apps/web/vite.config.ts` plugin graph.
//
// Behaviour:
// * Dev mode (`mode === "development"`): always returns `/` so the Vite
// dev server and HMR keep serving from the origin.
// * Production / build mode: when `ASSET_CDN_URL` is set to a valid URL,
// returns the origin with a trailing slash (e.g. `https://cdn.example.com/`).
// Vite then prefixes every emitted asset URL with that origin.
// * Empty / whitespace / malformed values fall back to `/` so a
// misconfigured deploy never breaks the build outright. The env-drift
// checks in CI are the right place to fail loudly on bad URLs.
//
// See: docs/improvement-plan/03-scaling-infrastructure/asset-cdn.md
// docs/ops/asset-cdn.md
export function resolveAssetBase(
env: Partial<Record<string, string | undefined>> = process.env,
mode: string | undefined = undefined
): string {
if (mode === "development") return "/";
const raw = env.ASSET_CDN_URL?.trim();
if (!raw) return "/";
try {
const url = new URL(raw);
return `${url.origin}/`;
} catch {
return "/";
}
}
@@ -0,0 +1,133 @@
import { describe, expect, it } from "vitest";
import { classifyBuildWarning, shouldSuppressBuildWarning } from "./buildWarningTriage";
describe("build warning triage", () => {
it("classifies large chunk warnings without suppressing them", () => {
const classification = classifyBuildWarning({
code: "PLUGIN_WARNING",
message: "Some chunks are larger than 1800 kB after minification. Consider code-splitting.",
});
expect(classification.class).toBe("large-chunk");
expect(classification.suppressed).toBe(false);
expect(shouldSuppressBuildWarning(classification)).toBe(false);
});
it("suppresses known empty chunk warnings that are covered by the bundle gate", () => {
const classification = classifyBuildWarning({
code: "EMPTY_BUNDLE",
message: 'Generated an empty chunk: "readyz".',
id: "apps/web/app/routes/readyz.tsx",
});
expect(classification.class).toBe("empty-chunk");
expect(classification.suppressed).toBe(true);
expect(shouldSuppressBuildWarning(classification)).toBe(true);
});
it("suppresses current React Router resource route empty chunks", () => {
const classification = classifyBuildWarning({
code: "EMPTY_BUNDLE",
message: 'Generated an empty chunk: "api.embed.forms._formKey.submit".',
});
expect(classification.class).toBe("empty-chunk");
expect(classification.suppressed).toBe(true);
expect(shouldSuppressBuildWarning(classification)).toBe(true);
});
it("keeps unknown empty chunk warnings visible", () => {
const classification = classifyBuildWarning({
code: "EMPTY_BUNDLE",
message: 'Generated an empty chunk: "new-empty-route".',
id: "apps/web/app/routes/new-empty-route.tsx",
});
expect(classification.class).toBe("empty-chunk");
expect(classification.suppressed).toBe(false);
expect(shouldSuppressBuildWarning(classification)).toBe(false);
});
it("classifies sourcemap resolve failures as suppressed", () => {
const classification = classifyBuildWarning({
code: "SOURCEMAP_ERROR",
message: "Can't resolve original location of error.",
id: "node_modules/example/index.js",
});
expect(classification.class).toBe("sourcemap-resolve-failure");
expect(classification.suppressed).toBe(true);
expect(shouldSuppressBuildWarning(classification)).toBe(true);
});
it("classifies production sourcemap warnings without suppressing them", () => {
const classification = classifyBuildWarning({
message: "Source maps are enabled in production",
});
expect(classification.class).toBe("production-sourcemap");
expect(classification.suppressed).toBe(false);
expect(shouldSuppressBuildWarning(classification)).toBe(false);
});
it("classifies AWS and Smithy circular chunk warnings without suppressing them", () => {
const classification = classifyBuildWarning({
message: [
'Export "Command" of module "../../node_modules/.pnpm/@smithy+core/node_modules/@smithy/core/dist-es/submodules/client/smithy-client/command.js"',
'was reexported through module "../../node_modules/.pnpm/@aws-sdk+nested-clients/node_modules/@aws-sdk/nested-clients/dist-es/index.js"',
"while both modules are dependencies of each other and will end up in different chunks by current Rollup settings.",
"This scenario is not well supported at the moment as it will produce a circular dependency between chunks and will likely lead to broken execution order.",
].join(" "),
});
expect(classification.class).toBe("aws-smithy-circular-chunk");
expect(classification.suppressed).toBe(false);
expect(shouldSuppressBuildWarning(classification)).toBe(false);
});
it("classifies dynamic/static import chunking warnings without suppressing them", () => {
const classification = classifyBuildWarning({
message:
"apps/web/app/utils/db.server.ts is dynamically imported by apps/web/app/routes/admin/marketing-leads/index.tsx but also statically imported by apps/web/app/modules/crm/services/CrmService.ts, dynamic import will not move module into another chunk.",
});
expect(classification.class).toBe("dynamic-static-import");
expect(classification.suppressed).toBe(false);
expect(shouldSuppressBuildWarning(classification)).toBe(false);
});
it("classifies next-themes invalid annotation warnings as suppressed", () => {
const classification = classifyBuildWarning({
code: "INVALID_ANNOTATION",
message: "Contains an annotation that Rollup cannot interpret.",
id: "/repo/node_modules/next-themes/dist/index.mjs",
});
expect(classification.class).toBe("next-themes-invalid-annotation");
expect(classification.suppressed).toBe(true);
expect(shouldSuppressBuildWarning(classification)).toBe(true);
});
it("keeps unknown warnings unsuppressed", () => {
const classification = classifyBuildWarning({
code: "CIRCULAR_DEPENDENCY",
message: "Circular dependency detected between route modules.",
file: "apps/web/app/routes/dashboard.tsx",
});
expect(classification.class).toBe("unknown");
expect(classification.suppressed).toBe(false);
expect(shouldSuppressBuildWarning(classification)).toBe(false);
});
it("handles casing drift for known warning codes and messages", () => {
const classification = classifyBuildWarning({
code: "sourcemap_error",
message: "can't resolve original location of error.",
});
expect(classification.class).toBe("sourcemap-resolve-failure");
expect(classification.suppressed).toBe(true);
});
});
@@ -0,0 +1,116 @@
export type BuildWarningInput = {
code?: string | null;
message?: string | null;
id?: string | null;
file?: string | null;
};
export type BuildWarningClass =
| "large-chunk"
| "empty-chunk"
| "production-sourcemap"
| "aws-smithy-circular-chunk"
| "dynamic-static-import"
| "sourcemap-resolve-failure"
| "next-themes-invalid-annotation"
| "unknown";
export type BuildWarningClassification = {
class: BuildWarningClass;
suppressed: boolean;
code?: string;
message: string;
id?: string;
file?: string;
};
const normalize = (value: string | null | undefined) => value?.toLowerCase() ?? "";
const EMPTY_CHUNK_ALLOWLIST = new Set([
"___entity.export",
"api.collab.token",
"api.embed.forms._formKey.submit",
"api.leads.capture",
"api.metrics",
"api.password-policy-remind",
"api.status",
"api.tenants._tenant.plugins",
"callback",
"dry-run",
"healthcheck",
"healthz",
"ip-address-log-labels",
"logout",
"null",
"onboarding._id",
"postman_collection_._json",
"preferences",
"readyz",
"start",
"stream",
"submit",
"swagger",
"track-click._deliveryId",
"track-open._deliveryId_.gif_",
"workflows._id.run.stream._executionId",
]);
function getEmptyChunkName(message: string): string | null {
const match = /generated an empty chunk:\s*["']?([^"'.]+(?:\.[^"'.]+)*)["']?/i.exec(message);
return match?.[1] ?? null;
}
export function classifyBuildWarning(warning: BuildWarningInput): BuildWarningClassification {
const code = warning.code ?? undefined;
const message = warning.message ?? "";
const id = warning.id ?? undefined;
const file = warning.file ?? undefined;
const normalizedCode = normalize(code);
const normalizedMessage = normalize(message);
const normalizedId = normalize(id);
let warningClass: BuildWarningClass = "unknown";
if (
normalizedMessage.includes("some chunks are larger than") ||
normalizedMessage.includes("chunk size limit") ||
normalizedMessage.includes("chunk size warning")
) {
warningClass = "large-chunk";
} else if (normalizedMessage.includes("generated an empty chunk") || normalizedMessage.includes("empty chunk")) {
warningClass = "empty-chunk";
} else if (normalizedMessage.includes("source maps are enabled in production")) {
warningClass = "production-sourcemap";
} else if (
normalizedMessage.includes("will end up in different chunks") &&
normalizedMessage.includes("circular dependency between chunks") &&
(normalizedMessage.includes("@aws-sdk") || normalizedMessage.includes("@smithy"))
) {
warningClass = "aws-smithy-circular-chunk";
} else if (
normalizedMessage.includes("is dynamically imported by") &&
normalizedMessage.includes("but also statically imported by") &&
normalizedMessage.includes("dynamic import will not move module into another chunk")
) {
warningClass = "dynamic-static-import";
} else if (normalizedCode === "sourcemap_error" && normalizedMessage.includes("can't resolve original location")) {
warningClass = "sourcemap-resolve-failure";
} else if (normalizedCode === "invalid_annotation" && normalizedId.includes("next-themes")) {
warningClass = "next-themes-invalid-annotation";
}
const emptyChunkName = warningClass === "empty-chunk" ? getEmptyChunkName(message) : null;
const suppressEmptyChunk = emptyChunkName !== null && EMPTY_CHUNK_ALLOWLIST.has(emptyChunkName);
return {
class: warningClass,
suppressed: suppressEmptyChunk || warningClass === "sourcemap-resolve-failure" || warningClass === "next-themes-invalid-annotation",
code,
message,
id,
file,
};
}
export function shouldSuppressBuildWarning(warning: BuildWarningInput): boolean {
return classifyBuildWarning(warning).suppressed;
}
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import { analyzeBundleScan, formatBundleScanSummary } from "./bundleScan";
describe("bundle scan", () => {
it("reports actionable over-budget vendor chunks", () => {
const result = analyzeBundleScan({
assets: [{ name: "assets/vendor-react.abcd.js", sizeKb: 620 }],
warnings: [
{
code: "PLUGIN_WARNING",
message: "Some chunks are larger than 500 kB after minification.",
},
],
});
expect(result.budgetViolations).toEqual([
{
name: "assets/vendor-react.abcd.js",
sizeKb: 620,
thresholdKb: 500,
budgetKind: "vendor",
},
]);
expect(result.warnings[0]?.class).toBe("large-chunk");
});
it("accepts chunks within explicit app, vendor and editor budgets", () => {
const result = analyzeBundleScan({
assets: [
{ name: "assets/routes-dashboard.js", sizeKb: 340 },
{ name: "assets/vendor-radix.js", sizeKb: 480 },
{ name: "assets/monaco-editor.js", sizeKb: 840 },
],
});
expect(result.budgetViolations).toEqual([]);
});
it("formats largest chunk summaries in deterministic size order", () => {
const result = analyzeBundleScan(
{
assets: [
{ name: "assets/workflow.js", sizeKb: 220 },
{ name: "assets/vendor-react.js", sizeKb: 500 },
{ name: "assets/editor-monaco.js", sizeKb: 500 },
{ name: "assets/settings.js", sizeKb: 120 },
],
},
{ largestChunkLimit: 3 },
);
expect(result.largestChunks.map((asset) => asset.name)).toEqual([
"assets/editor-monaco.js",
"assets/vendor-react.js",
"assets/workflow.js",
]);
expect(formatBundleScanSummary(result)).toContain("- assets/editor-monaco.js: 500 kB");
});
it("keeps unknown warnings visible instead of suppressing them", () => {
const result = analyzeBundleScan({
assets: [{ name: "assets/app.js", sizeKb: 100 }],
warnings: [
{
code: "SYNTHETIC_NEW_WARNING",
message: "Bundler emitted a warning this helper does not know yet.",
file: "apps/web/app/routes/dashboard.tsx",
},
],
});
expect(result.unknownWarnings).toHaveLength(1);
expect(result.unknownWarnings[0]).toMatchObject({
class: "unknown",
suppressed: false,
code: "SYNTHETIC_NEW_WARNING",
});
expect(formatBundleScanSummary(result)).toContain(
"- SYNTHETIC_NEW_WARNING: Bundler emitted a warning this helper does not know yet.",
);
});
});
@@ -0,0 +1,120 @@
import { type BuildWarningInput, classifyBuildWarning } from "./buildWarningTriage";
export type BundleAsset = {
name: string;
sizeKb: number;
};
export type BundleBudgetKind = "editor" | "vendor" | "app";
export type BundleBudget = {
kind: BundleBudgetKind;
thresholdKb: number;
};
export type BundleBudgetViolation = BundleAsset & {
thresholdKb: number;
budgetKind: BundleBudgetKind;
};
export type BundleScanOptions = {
budgets?: Partial<Record<BundleBudgetKind, number>>;
largestChunkLimit?: number;
};
export type BundleScanResult = {
assets: BundleAsset[];
largestChunks: BundleAsset[];
budgetViolations: BundleBudgetViolation[];
warnings: ReturnType<typeof classifyBuildWarning>[];
unknownWarnings: ReturnType<typeof classifyBuildWarning>[];
};
export const DEFAULT_BUNDLE_BUDGETS: Record<BundleBudgetKind, number> = {
app: 350,
vendor: 500,
editor: 850,
};
const EDITOR_ASSET_PATTERN = /(?:monaco|editor|codemirror)/i;
const VENDOR_ASSET_PATTERN = /(?:vendor|node_modules|react|radix|tiptap|recharts|xyflow)/i;
function getBudgetKind(assetName: string): BundleBudgetKind {
if (EDITOR_ASSET_PATTERN.test(assetName)) {
return "editor";
}
if (VENDOR_ASSET_PATTERN.test(assetName)) {
return "vendor";
}
return "app";
}
function sortLargestFirst(left: BundleAsset, right: BundleAsset) {
if (right.sizeKb !== left.sizeKb) {
return right.sizeKb - left.sizeKb;
}
return left.name.localeCompare(right.name);
}
export function analyzeBundleScan(
input: { assets: BundleAsset[]; warnings?: BuildWarningInput[] },
options: BundleScanOptions = {},
): BundleScanResult {
const budgets = { ...DEFAULT_BUNDLE_BUDGETS, ...options.budgets };
const assets = [...input.assets].sort((left, right) => left.name.localeCompare(right.name));
const warnings = (input.warnings ?? []).map((warning) => classifyBuildWarning(warning));
const largestChunkLimit = options.largestChunkLimit ?? 5;
const budgetViolations = assets
.map((asset) => {
const budgetKind = getBudgetKind(asset.name);
const thresholdKb = budgets[budgetKind];
return { ...asset, thresholdKb, budgetKind };
})
.filter((asset) => asset.sizeKb > asset.thresholdKb)
.sort((left, right) => {
if (right.sizeKb !== left.sizeKb) {
return right.sizeKb - left.sizeKb;
}
return left.name.localeCompare(right.name);
});
return {
assets,
largestChunks: [...assets].sort(sortLargestFirst).slice(0, largestChunkLimit),
budgetViolations,
warnings,
unknownWarnings: warnings.filter((warning) => warning.class === "unknown"),
};
}
export function formatBundleScanSummary(result: BundleScanResult): string {
const lines = [
`Bundle scan: ${result.assets.length} assets, ${result.budgetViolations.length} budget violations, ${result.unknownWarnings.length} unknown warnings.`,
"Largest chunks:",
...result.largestChunks.map((asset) => `- ${asset.name}: ${asset.sizeKb} kB`),
];
if (result.budgetViolations.length > 0) {
lines.push(
"Budget violations:",
...result.budgetViolations.map(
(asset) => `- ${asset.name}: ${asset.sizeKb} kB exceeds ${asset.thresholdKb} kB ${asset.budgetKind} budget`,
),
);
}
if (result.unknownWarnings.length > 0) {
lines.push(
"Unknown warnings:",
...result.unknownWarnings.map((warning) => `- ${warning.code ?? "NO_CODE"}: ${warning.message}`),
);
}
return lines.join("\n");
}
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { getManualChunkName } from "./manualChunks";
describe("manual chunk boundaries", () => {
it.each([
["/repo/node_modules/react/index.js", "vendor-react"],
["/repo/node_modules/react-dom/client.js", "vendor-react"],
["/repo/node_modules/react-router/dist/development/index.mjs", "vendor-react"],
["/repo/node_modules/clsx/dist/clsx.mjs", "vendor-core-utils"],
["/repo/node_modules/tailwind-merge/dist/lib/index.mjs", "vendor-core-utils"],
["/repo/node_modules/class-variance-authority/dist/index.mjs", "vendor-core-utils"],
["/repo/node_modules/recharts/es6/index.js", "vendor-charts"],
["/repo/node_modules/d3-shape/src/index.js", "vendor-charts"],
["/repo/node_modules/@monaco-editor/react/dist/index.mjs", "vendor-monaco"],
["/repo/node_modules/monaco-editor/esm/vs/editor/editor.api.js", "vendor-monaco"],
["/repo/node_modules/marked/lib/marked.esm.js", "vendor-markdown"],
["/repo/node_modules/katex/dist/katex.mjs", "vendor-markdown"],
["/repo/node_modules/@tiptap/react/dist/index.js", "vendor-editor-tiptap"],
["/repo/node_modules/prosemirror-state/dist/index.js", "vendor-editor-prosemirror"],
["/repo/node_modules/@react-pdf/renderer/lib/react-pdf.browser.js", "vendor-pdf"],
["/repo/node_modules/@xyflow/react/dist/esm/index.js", "vendor-workflow"],
["/repo/node_modules/@aws-sdk/client-s3/dist-es/S3Client.js", "vendor-aws-sdk"],
["/repo/node_modules/@smithy/core/dist-es/submodules/client/index.js", "vendor-aws-sdk"],
["/repo/node_modules/@react-email/components/dist/index.mjs", "vendor-react-email"],
["/repo/node_modules/react-email/dist/index.js", "vendor-react-email"],
])("maps %s to %s", (id, chunkName) => {
expect(getManualChunkName(id)).toBe(chunkName);
});
it("supports Windows-like node_modules paths from Rollup ids", () => {
expect(getManualChunkName("C:\\repo\\node_modules\\monaco-editor\\esm\\vs\\editor\\editor.api.js")).toBe("vendor-monaco");
});
it.each([
"/repo/apps/web/app/components/monaco-editor/FakeEditor.tsx",
"/repo/apps/web/app/routes/workflows/workflow-builder.tsx",
"/repo/node_modules/streamdown/dist/index.js",
"/repo/node_modules/remark-gfm/index.js",
"/repo/node_modules/hast-util-to-jsx-runtime/index.js",
"/repo/node_modules/shiki/dist/index.mjs",
"/repo/node_modules/@shikijs/langs/dist/javascript.mjs",
"/repo/node_modules/mermaid/dist/mermaid.esm.mjs",
"/repo/node_modules/cytoscape/dist/cytoscape.esm.mjs",
"/repo/node_modules/@tiptap-adjacent/react/index.js",
])("leaves non-heavy or app module %s unchunked", (id) => {
expect(getManualChunkName(id)).toBeUndefined();
});
});
@@ -0,0 +1,46 @@
const NODE_MODULES_SEGMENT = "/node_modules/";
const CHUNK_MATCHERS: Array<{ chunkName: string; packages: string[] }> = [
{ chunkName: "vendor-react", packages: ["react", "react-dom", "react-router", "scheduler", "use-sync-external-store"] },
{ chunkName: "vendor-core-utils", packages: ["clsx", "tailwind-merge", "class-variance-authority"] },
{ chunkName: "vendor-charts", packages: ["recharts", "victory-vendor", "d3-", "d3"] },
{ chunkName: "vendor-markdown", packages: ["marked", "katex"] },
{ chunkName: "vendor-monaco", packages: ["@monaco-editor/react", "monaco-editor"] },
{ chunkName: "vendor-editor-tiptap", packages: ["@tiptap/"] },
{ chunkName: "vendor-editor-prosemirror", packages: ["prosemirror-"] },
{ chunkName: "vendor-pdf", packages: ["@react-pdf/renderer"] },
{ chunkName: "vendor-workflow", packages: ["@xyflow/react"] },
{ chunkName: "vendor-aws-sdk", packages: ["@aws-sdk/", "@smithy/"] },
// Lighthouse-CI (#70) — splits the React-Email render path off the
// critical render bundle so transactional-email pages don't bloat the
// marketing TTI score. Matches the budget declared in scripts/bundle-budgets.json.
{ chunkName: "vendor-react-email", packages: ["@react-email/", "react-email"] },
];
const normalizeModuleId = (id: string) => id.replace(/\\/g, "/");
const hasPackageBoundary = (moduleId: string, packageName: string) => {
const packageRoot = `node_modules/${packageName}`;
if (packageName.endsWith("/") || packageName.endsWith("-")) {
return moduleId.includes(packageRoot);
}
return moduleId.includes(`${packageRoot}/`) || moduleId.endsWith(packageRoot);
};
export function getManualChunkName(id: string): string | undefined {
const normalizedId = normalizeModuleId(id);
if (!normalizedId.includes(NODE_MODULES_SEGMENT)) {
return undefined;
}
for (const matcher of CHUNK_MATCHERS) {
if (matcher.packages.some((packageName) => hasPackageBoundary(normalizedId, packageName))) {
return matcher.chunkName;
}
}
return undefined;
}
@@ -0,0 +1,31 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
const readViteConfigSource = () => readFile(resolve(process.cwd(), "apps/web/vite.config.ts"), "utf8");
describe("vite config integration wiring", () => {
it("wires the manual chunk helper into Rollup output without running a build", async () => {
const source = await readViteConfigSource();
expect(source).toContain('import { getManualChunkName } from "./app/utils/build/manualChunks";');
expect(source).toMatch(/rollupOptions:\s*{[\s\S]*output:\s*{[\s\S]*manualChunks:\s*getManualChunkName/);
});
it("keeps build warning triage connected to Rollup onwarn", async () => {
const source = await readViteConfigSource();
expect(source).toContain('import { shouldSuppressBuildWarning } from "./app/utils/build/buildWarningTriage";');
expect(source).toMatch(/onwarn\(warning,\s*defaultHandler\)\s*{[\s\S]*shouldSuppressBuildWarning\(warning\)/);
expect(source).toMatch(/defaultHandler\(warning\)/);
});
it("keeps production source maps opt-in for Sentry release lanes", async () => {
const source = await readViteConfigSource();
expect(source).toContain('process.env.TOOLCTRL_BUILD_SOURCEMAPS === "1"');
expect(source).toContain("process.env.SENTRY_AUTH_TOKEN && buildSourcemapsEnabled");
expect(source).toMatch(/sourcemap:\s*buildSourcemapsEnabled/);
});
});
@@ -0,0 +1,38 @@
import { LRUCache } from "lru-cache";
import { cachified as originalCachified, CacheEntry, CachifiedOptions, verboseReporter } from "@epic-web/cachified";
const lru = new LRUCache<string, CacheEntry>({ max: 1000 });
// replace this with redisCacheAdapter(redis) if you need to: https://github.com/epicweb-dev/cachified#adapter-for-redis
export const cache = lru;
const CACHE_ENABLED = true;
const CACHE_LOGGING_ENABLED = false;
export async function cachified<Value>(
options: Omit<CachifiedOptions<Value>, "cache"> & {
disabled?: boolean;
}
): Promise<Value> {
if (!CACHE_ENABLED || options.disabled) {
// @ts-ignore
return options.getFreshValue(options);
}
return originalCachified({
...options,
cache,
reporter: CACHE_LOGGING_ENABLED ? verboseReporter() : undefined,
});
}
export function clearCacheKey(key: string): void {
cache.delete(key);
}
export function clearCacheKeyPrefix(prefix: string): void {
for (const key of cache.keys()) {
if (key.startsWith(prefix)) {
cache.delete(key);
}
}
}
@@ -0,0 +1,219 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createRoutePath, createRoutesFromFolders } from "./v1-route-convention";
import type { RouteManifest } from "node_modules/@react-router/remix-config-routes-adapter/dist/manifest";
const routePathCases: Array<{
routeId: string;
expected: string | undefined;
note: string;
}> = [
{ routeId: "index", expected: undefined, note: "root index" },
{ routeId: "admin/index", expected: "admin", note: "nested index" },
{ routeId: "__auth/login", expected: "login", note: "pathless layout segment" },
{ routeId: "account/(settings)", expected: "account/settings?", note: "optional segment" },
{ routeId: "docs/[sitemap.xml]", expected: "docs/sitemap.xml", note: "escaped dot segment" },
{ routeId: "files/$", expected: "files/*", note: "splat segment" },
];
let tempAppDirectory: string | undefined;
function writeRouteFixture(relativePath: string): void {
if (!tempAppDirectory) {
throw new Error("tempAppDirectory must be initialized before writing fixtures");
}
const absolutePath = path.join(tempAppDirectory, relativePath);
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
fs.writeFileSync(absolutePath, "export default function Route() { return null; }\n");
}
function defineRouteManifest(callback: Parameters<typeof createRoutesFromFolders>[0] extends (callback: infer Callback) => RouteManifest ? Callback : never): RouteManifest {
const routes: RouteManifest = Object.create(null);
const parentRoutes: Array<{ id: string }> = [];
const defineRoute = (routePath: string | undefined, file: string, optionsOrChildren?: { id?: string; index?: boolean } | (() => void), children?: () => void) => {
const options = typeof optionsOrChildren === "function" ? {} : (optionsOrChildren ?? {});
const routeChildren = typeof optionsOrChildren === "function" ? optionsOrChildren : children;
const route = {
id: options.id ?? file.replace(/\.[a-z0-9]+$/i, ""),
file,
path: routePath || undefined,
index: options.index ? true : undefined,
parentId: parentRoutes.length > 0 ? parentRoutes[parentRoutes.length - 1].id : "root",
};
routes[route.id] = route;
if (routeChildren) {
parentRoutes.push(route);
routeChildren();
parentRoutes.pop();
}
};
callback(defineRoute);
return routes;
}
describe("v1 route convention path creation", () => {
afterEach(() => {
if (tempAppDirectory) {
fs.rmSync(tempAppDirectory, { recursive: true, force: true });
tempAppDirectory = undefined;
}
});
it.each(routePathCases)("creates route paths for $note", ({ routeId, expected }) => {
expect(createRoutePath(routeId)).toBe(expected);
});
it("never returns empty-string paths for supported route naming patterns", () => {
for (const { routeId } of routePathCases) {
const routePath = createRoutePath(routeId);
expect(routePath === undefined || (typeof routePath === "string" && routePath.length > 0)).toBe(true);
expect(routePath).not.toBe("");
}
});
it("keeps escaped, pathless, and index artifacts from becoming empty strings", () => {
const artifactRouteIds = ["[index]", "__layout/index", "__layout/[.]index"];
const paths = artifactRouteIds.map((routeId) => createRoutePath(routeId));
expect(paths).toEqual(["index", undefined, "."]);
expect(paths).not.toContain("");
});
it("excludes ignored dotfiles, css artifacts, and colocated tests from generated route manifests", () => {
tempAppDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "v1-route-convention-"));
writeRouteFixture("routes/_index.tsx");
writeRouteFixture("routes/blog.tsx");
writeRouteFixture("routes/blog/$slug.tsx");
writeRouteFixture("routes/blog/.draft.tsx");
writeRouteFixture("routes/blog/styles.css");
writeRouteFixture("routes/blog/$slug.test.tsx");
writeRouteFixture("routes/blog/helpers.spec.ts");
writeRouteFixture("routes/blog/real.specimen.tsx");
writeRouteFixture("routes/.hidden.tsx");
writeRouteFixture("routes/root.css");
const manifest = createRoutesFromFolders(defineRouteManifest, {
appDirectory: tempAppDirectory,
ignoredFilePatterns: ["**/.*", "**/*.css", "**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts", "**/*.spec.tsx"],
});
const entries = Object.values(manifest);
const routeIds = entries.map((entry) => entry.id);
const routeFiles = entries.map((entry) => entry.file);
expect(routeFiles).toEqual(["routes/_index.tsx", "routes/blog.tsx", "routes/blog/real.specimen.tsx", "routes/blog/$slug.tsx"]);
expect(routeIds).toEqual(["routes/_index", "routes/blog", "routes/blog/real.specimen", "routes/blog/$slug"]);
expect(new Set(routeIds).size).toBe(routeIds.length);
expect(routeIds.every((id) => id.length > 0)).toBe(true);
expect(routeFiles).not.toEqual(
expect.arrayContaining([expect.stringContaining(".hidden"), expect.stringContaining(".draft"), expect.stringContaining(".css"), expect.stringContaining(".test"), expect.stringContaining(".spec")])
);
});
it("promotes the production tenant route directory shape without synthetic parent routes", () => {
tempAppDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "v1-route-convention-"));
writeRouteFixture("routes/app.$tenant.tsx");
writeRouteFixture("routes/app.$tenant/dashboard.tsx");
writeRouteFixture("routes/app.$tenant/settings.tsx");
writeRouteFixture("routes/app.$tenant/settings/subscription.tsx");
writeRouteFixture("routes/app.$tenant/settings/members.tsx");
const manifest = createRoutesFromFolders(defineRouteManifest, {
appDirectory: tempAppDirectory,
});
const promotedTenantRoutes = Object.fromEntries(
Object.values(manifest)
.filter((entry) => entry.id.startsWith("routes/app.$tenant"))
.map((entry) => [entry.id, { file: entry.file, path: entry.path, parentId: entry.parentId }])
);
expect(promotedTenantRoutes).toMatchObject({
"routes/app.$tenant": { file: "routes/app.$tenant.tsx", path: ":tenant", parentId: "root" },
"routes/app.$tenant/dashboard": { file: "routes/app.$tenant/dashboard.tsx", path: "dashboard", parentId: "routes/app.$tenant" },
"routes/app.$tenant/settings": { file: "routes/app.$tenant/settings.tsx", path: "settings", parentId: "routes/app.$tenant" },
"routes/app.$tenant/settings/members": { file: "routes/app.$tenant/settings/members.tsx", path: "members", parentId: "routes/app.$tenant/settings" },
"routes/app.$tenant/settings/subscription": { file: "routes/app.$tenant/settings/subscription.tsx", path: "subscription", parentId: "routes/app.$tenant/settings" },
});
expect(Object.values(promotedTenantRoutes).map((entry) => entry.path)).not.toEqual(
expect.arrayContaining(["app/:tenant", expect.stringMatching(/^app\/:tenant\//), expect.stringMatching(/^:tenant\/:tenant/)])
);
expect(Object.values(promotedTenantRoutes).every((entry) => typeof entry.file === "string")).toBe(true);
});
it("ignores server-only files when requested by the route config", () => {
tempAppDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "v1-route-convention-"));
writeRouteFixture("routes/app.$tenant.tsx");
writeRouteFixture("routes/app.$tenant/portalAccess.server.ts");
const manifest = createRoutesFromFolders(defineRouteManifest, {
appDirectory: tempAppDirectory,
ignoredFilePatterns: ["**/*.server.ts", "**/*.server.tsx"],
});
expect(Object.values(manifest).map((entry) => entry.file)).toEqual(["routes/app.$tenant.tsx"]);
});
it("ignores route directories when requested by the route config", () => {
tempAppDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "v1-route-convention-"));
writeRouteFixture("routes/admin/ai/index.tsx");
writeRouteFixture("routes/admin/ai/chats.tsx");
writeRouteFixture("routes/admin/dashboard.tsx");
const manifest = createRoutesFromFolders(defineRouteManifest, {
appDirectory: tempAppDirectory,
ignoredFilePatterns: ["admin/ai/**"],
});
expect(Object.values(manifest).map((entry) => entry.file)).toEqual(["routes/admin/dashboard.tsx"]);
});
it("adds canonical admin host aliases under an admin data/layout parent", () => {
tempAppDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "v1-route-convention-"));
writeRouteFixture("routes/admin.tsx");
writeRouteFixture("routes/admin/dashboard.tsx");
writeRouteFixture("routes/admin/mfa-challenge.tsx");
writeRouteFixture("routes/admin/settings.tsx");
writeRouteFixture("routes/admin/settings/profile.tsx");
writeRouteFixture("routes/admin/crm.tsx");
writeRouteFixture("routes/admin/crm/index.tsx");
const manifest = createRoutesFromFolders(defineRouteManifest, {
appDirectory: tempAppDirectory,
});
const adminHostRoutes = Object.fromEntries(
Object.values(manifest)
.filter((entry) => entry.id.includes(".__host"))
.map((entry) => [entry.id, { file: entry.file, path: entry.path, index: entry.index, parentId: entry.parentId }])
);
expect(adminHostRoutes).toMatchObject({
"routes/admin.__host": { file: "routes/admin.tsx", path: undefined, parentId: "root" },
"routes/admin/settings.__host": { file: "routes/admin/settings.tsx", path: undefined, parentId: "routes/admin.__host" },
"routes/admin/settings/profile.__host": {
file: "routes/admin/settings/profile.tsx",
path: "settings/profile",
parentId: "routes/admin/settings.__host",
},
"routes/admin/crm.__host": { file: "routes/admin/crm.tsx", path: "crm", parentId: "routes/admin.__host" },
"routes/admin/crm/index.__host": { file: "routes/admin/crm/index.tsx", path: undefined, index: true, parentId: "routes/admin/crm.__host" },
});
expect(adminHostRoutes).not.toHaveProperty("routes/admin/dashboard.__host");
expect(adminHostRoutes).not.toHaveProperty("routes/admin/mfa-challenge.__host");
expect(manifest).not.toHaveProperty("routes/admin");
expect(Object.values(manifest)).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: "routes/admin", path: "admin" })]));
});
});
@@ -0,0 +1,496 @@
// I copied this file from:
// https://github.com/remix-run/v1-compat-utils/blob/main/packages/v1-route-convention/src/lib.ts
import fs from "node:fs";
import path from "node:path";
import { DefineRoutesFunction } from "@react-router/remix-config-routes-adapter";
import { RouteManifest } from "node_modules/@react-router/remix-config-routes-adapter/dist/manifest";
let paramPrefixChar = "$" as const;
let escapeStart = "[" as const;
let escapeEnd = "]" as const;
let optionalStart = "(" as const;
let optionalEnd = ")" as const;
let routeModuleExts = [".js", ".jsx", ".ts", ".tsx", ".md", ".mdx"];
function isRouteModuleFile(filename: string): boolean {
return routeModuleExts.includes(path.extname(filename));
}
function matchesIgnoredFilePattern(file: string, pattern: string): boolean {
const normalizedFile = file.split(path.sep).join("/");
const normalizedPattern = pattern.split(path.sep).join("/");
if (normalizedPattern === "**/.*") {
return normalizedFile.split("/").some((segment) => segment.startsWith("."));
}
if (normalizedPattern.startsWith("**/*")) {
return normalizedFile.endsWith(normalizedPattern.slice("**/*".length));
}
if (normalizedPattern.endsWith("/**")) {
const directory = normalizedPattern.slice(0, -"/**".length);
return normalizedFile === directory || normalizedFile.startsWith(`${directory}/`);
}
return normalizedFile === normalizedPattern;
}
export type CreateRoutesFromFoldersOptions = {
/**
* The directory where your app lives. Defaults to `app`.
* @default "app"
*/
appDirectory?: string;
/**
* A list of glob patterns to ignore when looking for route modules.
* Defaults to `[]`.
*/
ignoredFilePatterns?: string[];
/**
* The directory where your routes live. Defaults to `routes`.
* This is relative to `appDirectory`.
* @default "routes"
*/
routesDirectory?: string;
};
/**
* Defines routes using the filesystem convention in `apps/web/app/routes`. The rules are:
*
* - Route paths are derived from the file path. A `.` in the filename indicates
* a `/` in the URL (a "nested" URL, but no route nesting). A `$` in the
* filename indicates a dynamic URL segment.
* - Subdirectories are used for nested routes.
*
* For example, a file named `apps/web/app/routes/gists/$username.tsx` creates a route
* with a path of `gists/:username`.
*/
export function createRoutesFromFolders(defineRoutes: DefineRoutesFunction, options: CreateRoutesFromFoldersOptions = {}): RouteManifest {
let { appDirectory = "app", ignoredFilePatterns = [], routesDirectory = "routes" } = options;
let appRoutesDirectory = path.join(appDirectory, routesDirectory);
let files: { [routeId: string]: string } = {};
// First, find all route modules in apps/web/app/routes
visitFiles(appRoutesDirectory, (file) => {
if (ignoredFilePatterns.length > 0 && ignoredFilePatterns.some((pattern) => matchesIgnoredFilePattern(file, pattern))) {
return;
}
if (isRouteModuleFile(file)) {
let relativePath = path.join(routesDirectory, file);
let routeId = createRouteId(relativePath);
files[routeId] = relativePath;
return;
}
throw new Error(`Invalid route module file: ${path.join(appRoutesDirectory, file)}`);
});
let routeIds = Object.keys(files).sort(byLongestFirst);
let parentRouteIds = getParentRouteIds(routeIds);
let uniqueRoutes = new Map<string, string>();
// Then, recurse through all routes using the public defineRoutes() API
function defineNestedRoutes(defineRoute: DefineRouteFunction, parentId?: string): void {
let childRouteIds = routeIds.filter((id) => {
if (!parentId && id === `${routesDirectory}/admin`) {
return false;
}
return parentRouteIds[id] === parentId;
});
for (let routeId of childRouteIds) {
let routePath: string | undefined = createRoutePath(routeId.slice((parentId || routesDirectory).length + 1));
let isIndexRoute = routeId.endsWith("/index");
let fullPath = createRoutePath(routeId.slice(routesDirectory.length + 1));
fullPath = promoteTenantAppRoutePath(fullPath);
routePath = promoteTenantAppRoutePath(routePath);
let uniqueRouteId = (fullPath || "") + (isIndexRoute ? "?index" : "");
let isPathlessLayoutRoute = routeId.split("/").pop()?.startsWith("__") === true;
/**
* We do not try to detect path collisions for pathless layout route
* files because, by definition, they create the potential for route
* collisions _at that level in the tree_.
*
* Consider example where a user may want multiple pathless layout routes
* for different subfolders
*
* routes/
* account.tsx
* account/
* __public/
* login.tsx
* perks.tsx
* __private/
* orders.tsx
* profile.tsx
* __public.tsx
* __private.tsx
*
* In order to support both a public and private layout for `/account/*`
* URLs, we are creating a mutually exclusive set of URLs beneath 2
* separate pathless layout routes. In this case, the route paths for
* both account/__public.tsx and account/__private.tsx is the same
* (/account), but we're again not expecting to match at that level.
*
* By only ignoring this check when the final portion of the filename is
* pathless, we will still detect path collisions such as:
*
* routes/parent/__pathless/foo.tsx
* routes/parent/__pathless2/foo.tsx
*
* and
*
* routes/parent/__pathless/index.tsx
* routes/parent/__pathless2/index.tsx
*/
if (uniqueRouteId && !isPathlessLayoutRoute) {
if (uniqueRoutes.has(uniqueRouteId)) {
throw new Error(
`Path ${JSON.stringify(fullPath || "/")} defined by route ` +
`${JSON.stringify(routeId)} conflicts with route ` +
`${JSON.stringify(uniqueRoutes.get(uniqueRouteId))}`
);
} else {
uniqueRoutes.set(uniqueRouteId, routeId);
}
}
if (isIndexRoute) {
let invalidChildRoutes = routeIds.filter((id) => parentRouteIds[id] === routeId);
if (invalidChildRoutes.length > 0) {
throw new Error(`Child routes are not allowed in index routes. Please remove child routes of ${routeId}`);
}
defineRoute(routePath, files[routeId], { index: true, id: routeId });
} else {
defineRoute(routePath, files[routeId], { id: routeId }, () => {
defineNestedRoutes(defineRoute, routeId);
});
}
}
}
function defineAdminHostRoutes(defineRoute: DefineRouteFunction): void {
const adminRouteId = `${routesDirectory}/admin`;
if (!files[adminRouteId]) {
return;
}
defineRoute(undefined, files[adminRouteId], { id: getAdminHostRouteId(adminRouteId) }, () => {
defineAdminHostChildRoutes(defineRoute, adminRouteId);
});
}
function defineAdminHostChildRoutes(defineRoute: DefineRouteFunction, parentId: string): void {
let childRouteIds = routeIds.filter((id) => {
return parentRouteIds[id] === parentId && shouldCreateAdminHostRoute(id);
});
for (let routeId of childRouteIds) {
const routePath = createAdminHostRoutePath(routeId, parentId);
let isIndexRoute = routeId.endsWith("/index");
if (isIndexRoute) {
defineRoute(routePath, files[routeId], { index: true, id: getAdminHostRouteId(routeId) });
} else {
defineRoute(routePath, files[routeId], { id: getAdminHostRouteId(routeId) }, () => {
defineAdminHostChildRoutes(defineRoute, routeId);
});
}
}
}
return defineRoutes((defineRoute) => {
defineAdminHostRoutes(defineRoute);
defineNestedRoutes(defineRoute);
});
}
// Mirrors Remix v1 route-id parsing; covered by v1-route-convention tests so
// route migration changes fail before runtime.
export function createRoutePath(partialRouteId: string): string | undefined {
let result = "";
let rawSegmentBuffer = "";
let inEscapeSequence = 0;
let inOptionalSegment = 0;
let optionalSegmentIndex = null;
let skipSegment = false;
for (let i = 0; i < partialRouteId.length; i++) {
let char = partialRouteId.charAt(i);
let prevChar = i > 0 ? partialRouteId.charAt(i - 1) : undefined;
let nextChar = i < partialRouteId.length - 1 ? partialRouteId.charAt(i + 1) : undefined;
function isNewEscapeSequence() {
return !inEscapeSequence && char === escapeStart && prevChar !== escapeStart;
}
function isCloseEscapeSequence() {
return inEscapeSequence && char === escapeEnd && nextChar !== escapeEnd;
}
function isStartOfLayoutSegment() {
return char === "_" && nextChar === "_" && !rawSegmentBuffer;
}
function isNewOptionalSegment() {
return (
char === optionalStart &&
prevChar !== optionalStart &&
(isSegmentSeparator(prevChar) || prevChar === undefined) &&
!inOptionalSegment &&
!inEscapeSequence
);
}
function isCloseOptionalSegment() {
return (
char === optionalEnd && nextChar !== optionalEnd && (isSegmentSeparator(nextChar) || nextChar === undefined) && inOptionalSegment && !inEscapeSequence
);
}
if (skipSegment) {
if (isSegmentSeparator(char)) {
skipSegment = false;
}
continue;
}
if (isNewEscapeSequence()) {
inEscapeSequence++;
continue;
}
if (isCloseEscapeSequence()) {
inEscapeSequence--;
continue;
}
if (isNewOptionalSegment()) {
inOptionalSegment++;
optionalSegmentIndex = result.length;
result += optionalStart;
continue;
}
if (isCloseOptionalSegment()) {
if (optionalSegmentIndex !== null) {
result = result.slice(0, optionalSegmentIndex) + result.slice(optionalSegmentIndex + 1);
}
optionalSegmentIndex = null;
inOptionalSegment--;
result += "?";
continue;
}
if (inEscapeSequence) {
result += char;
continue;
}
if (isSegmentSeparator(char)) {
if (rawSegmentBuffer === "index" && result.endsWith("index")) {
result = result.replace(/\/?index$/, "");
} else {
result += "/";
}
rawSegmentBuffer = "";
inOptionalSegment = 0;
optionalSegmentIndex = null;
continue;
}
if (isStartOfLayoutSegment()) {
skipSegment = true;
continue;
}
rawSegmentBuffer += char;
if (char === paramPrefixChar) {
if (nextChar === optionalEnd) {
throw new Error(`Invalid route path: ${partialRouteId}. Splat route $ is already optional`);
}
result += typeof nextChar === "undefined" ? "*" : ":";
continue;
}
result += char;
}
if (rawSegmentBuffer === "index" && result.endsWith("index")) {
result = result.replace(/\/?index$/, "");
} else {
result = result.replace(/\/$/, "");
}
if (rawSegmentBuffer === "index" && result.endsWith("index?")) {
throw new Error(`Invalid route path: ${partialRouteId}. Make index route optional by using (index)`);
}
return result || undefined;
}
function isSegmentSeparator(checkChar: string | undefined) {
if (!checkChar) return false;
return ["/", ".", path.win32.sep].includes(checkChar);
}
function promoteTenantAppRoutePath(routePath: string | undefined): string | undefined {
if (routePath === "app/:tenant") {
return ":tenant";
}
if (routePath?.startsWith("app/:tenant/")) {
return routePath.replace(/^app\/:tenant\//, ":tenant/");
}
return routePath;
}
function getAdminHostRouteId(routeId: string): string {
return `${routeId}.__host`;
}
function shouldCreateAdminHostRoute(routeId: string): boolean {
if (!routeId.startsWith("routes/admin/")) {
return false;
}
const canonicalPath = createRoutePath(routeId.slice("routes/admin/".length));
if (!canonicalPath) {
return true;
}
return !["404", "dashboard", "mfa-challenge"].includes(canonicalPath);
}
function createAdminHostRoutePath(routeId: string, parentId: string): string | undefined {
if (routeId === "routes/admin/settings") {
return undefined;
}
if (parentId === "routes/admin/settings") {
return createRoutePath(routeId.slice("routes/admin/".length));
}
return createRoutePath(routeId.slice(parentId.length + 1));
}
function addPromotedTenantDescendantRouteIds(routeIds: string[]): string[] {
const routeIdSet = new Set(routeIds);
for (const routeId of routeIds) {
if (!routeId.startsWith("routes/app.$tenant/")) {
continue;
}
const segments = routeId.split("/");
for (let index = segments.length - 1; index > 1; index--) {
routeIdSet.add(segments.slice(0, index).join("/"));
}
}
return [...routeIdSet].sort(byLongestFirst);
}
function getParentRouteIds(routeIds: string[]): Record<string, string | undefined> {
return routeIds.reduce<Record<string, string | undefined>>(
(parentRouteIds, childRouteId) => ({
...parentRouteIds,
[childRouteId]: routeIds.find((id) => childRouteId.startsWith(`${id}/`)),
}),
{}
);
}
function byLongestFirst(a: string, b: string): number {
return b.length - a.length;
}
function visitFiles(dir: string, visitor: (file: string) => void, baseDir = dir): void {
for (let filename of fs.readdirSync(dir)) {
let file = path.resolve(dir, filename);
let stat = fs.lstatSync(file);
if (stat.isDirectory()) {
visitFiles(file, visitor, baseDir);
} else if (stat.isFile()) {
visitor(path.relative(baseDir, file));
}
}
}
/*
eslint
no-loop-func: "off",
*/
function createRouteId(file: string) {
return normalizeSlashes(stripFileExtension(file));
}
function normalizeSlashes(file: string) {
return file.split(path.win32.sep).join("/");
}
function stripFileExtension(file: string) {
return file.replace(/\.[a-z0-9]+$/i, "");
}
interface DefineRouteOptions {
/**
* Should be `true` if the route `path` is case-sensitive. Defaults to
* `false`.
*/
caseSensitive?: boolean;
/**
* Should be `true` if this is an index route that does not allow child routes.
*/
index?: boolean;
/**
* An optional unique id string for this route. Use this if you need to aggregate
* two or more routes with the same route file.
*/
id?: string;
}
interface DefineRouteChildren {
(): void;
}
interface DefineRouteFunction {
(
/**
* The path this route uses to match the URL pathname.
*/
path: string | undefined,
/**
* The path to the file that exports the React component rendered by this
* route as its default export, relative to the `app` directory.
*/
file: string,
/**
* Options for defining routes, or a function for defining child routes.
*/
optionsOrChildren?: DefineRouteOptions | DefineRouteChildren,
/**
* A function for defining child routes.
*/
children?: DefineRouteChildren
): void;
}
@@ -0,0 +1,3 @@
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
export type v2MetaFunction<T = unknown> = (args: { data: T & { metatags: MetaTagsDto } }) => MetaTagsDto;
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { resolveConnectorNavState } from "./connectorNavState";
describe("resolveConnectorNavState", () => {
it("maps an allowed decision to 'active'", () => {
expect(resolveConnectorNavState({ allowed: true })).toBe("active");
expect(resolveConnectorNavState({ allowed: true, reason: "included" })).toBe("active");
});
it("maps blueprint/role/override denials to 'hidden'", () => {
expect(resolveConnectorNavState({ allowed: false, reason: "blueprint-disabled" })).toBe("hidden");
expect(resolveConnectorNavState({ allowed: false, reason: "tenant-role-missing" })).toBe("hidden");
expect(resolveConnectorNavState({ allowed: false, reason: "tenant-override-disabled" })).toBe("hidden");
expect(resolveConnectorNavState({ allowed: false, reason: "tenant-override-not-toggleable" })).toBe("hidden");
});
it("maps entitlement/plan denials to 'locked' (upsell)", () => {
expect(resolveConnectorNavState({ allowed: false, reason: "not-included" })).toBe("locked");
expect(resolveConnectorNavState({ allowed: false, reason: "limit-reached" })).toBe("locked");
expect(resolveConnectorNavState({ allowed: false, reason: "no-subscription" })).toBe("locked");
});
it("defaults to 'locked' when a denial carries no reason", () => {
expect(resolveConnectorNavState({ allowed: false })).toBe("locked");
});
});
@@ -0,0 +1,33 @@
/**
* Konnektor → Sidebar-Zustand (R-P1c).
*
* Bildet eine Konnektor-Entscheidung (aus {@link tenantHasModule} / `resolveTenantProductAccess`)
* auf die drei Sidebar-Zustände ab, die das Walking-Skeleton exemplarisch demonstriert:
*
* - `active` — Tenant darf das Modul nutzen → Eintrag sichtbar + klickbar.
* - `locked` — Modul ist im Produkt-Blueprint, aber nicht im Abo des Tenants → sichtbar,
* aber `disabled` (Upsell-Hinweis).
* - `hidden` — Modul gilt für diesen Tenant/User gar nicht (nicht im Blueprint, Rolle fehlt,
* per Override abgeschaltet) → kein Eintrag (404 statt 403, Bauprinzip A.5.3).
*/
export type ConnectorNavState = "active" | "locked" | "hidden";
export interface ConnectorDecisionLike {
readonly allowed: boolean;
readonly reason?: string;
}
// Gründe, bei denen das Modul für diesen Tenant/User schlicht nicht existiert → ausblenden.
const HIDDEN_REASONS = new Set<string>(["blueprint-disabled", "tenant-role-missing", "tenant-override-disabled", "tenant-override-not-toggleable"]);
export function resolveConnectorNavState(decision: ConnectorDecisionLike): ConnectorNavState {
if (decision.allowed) {
return "active";
}
if (decision.reason && HIDDEN_REASONS.has(decision.reason)) {
return "hidden";
}
// Übrige Gründe (Entitlement nicht enthalten / Limit erreicht / kein Abo) = im Blueprint, aber
// nicht gebucht → gesperrt mit Upsell.
return "locked";
}
@@ -0,0 +1,101 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// Cache-Abstraktion: getFreshValue direkt durchreichen (deterministisch, kein Cache-State zwischen Tests).
vi.mock("~/utils/cache.server", () => ({
cachified: vi.fn(async (opts: { getFreshValue: () => Promise<unknown> }) => opts.getFreshValue()),
clearCacheKeyPrefix: vi.fn(),
}));
vi.mock("~/utils/entitlements/.server/productAccess.server", () => ({
resolveTenantProductAccess: vi.fn(),
}));
vi.mock("~/utils/services/.server/subscriptionService", () => ({
getActiveTenantSubscriptions: vi.fn(async () => null),
}));
vi.mock("~/utils/services/.server/featureFlagEvaluator.server", () => ({
evaluateFlagForContext: vi.fn(),
}));
import { clearCacheKeyPrefix } from "~/utils/cache.server";
import { resolveTenantProductAccess } from "~/utils/entitlements/.server/productAccess.server";
import { getActiveTenantSubscriptions } from "~/utils/services/.server/subscriptionService";
import { evaluateFlagForContext } from "~/utils/services/.server/featureFlagEvaluator.server";
import { invalidateTenantModuleAccessCache, tenantHasFlag, tenantHasModule } from "./tenantConnector.server";
const resolveMock = vi.mocked(resolveTenantProductAccess);
const subscriptionMock = vi.mocked(getActiveTenantSubscriptions);
const flagMock = vi.mocked(evaluateFlagForContext);
const clearPrefixMock = vi.mocked(clearCacheKeyPrefix);
describe("tenantHasModule", () => {
beforeEach(() => {
vi.clearAllMocks();
subscriptionMock.mockResolvedValue(null as never);
});
it("returns true when the product-access decision allows the module", async () => {
resolveMock.mockResolvedValue({ allowed: true } as never);
const allowed = await tenantHasModule({ tenantId: "t1", userId: "u1", moduleKey: "crm-basic" });
expect(allowed).toBe(true);
expect(subscriptionMock).toHaveBeenCalledWith("t1");
expect(resolveMock).toHaveBeenCalledWith(
expect.objectContaining({ tenantId: "t1", userId: "u1", featureKey: "crm-basic" })
);
});
it("returns false when the product-access decision denies the module", async () => {
resolveMock.mockResolvedValue({ allowed: false } as never);
const allowed = await tenantHasModule({ tenantId: "t1", userId: "u1", moduleKey: "kwp" });
expect(allowed).toBe(false);
});
it("forwards the bypassTenantRolePermission flag to the resolver", async () => {
resolveMock.mockResolvedValue({ allowed: true } as never);
await tenantHasModule({ tenantId: "t1", userId: "u1", moduleKey: "crm-basic", bypassTenantRolePermission: true });
expect(resolveMock).toHaveBeenCalledWith(expect.objectContaining({ bypassTenantRolePermission: true }));
});
});
describe("tenantHasFlag", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns the evaluator's enabled boolean", async () => {
flagMock.mockResolvedValue({ enabled: true } as never);
const enabled = await tenantHasFlag({ flagKey: "new-dashboard", tenantId: "t1", userId: "u1" });
expect(enabled).toBe(true);
expect(flagMock).toHaveBeenCalledWith("new-dashboard", { flagName: "new-dashboard", tenantId: "t1", userId: "u1" });
});
it("normalizes missing tenant/user context to null", async () => {
flagMock.mockResolvedValue({ enabled: false } as never);
const enabled = await tenantHasFlag({ flagKey: "beta" });
expect(enabled).toBe(false);
expect(flagMock).toHaveBeenCalledWith("beta", { flagName: "beta", tenantId: null, userId: null });
});
});
describe("invalidateTenantModuleAccessCache", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("clears every cached module decision for the tenant", () => {
invalidateTenantModuleAccessCache("t1");
expect(clearPrefixMock).toHaveBeenCalledWith("connector:module:t1:");
});
});
@@ -0,0 +1,94 @@
import { cachified, clearCacheKeyPrefix } from "~/utils/cache.server";
import { resolveTenantProductAccess } from "~/utils/entitlements/.server/productAccess.server";
import { getActiveTenantSubscriptions } from "~/utils/services/.server/subscriptionService";
import { evaluateFlagForContext } from "~/utils/services/.server/featureFlagEvaluator.server";
/**
* Konnektor (R-P1b) — das kanonische Pflicht-Gate jeder Modul-Phase.
*
* Zwei Boolean-Checks, auf die jedes Folge-Modul aufsetzt (Reachability / Sidebar-Gating /
* Loader-Guard). Beide bauen auf bestehende Resolver auf, statt sie nachzubauen:
*
* - `tenantHasModule()` — "Darf dieser Tenant dieses Modul nutzen?" Brücke Billing↔Feature-Gate:
* prüft Abo/Entitlement (`SubscriptionFeature` über das aktive Tenant-Abo), Produkt-Blueprint,
* Tenant-Override und ggf. Tenant-Rollen-Permission via {@link resolveTenantProductAccess}.
* Die Modul-Entscheidung wird auf Connector-Ebene gecacht (`getActiveTenantSubscriptions` ist
* bereits gecacht; die zusätzlichen Konfigurations-/Override-/Permission-Queries nicht).
*
* - `tenantHasFlag()` — "Ist dieses Feature-Flag für diesen Kontext aktiv?" Delegiert an den
* v2-Evaluator {@link evaluateFlagForContext}, der bereits eigenen Eval-Cache mitbringt.
*
* Cache-Backing ist die App-Cache-Abstraktion (`~/utils/cache.server`, LRU heute, per
* `redisCacheAdapter` Redis-swappbar — Infra-Schalter, kein Logik-Eingriff).
*/
const MODULE_CACHE_TTL_MS = 60_000;
const MODULE_CACHE_PREFIX = "connector:module:";
export interface TenantModuleAccessInput {
/** Tenant, dessen Abo/Entitlement geprüft wird. */
readonly tenantId: string;
/** Aktueller User (für Tenant-Rollen-Permission + Super-Admin-Bypass). */
readonly userId: string;
/** Modul-/Feature-Code (z.B. "crm-basic"), wie in `SubscriptionFeature.name` / `featureKeys`. */
readonly moduleKey: string;
/** Rollen-Permission-Prüfung umgehen (z.B. System-/Hintergrund-Kontext). */
readonly bypassTenantRolePermission?: boolean;
}
function moduleCacheKey(input: TenantModuleAccessInput): string {
const scope = input.bypassTenantRolePermission ? "bypass" : "rbac";
return `${MODULE_CACHE_PREFIX}${input.tenantId}:${input.userId}:${input.moduleKey}:${scope}`;
}
/**
* Konnektor-Gate: Ist das Modul für den Tenant freigeschaltet?
*
* @returns `true`, wenn der Tenant das Modul nutzen darf (Abo + Blueprint + Override + Rolle ok).
*/
export async function tenantHasModule(input: TenantModuleAccessInput): Promise<boolean> {
return cachified({
key: moduleCacheKey(input),
ttl: MODULE_CACHE_TTL_MS,
getFreshValue: async () => {
const subscription = await getActiveTenantSubscriptions(input.tenantId);
const decision = await resolveTenantProductAccess({
tenantId: input.tenantId,
userId: input.userId,
featureKey: input.moduleKey,
subscription,
bypassTenantRolePermission: input.bypassTenantRolePermission,
});
return decision.allowed;
},
});
}
export interface TenantFlagContext {
/** Flag-Name/-Key wie in der FeatureFlag-Tabelle. */
readonly flagKey: string;
readonly tenantId?: string | null;
readonly userId?: string | null;
}
/**
* Konnektor-Gate: Ist das Feature-Flag für den Kontext aktiv?
*
* @returns `true`, wenn das Flag (global/Tenant/User-scoped + Regeln) enabled ist.
*/
export async function tenantHasFlag(input: TenantFlagContext): Promise<boolean> {
const result = await evaluateFlagForContext(input.flagKey, {
flagName: input.flagKey,
tenantId: input.tenantId ?? null,
userId: input.userId ?? null,
});
return result.enabled;
}
/**
* Verwirft alle gecachten Modul-Entscheidungen eines Tenants. Aufrufen nach Abo-/Override-/
* Rollen-Änderungen (z.B. Stripe-Webhook, Admin-Plan-Wechsel), damit das Gate sofort nachzieht.
*/
export function invalidateTenantModuleAccessCache(tenantId: string): void {
clearCacheKeyPrefix(`${MODULE_CACHE_PREFIX}${tenantId}:`);
}
+22
View File
@@ -0,0 +1,22 @@
export function getCSRFToken() {
if (typeof document === "undefined") {
return undefined;
}
const token = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content;
return token || undefined;
}
export function appendCSRFToken(formData: FormData): FormData {
const token = getCSRFToken();
if (token) {
formData.set("csrf", token);
}
return formData;
}
export function csrfRequestHeaders(headers: HeadersInit = {}): HeadersInit {
const token = getCSRFToken();
return token ? { ...headers, "x-csrf-token": token } : headers;
}
@@ -0,0 +1,51 @@
import type { LoaderFunctionArgs } from "react-router";
import { getTranslations } from "~/locale/i18next.server";
import { createMetrics } from "~/modules/metrics/services/.server/MetricTracker";
import { adminGetAllTenantsWithUsage, type TenantWithUsage } from "~/utils/db/tenants.db.server";
import { getPaginationFromCurrentUrl } from "~/utils/helpers/RowPaginationHelper";
import PeriodHelper from "~/utils/helpers/PeriodHelper";
import { promiseHash } from "~/utils/promises/promiseHash";
import { getAdminDashboardStats } from "~/utils/services/adminDashboardService";
import { getSetupSteps } from "~/utils/services/setupService";
import type { SetupItem } from "~/application/dtos/setup/SetupItem";
import type { Stat } from "~/application/dtos/stats/Stat";
import type { PaginationDto } from "~/application/dtos/data/PaginationDto";
export type AdminDashboardLoaderData = {
title: string;
stats: Stat[];
setupSteps: SetupItem[];
tenants: {
items: TenantWithUsage[];
pagination: PaginationDto;
};
};
type AdminDashboardDataArgs = Pick<LoaderFunctionArgs, "request" | "params">;
export async function loadAdminDashboardRouteData({ request, params }: AdminDashboardDataArgs) {
const { time, getServerTimingHeader } = await createMetrics({ request, params }, "admin.dashboard");
const urlSearchParams = new URL(request.url).searchParams;
const currentPagination = getPaginationFromCurrentUrl(urlSearchParams);
const { t } = await time(getTranslations(request), "getTranslations");
const { stats, setupSteps, tenants } = await time(
promiseHash({
stats: time(
getAdminDashboardStats({ t, gte: PeriodHelper.getGreaterThanOrEqualsFromRequest({ request }) }),
"admin.dashboard.details.getAdminDashboardStats"
),
setupSteps: time(getSetupSteps(), "admin.dashboard.details.getSetupSteps"),
tenants: time(adminGetAllTenantsWithUsage(undefined, currentPagination), "admin.dashboard.details.adminGetAllTenantsWithUsage"),
}),
"admin.dashboard.details"
);
const data: AdminDashboardLoaderData = {
title: `${t("app.sidebar.dashboard")} | ${process.env.APP_NAME}`,
stats,
setupSteps,
tenants,
};
return { data, headers: getServerTimingHeader() };
}
@@ -0,0 +1,24 @@
// @vitest-environment node
import { describe, expect, test } from "vitest";
import { readRepoFile } from "~/utils/testing/repoPaths";
function readAdminDataSource() {
return readRepoFile("apps/web/app/utils/data/.server/adminData.ts");
}
describe("admin data MFA enforcement boundary", () => {
test("does not import MFA secret or recovery-code storage directly", () => {
const source = readAdminDataSource();
expect(source).not.toMatch(/mfaRecoveryCode|totpSecret|encryptedTotpSecret/i);
});
test("routes shared admin loader through the MFA enforcement boundary", () => {
const source = readAdminDataSource();
expect(source).toContain("getUserTotpStatus");
expect(source).toContain("checkMfaStepUpState");
expect(source).toContain("enforceAdminMfa");
expect(source).toMatch(/if \(!mfaEnforcement\.allowed\) \{\s*throw redirect\(mfaEnforcement\.redirectTo\);\s*\}/);
});
});
@@ -0,0 +1,87 @@
import { DefaultAdminRoles } from "~/application/dtos/shared/DefaultAdminRoles";
import { DefaultPermission } from "~/application/dtos/shared/DefaultPermissions";
import { TimeFunction } from "~/modules/metrics/services/.server/MetricTracker";
import OnboardingService from "~/modules/onboarding/services/OnboardingService";
import UrlUtils from "~/utils/app/UrlUtils";
import { getAllEntities } from "~/utils/db/entities/entities.db.server";
import { getAllEntityGroups } from "~/utils/db/entities/entityGroups.db.server";
import { getMyGroups } from "~/utils/db/permissions/groups.db.server";
import { getAllRolesWithoutPermissions } from "~/utils/db/permissions/roles.db.server";
import { getPermissionsByUser, getUserRoleInAdmin } from "~/utils/db/permissions/userRoles.db.server";
import { getTenantsForUser as getMyTenants } from "@toolctrl/db/auth-boundary.server";
import { getAllTenantTypes } from "~/utils/db/tenants/tenantTypes.db.server";
import { getUserTotpStatus } from "~/utils/db/users.db.server";
import { enforceAdminMfa } from "~/utils/auth/mfa/enforcement.server";
import { checkMfaStepUpState } from "~/utils/auth/mfa/step-up.server";
import { promiseHash } from "~/utils/promises/promiseHash";
import { getUserInfo, getUserSession } from "~/utils/session.server";
import { AdminLoaderData } from "../useAdminData";
import { getAuthUserWithoutPasswordById as getUser } from "@toolctrl/db/auth-boundary.server";
import { TFunction } from "i18next";
import { redirect } from "react-router";
import { NOT_FOUND_ENVELOPE } from "~/utils/errors.server";
export async function loadAdminData({ request, t }: { request: Request; t: TFunction }, time: TimeFunction) {
const userInfo = await time(getUserInfo(request), "getUserInfo");
const url = new URL(request.url);
if (UrlUtils.stripTrailingSlash(url.pathname) === `/admin`) {
throw Response.json(NOT_FOUND_ENVELOPE, { status: 404 });
}
const user = await time(getUser(userInfo?.userId), "getUser");
const redirectTo = url.pathname + url.search;
if (!userInfo || !user) {
let searchParams = new URLSearchParams([["redirect", redirectTo]]);
throw redirect(`/login?${searchParams}`);
}
if (!user.admin) {
throw Response.json({ error: t("admin.errors.adminOnly") }, { status: 401 });
}
const session = await time(getUserSession(request), "getUserSession");
const totpStatus = await time(getUserTotpStatus(user.id), "getUserTotpStatus");
const stepUp = checkMfaStepUpState(session, user.id);
const mfaEnforcement = enforceAdminMfa({
requestUrl: url,
userId: user.id,
isAdmin: Boolean(user.admin),
totpStatus,
stepUp,
redirectMode: "host-aware",
});
if (!mfaEnforcement.allowed) {
throw redirect(mfaEnforcement.redirectTo);
}
const myTenants = await time(getMyTenants(user.id), "getMyTenants");
const { allPermissions, superAdminRole, entities, entityGroups, allRoles, myGroups, onboardingSession } = await time(
promiseHash({
allPermissions: getPermissionsByUser(userInfo.userId, null),
superAdminRole: getUserRoleInAdmin(userInfo.userId, DefaultAdminRoles.SuperAdmin),
entities: getAllEntities({ tenantId: null }),
entityGroups: getAllEntityGroups(),
allRoles: getAllRolesWithoutPermissions("admin"),
myGroups: getMyGroups(user.id, null),
onboardingSession: OnboardingService.getUserActiveOnboarding({ userId: user.id, tenantId: null, request }),
}),
"loadAdminData.getDetails"
);
const data: AdminLoaderData = {
user,
myTenants,
currentTenant: null,
entities,
entityGroups,
// roles,
allRoles,
permissions: allPermissions.map((f) => f as DefaultPermission),
isSuperUser: !!superAdminRole,
isSuperAdmin: !!superAdminRole,
myGroups,
lng: user?.locale ?? userInfo.lng,
onboardingSession,
tenantTypes: await getAllTenantTypes(),
};
return data;
}
@@ -0,0 +1,95 @@
// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
import { TenantUserType } from "~/application/enums/tenants/TenantUserType";
type Deferred<T> = {
promise: Promise<T>;
resolve: (value: T) => void;
};
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
const mocks = vi.hoisted(() => {
const postDetails = {
tenantUser: deferred<{ type: TenantUserType }>(),
tenantTypes: deferred<unknown[]>(),
credits: deferred<unknown>(),
productAccess: deferred<{ allowed: boolean }>(),
};
return { postDetails };
});
vi.mock("~/utils/services/.server/urlService", () => ({ getTenantIdFromUrl: vi.fn(async () => "tenant_1") }));
vi.mock("~/utils/session.server", () => ({ getUserInfo: vi.fn(async () => ({ userId: "user_1", lng: "en" })) }));
vi.mock("@toolctrl/db/auth-boundary.server", () => ({
getAuthUserWithoutPasswordById: vi.fn(async () => ({ id: "user_1", admin: false, locale: "en" })),
getTenantsForUser: vi.fn(async () => [{ id: "tenant_1", slug: "acme", name: "Acme" }]),
}));
vi.mock("~/utils/db/tenants.db.server", () => ({
adminGetAllActiveTenantSimple: vi.fn(async () => []),
getTenantSimple: vi.fn(async () => ({ id: "tenant_1", slug: "acme", name: "Acme", deactivatedReason: null })),
getTenantUserType: vi.fn(() => mocks.postDetails.tenantUser.promise),
}));
vi.mock("@toolctrl/config", () => ({ loadRequiredWebConfig: vi.fn(() => ({ routing: {} })) }));
vi.mock("~/utils/db/entities/entities.db.server", () => ({ getAllEntities: vi.fn(async () => []) }));
vi.mock("~/utils/db/entities/entityGroups.db.server", () => ({ getAllEntityGroups: vi.fn(async () => []) }));
vi.mock("~/utils/db/permissions/groups.db.server", () => ({ getMyGroups: vi.fn(async () => []) }));
vi.mock("~/utils/db/permissions/roles.db.server", () => ({ getAllRolesWithoutPermissions: vi.fn(async () => []) }));
vi.mock("~/utils/db/permissions/userRoles.db.server", () => ({
getPermissionsByUser: vi.fn(async () => []),
getUserRoleInAdmin: vi.fn(async () => null),
getUserRoleInTenant: vi.fn(async () => null),
}));
vi.mock("~/utils/db/tenants/tenantTypes.db.server", () => ({ getAllTenantTypes: vi.fn(() => mocks.postDetails.tenantTypes.promise) }));
vi.mock("~/utils/services/.server/subscriptionService", () => ({
getActiveTenantSubscriptions: vi.fn(async () => ({ products: [] })),
getPlanFeatureUsage: vi.fn(() => mocks.postDetails.credits.promise),
}));
vi.mock("~/modules/onboarding/services/OnboardingService", () => ({ default: { getUserActiveOnboarding: vi.fn(async () => null) } }));
vi.mock("~/utils/entitlements/.server/productAccess.server", () => ({
resolveTenantProductAccess: vi.fn(() => mocks.postDetails.productAccess.promise),
}));
import { getTenantUserType } from "~/utils/db/tenants.db.server";
import { getAllTenantTypes } from "~/utils/db/tenants/tenantTypes.db.server";
import { getPlanFeatureUsage } from "~/utils/services/.server/subscriptionService";
import { resolveTenantProductAccess } from "~/utils/entitlements/.server/productAccess.server";
import { loadAppData } from "./appData";
async function flushAsyncWork() {
await new Promise((resolve) => setImmediate(resolve));
}
describe("loadAppData performance", () => {
it("starts post-detail shell lookups in parallel", async () => {
const result = loadAppData({
request: new Request("https://app.example.test/acme/dashboard"),
params: { tenant: "acme" },
t: ((key: string) => key) as never,
});
await flushAsyncWork();
expect(getTenantUserType).toHaveBeenCalledWith("user_1", "tenant_1");
expect(getAllTenantTypes).toHaveBeenCalled();
expect(getPlanFeatureUsage).toHaveBeenCalledWith("tenant_1", "ai-credits");
expect(resolveTenantProductAccess).toHaveBeenCalled();
mocks.postDetails.tenantUser.resolve({ type: TenantUserType.MEMBER });
mocks.postDetails.tenantTypes.resolve([]);
mocks.postDetails.credits.resolve(undefined);
mocks.postDetails.productAccess.resolve({ allowed: true });
await expect(result).resolves.toMatchObject({
currentRole: TenantUserType.MEMBER,
tenantTypes: [],
productAccess: expect.any(Object),
});
});
});
@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("~/utils/services/.server/urlService", () => ({ getTenantIdFromUrl: vi.fn(async () => "tenant_1") }));
vi.mock("~/utils/session.server", () => ({ getUserInfo: vi.fn(async () => ({ userId: "user_1", lng: "en" })) }));
vi.mock("@toolctrl/db/auth-boundary.server", () => ({
getAuthUserWithoutPasswordById: vi.fn(async () => ({ id: "user_1", admin: false, locale: "en" })),
getTenantsForUser: vi.fn(),
}));
vi.mock("~/utils/db/tenants.db.server", () => ({
getTenantSimple: vi.fn(async () => ({ id: "tenant_1", slug: "acme", name: "Acme", deactivatedReason: null })),
getTenantUserType: vi.fn(),
}));
vi.mock("@toolctrl/config", () => ({ loadRequiredWebConfig: vi.fn(() => ({ routing: {} })) }));
vi.mock("~/utils/db/entities/entities.db.server", () => ({ getAllEntities: vi.fn() }));
vi.mock("~/utils/db/entities/entityGroups.db.server", () => ({ getAllEntityGroups: vi.fn() }));
vi.mock("~/utils/db/permissions/groups.db.server", () => ({ getMyGroups: vi.fn() }));
vi.mock("~/utils/db/permissions/roles.db.server", () => ({ getAllRolesWithoutPermissions: vi.fn() }));
vi.mock("~/utils/db/permissions/userRoles.db.server", () => ({
getPermissionsByUser: vi.fn(),
getUserRoleInAdmin: vi.fn(),
getUserRoleInTenant: vi.fn(),
}));
vi.mock("~/utils/db/tenants/tenantTypes.db.server", () => ({ getAllTenantTypes: vi.fn() }));
vi.mock("~/utils/services/.server/subscriptionService", () => ({ getActiveTenantSubscriptions: vi.fn(), getPlanFeatureUsage: vi.fn() }));
vi.mock("~/modules/onboarding/services/OnboardingService", () => ({ default: { getUserActiveOnboarding: vi.fn() } }));
import { loadAppData } from "./appData";
describe("loadAppData tenant canonicalization", () => {
it("redirects tenant id aliases to the canonical tenant slug and preserves path/search", async () => {
try {
await loadAppData({
request: new Request("https://app.example.test/tenant_1/settings/profile?tab=security"),
params: { tenant: "tenant_1" },
t: ((key: string) => key) as never,
});
} catch (error) {
expect(error).toBeInstanceOf(Response);
expect((error as Response).headers.get("Location")).toBe("/acme/settings/profile?tab=security");
}
});
});
@@ -0,0 +1,12 @@
// @vitest-environment node
import { describe, expect, test } from "vitest";
import { readRepoFile } from "~/utils/testing/repoPaths";
describe("loadAppData tenant role lookup", () => {
test("calls getTenantUserType with user id before tenant id", () => {
const source = readRepoFile("apps/web/app/utils/data/.server/appData.ts");
expect(source).toContain("getTenantUserType(userInfo.userId, tenantId)");
expect(source).not.toContain("getTenantUserType(tenantId, userInfo.userId)");
});
});
@@ -0,0 +1,169 @@
import { Params, redirect } from "react-router";
import { DefaultAdminRoles } from "~/application/dtos/shared/DefaultAdminRoles";
import { DefaultAppRoles } from "~/application/dtos/shared/DefaultAppRoles";
import { DefaultEntityTypes } from "~/application/dtos/shared/DefaultEntityTypes";
import { DefaultPermission } from "~/application/dtos/shared/DefaultPermissions";
import { TenantUserType } from "~/application/enums/tenants/TenantUserType";
import { timeFake, TimeFunction } from "~/modules/metrics/services/.server/MetricTracker";
import OnboardingService from "~/modules/onboarding/services/OnboardingService";
import { absoluteAppRootPath } from "~/utils/app/CrossHostUrls";
import { appPath } from "~/utils/app/CanonicalUrlUtils";
import UrlUtils from "~/utils/app/UrlUtils";
import { loadRequiredWebConfig } from "@toolctrl/config";
import { getAllEntities } from "~/utils/db/entities/entities.db.server";
import { getAllEntityGroups } from "~/utils/db/entities/entityGroups.db.server";
import { getMyGroups } from "~/utils/db/permissions/groups.db.server";
import { getAllRolesWithoutPermissions } from "~/utils/db/permissions/roles.db.server";
import { getPermissionsByUser, getUserRoleInTenant, getUserRoleInAdmin } from "~/utils/db/permissions/userRoles.db.server";
import { adminGetAllActiveTenantSimple, getTenantSimple, getTenantUserType } from "~/utils/db/tenants.db.server";
import { getAuthUserWithoutPasswordById as getUser, getTenantsForUser as getMyTenants } from "@toolctrl/db/auth-boundary.server";
import { getAllTenantTypes } from "~/utils/db/tenants/tenantTypes.db.server";
import { promiseHash } from "~/utils/promises/promiseHash";
import { getTenantIdFromUrl } from "~/utils/services/.server/urlService";
import { getActiveTenantSubscriptions, getPlanFeatureUsage } from "~/utils/services/.server/subscriptionService";
import { getUserInfo } from "~/utils/session.server";
import { AppLoaderData } from "../useAppData";
import { PlanFeatureUsageDto } from "~/application/dtos/subscriptions/PlanFeatureUsageDto";
import { CreditTypes } from "~/modules/usage/dtos/CreditType";
import { DefaultFeatures } from "~/application/dtos/shared/DefaultFeatures";
import { TFunction } from "i18next";
import { featureKeys, type FeatureKey } from "@toolctrl/entitlements";
import { resolveTenantProductAccess } from "~/utils/entitlements/.server/productAccess.server";
import { WALKING_SKELETON_MODULE_KEY } from "~/modules/walkingSkeleton/constants";
const tenantNavigationFeatureKeys = [
featureKeys.crmBasic,
featureKeys.emailMarketing,
featureKeys.api,
featureKeys.workflowEngine,
featureKeys.credits,
] as const satisfies readonly FeatureKey[];
// R-P1c: Konnektor-Demo-Modul (kein Entitlement-FeatureKey) — speist das Sidebar-Gate der
// Walking-Skeleton-Route. Liefert für reale Tenants "blueprint-disabled" → Eintrag ausgeblendet.
const tenantConnectorDemoModuleKeys: readonly string[] = [WALKING_SKELETON_MODULE_KEY];
export async function loadAppData({
request,
params,
t,
time = timeFake,
tenantId: preloadedTenantId,
userInfo: preloadedUserInfo,
user: preloadedUser,
}: {
request: Request;
params: Params;
t: TFunction;
time?: TimeFunction;
tenantId?: string;
userInfo?: Awaited<ReturnType<typeof getUserInfo>>;
user?: Awaited<ReturnType<typeof getUser>>;
}) {
const { tenantId, userInfo } = await time(
promiseHash({
tenantId: preloadedTenantId ? Promise.resolve(preloadedTenantId) : getTenantIdFromUrl(params),
userInfo: preloadedUserInfo ? Promise.resolve(preloadedUserInfo) : getUserInfo(request),
}),
"loadAppData.session"
);
const url = new URL(request.url);
const { user, currentTenant } = await time(
promiseHash({
user: preloadedUser ? Promise.resolve(preloadedUser) : getUser(userInfo?.userId),
currentTenant: getTenantSimple(tenantId),
}),
"loadAppData.getUserAndTenant"
);
const redirectTo = url.pathname + url.search;
if (!userInfo || !user) {
let searchParams = new URLSearchParams([["redirect", redirectTo]]);
throw redirect(`/login?${searchParams}`);
}
if (!currentTenant) {
throw redirect(absoluteAppRootPath(loadRequiredWebConfig().routing));
}
const [, tenantPathSegment = "", ...childPathSegments] = url.pathname.split("/");
const childPath = childPathSegments.join("/");
if (params.tenant && params.tenant !== currentTenant.slug) {
throw redirect(`${appPath({ tenantSlug: currentTenant.slug, path: childPath || "dashboard", mode: "host-aware" })}${url.search}`);
}
if (UrlUtils.stripTrailingSlash(url.pathname) === UrlUtils.stripTrailingSlash(appPath({ tenantSlug: tenantPathSegment, mode: "host-aware" }))) {
throw redirect(`${appPath({ tenantSlug: currentTenant.slug, path: "dashboard", mode: "host-aware" })}${url.search}`);
}
let { myTenants, mySubscription, allPermissions, superUserRole, superAdminRole, entities, entityGroups, allRoles, myGroups, onboardingSession } = await time(
promiseHash({
myTenants: time(user.admin ? adminGetAllActiveTenantSimple() : getMyTenants(user.id), "loadAppData.getDetails.getMyTenants"),
mySubscription: time(getActiveTenantSubscriptions(tenantId), "loadAppData.getDetails.getActiveTenantSubscriptions"),
allPermissions: time(getPermissionsByUser(userInfo.userId, tenantId), "loadAppData.getDetails.getPermissionsByUser"),
superUserRole: time(getUserRoleInTenant(userInfo.userId, tenantId, DefaultAppRoles.SuperUser), "loadAppData.getDetails.getUserRoleInTenant"),
superAdminRole: time(getUserRoleInAdmin(userInfo.userId, DefaultAdminRoles.SuperAdmin), "loadAppData.getDetails.getUserRoleInAdmin"),
entities: time(
getAllEntities({ tenantId, active: true, types: [DefaultEntityTypes.All, DefaultEntityTypes.AppOnly] }),
"loadAppData.getDetails.getAllEntities"
),
entityGroups: time(getAllEntityGroups(), "loadAppData.getDetails.getAllEntityGroups()"),
allRoles: time(getAllRolesWithoutPermissions("app"), "loadAppData.getDetails.getAllRolesWithoutPermissions"),
myGroups: time(getMyGroups(user.id, currentTenant.id), "loadAppData.getDetails.getMyGroups"),
onboardingSession: time(
OnboardingService.getUserActiveOnboarding({ userId: user.id, tenantId: currentTenant.id, request }),
"loadAppData.getDetails.OnboardingService.getUserActiveOnboarding"
),
}),
"loadAppData.getDetails"
);
const { tenantUser, tenantTypes, credits, productAccessEntries } = await time(
promiseHash({
tenantUser: time(getTenantUserType(userInfo.userId, tenantId), "loadAppData.postDetails.getTenantUserType"),
tenantTypes: time(getAllTenantTypes(), "loadAppData.postDetails.getAllTenantTypes"),
credits:
CreditTypes.length > 0
? time(getPlanFeatureUsage(tenantId, DefaultFeatures.Credits), "loadAppData.postDetails.getPlanFeatureUsage")
: Promise.resolve(undefined as PlanFeatureUsageDto | undefined),
productAccessEntries: time(
Promise.all(
[...tenantNavigationFeatureKeys, ...tenantConnectorDemoModuleKeys].map(async (featureKey) => [
featureKey,
await resolveTenantProductAccess({
tenantId,
userId: userInfo.userId,
featureKey,
subscription: mySubscription,
tenantRolePermissionNames: allPermissions,
bypassTenantRolePermission: !!user.admin || !!superAdminRole,
}),
] as const)
),
"loadAppData.postDetails.resolveTenantProductAccess"
),
}),
"loadAppData.postDetails"
);
const currentRole = user.admin ? TenantUserType.ADMIN : tenantUser?.type ?? TenantUserType.MEMBER;
const data: AppLoaderData = {
// i18n: i18n.translations,
user,
myTenants,
currentTenant,
currentRole,
mySubscription,
entities,
entityGroups,
// roles,
allRoles,
permissions: allPermissions.map((f) => f as DefaultPermission),
myGroups,
isSuperUser: !!superUserRole,
isSuperAdmin: !!superAdminRole,
lng: user?.locale ?? userInfo.lng,
onboardingSession,
tenantTypes,
credits,
productAccess: Object.fromEntries(productAccessEntries),
};
return data;
}
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { readRepoFile } from "~/utils/testing/repoPaths";
const dataLoaderPaths = [
{
path: "apps/web/app/utils/data/.server/rootData.ts",
blockedImports: ["~/utils/db/users.db.server", "~/utils/db/tenants.db.server"],
},
{
path: "apps/web/app/utils/data/.server/appData.ts",
blockedImports: ["~/utils/db/users.db.server"],
},
{
path: "apps/web/app/utils/data/.server/adminData.ts",
blockedImports: ["~/utils/db/tenants.db.server"],
},
];
const tenantSelectionRoutePaths = [
"apps/web/app/routes/app.tsx",
"apps/web/app/routes/login.tsx",
];
function readLoader(relativePath: string) {
return readRepoFile(relativePath);
}
describe("auth boundary imports for shared data loaders", () => {
it.each(dataLoaderPaths)("imports migrated auth/user/tenant reads from @toolctrl/db in $path", ({ path: relativePath }) => {
const source = readLoader(relativePath);
expect(source).toContain("@toolctrl/db/auth-boundary.server");
});
it.each(dataLoaderPaths)("does not import migrated app DB wrappers in $path", ({ path: relativePath, blockedImports }) => {
const source = readLoader(relativePath);
for (const importPath of blockedImports) {
expect(source).not.toContain(`from "${importPath}"`);
expect(source).not.toContain(`from '${importPath}'`);
}
});
});
describe("auth boundary imports for tenant selection routes", () => {
it.each(tenantSelectionRoutePaths)("imports migrated auth/user/tenant reads from @toolctrl/db in %s", (relativePath) => {
const source = readLoader(relativePath);
expect(source).toContain("@toolctrl/db/auth-boundary.server");
});
it.each(tenantSelectionRoutePaths)("does not import migrated app DB wrappers in %s", (relativePath) => {
const source = readLoader(relativePath);
for (const importPath of ["~/utils/db/users.db.server", "~/utils/db/tenants.db.server"]) {
expect(source).not.toContain(`from "${importPath}"`);
expect(source).not.toContain(`from '${importPath}'`);
}
});
});
@@ -0,0 +1,114 @@
import { loadRequiredWebConfig } from "@toolctrl/config";
import { getAuthUserWithoutPasswordById as getUser, getTenantById as getTenant, getTenantByIdOrSlug } from "@toolctrl/db/auth-boundary.server";
import { Params } from "react-router";
import { AnalyticsInfoDto } from "~/application/dtos/marketing/AnalyticsInfoDto";
import { ImpersonatingSessionDto } from "~/application/dtos/session/ImpersonatingSessionDto";
import { CookieCategory } from "~/application/cookies/CookieCategory";
import { remixI18Next, i18nCookie } from "~/locale/i18next.server";
import { getCurrentEnabledFlagNames } from "~/utils/services/.server/featureFlagsRequest.server";
import { createMetrics } from "~/modules/metrics/services/.server/MetricTracker";
import { getAnalyticsSession, generateAnalyticsUserId, commitAnalyticsSession, destroyAnalyticsSession } from "~/utils/analyticsCookie.server";
import { getAppConfiguration } from "~/utils/db/appConfiguration.db.server";
import AnalyticsService from "~/utils/helpers/.server/AnalyticsService";
import CookieHelper from "~/utils/helpers/CookieHelper";
import { promiseHash } from "~/utils/promises/promiseHash";
import { getUserInfo, getUserSession, setCSRFToken, createUserSession, commitSession } from "~/utils/session.server";
import { getAbsoluteApiUrl, getAbsoluteUrl, getBaseURL, getDomainName } from "~/utils/url.server";
import { AppRootData } from "../useRootData";
export async function loadRootData({ request, params }: { request: Request; params: Params }) {
const { time, getServerTimingHeader } = await createMetrics({ request, params }, "root");
const { userInfo, session, analyticsSession } = await time(
promiseHash({
userInfo: getUserInfo(request),
session: getUserSession(request),
analyticsSession: getAnalyticsSession(request),
}),
"loadRootData.session"
);
const user = userInfo.userId ? await getUser(userInfo.userId) : null;
const currentTenant = params.tenant ? await getTenantByIdOrSlug(params.tenant) : user?.defaultTenantId ? await getTenant(user.defaultTenantId) : null;
const existingCsrf = session.get("csrf");
const csrf = typeof existingCsrf === "string" && existingCsrf.length > 0 ? existingCsrf : setCSRFToken(session);
let analytics: AnalyticsInfoDto | undefined = undefined;
const appConfiguration = await time(getAppConfiguration({ request }), "getAppConfiguration");
const hasAnalyticsConsent = CookieHelper.hasConsent(userInfo, CookieCategory.ANALYTICS);
if (appConfiguration.analytics.enabled && hasAnalyticsConsent) {
try {
analytics = await AnalyticsService.getFromRequest({ appConfiguration, request, userId: userInfo.userId ?? null });
} catch (e: any) {
// eslint-disable-next-line no-console
console.error(e.message);
}
}
const headers = new Headers();
let userAnalyticsId = analyticsSession.get("userAnalyticsId");
headers.append("Set-Cookie", await commitSession(session));
if (hasAnalyticsConsent) {
if (!userAnalyticsId) {
userAnalyticsId = generateAnalyticsUserId();
analyticsSession.set("userAnalyticsId", userAnalyticsId);
}
if (analytics) {
analytics = { ...analytics, userAnalyticsId };
}
headers.append("Set-Cookie", await commitAnalyticsSession(analyticsSession));
} else {
userAnalyticsId = undefined;
headers.append("Set-Cookie", await destroyAnalyticsSession(analyticsSession));
}
let impersonatingSession: ImpersonatingSessionDto | null = null;
if (userInfo.impersonatingFromUserId && userInfo.userId?.length > 0) {
const fromUser = await getUser(userInfo.impersonatingFromUserId);
const toUser = await getUser(userInfo.userId);
if (fromUser && toUser) {
impersonatingSession = { fromUser, toUser };
}
}
const locale = await remixI18Next.getLocale(request);
const webConfig = loadRequiredWebConfig();
const data: AppRootData = {
metatags: [{ title: webConfig.appName }],
user,
currentTenant,
locale,
theme: userInfo.theme || appConfiguration.app.theme,
serverUrl: getBaseURL(request),
publicBaseUrl: getAbsoluteUrl(request, "/"),
apiBaseUrl: getAbsoluteApiUrl(request, "/"),
routing: webConfig.routing,
domainName: getDomainName(request),
userSession: userInfo,
authenticated: userInfo.userId?.length > 0,
debug: webConfig.nodeEnv === "development",
isStripeTest: webConfig.stripe.secretKey?.startsWith("sk_test_") ?? true,
chatWebsiteId: webConfig.crisp.chatWebsiteId,
appConfiguration,
csrf,
analytics,
analyticsToken: appConfiguration.analytics.enabled && hasAnalyticsConsent ? webConfig.analytics.token : undefined,
featureFlags: appConfiguration.featureFlags.enabled ? await getCurrentEnabledFlagNames({ request, params }) : [],
impersonatingSession,
};
const updateMetrics = userInfo.userId?.length > 0 && appConfiguration.metrics.enabled !== userInfo.metrics?.enabled;
const needsToUpdateSession = updateMetrics;
if (needsToUpdateSession) {
return createUserSession(
{
...userInfo,
metrics: appConfiguration.metrics,
},
new URL(request.url).pathname + new URL(request.url).search
);
}
headers.append("Server-Timing", getServerTimingHeader()["Server-Timing"]);
headers.append("Set-Cookie", await i18nCookie.serialize(locale));
return Response.json(data, { headers });
}
@@ -0,0 +1,12 @@
import { useMatches } from "react-router";
import { AppOrAdminData } from "./useAppOrAdminData";
import EntitiesSingleton from "~/modules/rows/repositories/EntitiesSingleton";
export type AdminLoaderData = AppOrAdminData;
export function useAdminData(): AdminLoaderData {
const paths: string[] = ["routes/admin", "routes/admin.__host"];
const adminData = (useMatches().find((f) => paths.includes(f.id.toLowerCase()))?.data ?? {}) as AdminLoaderData;
EntitiesSingleton.getInstance().setEntities(adminData.entities);
return adminData;
}
@@ -0,0 +1,14 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const source = fs.readFileSync(path.join(__dirname, "useAdminTenantData.ts"), "utf8");
describe("useAdminTenantData route contract", () => {
it("matches the canonical admin account detail route", () => {
expect(source).toContain('adminPath(`accounts/${id}`, { mode: "host-aware" })');
expect(source).not.toContain('adminPath(`tenant/${id}`');
});
});
@@ -0,0 +1,25 @@
import { Tenant, TenantUser } from "@toolctrl/db/prisma-browser";
import { useMatches } from "react-router";
import { getTranslations } from "~/locale/i18next.server";
import { getTenantWithUsers } from "../db/tenants.db.server";
import { adminPath } from "../app/CanonicalUrlUtils";
export type AdminTenantLoaderData = {
title: string;
tenant: (Tenant & { users: TenantUser[] }) | null;
};
export function useAdminTenantData(id?: string): AdminTenantLoaderData {
const canonicalPath = adminPath(`accounts/${id}`, { mode: "host-aware" });
return (useMatches().find((f) => f.pathname === canonicalPath)?.data ?? {}) as AdminTenantLoaderData;
}
export async function loadAdminTenantData(request: Request, id?: string) {
const { t } = await getTranslations(request);
const tenant = await getTenantWithUsers(id);
const data: AdminTenantLoaderData = {
title: `${t("models.tenant.object")} | ${process.env.APP_NAME}`,
tenant,
};
return data;
}
@@ -0,0 +1,19 @@
import { useMatches } from "react-router";
import { TenantUserType } from "~/application/enums/tenants/TenantUserType";
import { TenantSimple } from "../db/tenants.db.server";
import { TenantSubscriptionWithDetails } from "../db/tenantSubscriptions.db.server";
import { AppOrAdminData } from "./useAppOrAdminData";
import EntitiesSingleton from "~/modules/rows/repositories/EntitiesSingleton";
export type AppLoaderData = AppOrAdminData & {
currentTenant: TenantSimple;
mySubscription: TenantSubscriptionWithDetails | null;
currentRole: TenantUserType;
};
export function useAppData(): AppLoaderData {
const paths: string[] = ["routes/app.$tenant", "routes/app"];
const appData = (useMatches().find((f) => paths.includes(f.id.toLowerCase()))?.data ?? {}) as AppLoaderData;
EntitiesSingleton.getInstance().setEntities(appData.entities);
return appData;
}
@@ -0,0 +1,43 @@
import { useMatches } from "react-router";
import { TenantSimple } from "../db/tenants.db.server";
import { UserWithoutPassword } from "../db/users.db.server";
import { AppLoaderData } from "./useAppData";
import { AdminLoaderData } from "./useAdminData";
import { EntityWithDetails } from "../db/entities/entities.db.server";
import { GroupWithDetails } from "../db/permissions/groups.db.server";
import { OnboardingSessionWithDetails } from "~/modules/onboarding/db/onboardingSessions.db.server";
import { DefaultPermission } from "~/application/dtos/shared/DefaultPermissions";
import { EntityGroupWithDetails } from "../db/entities/entityGroups.db.server";
import { TenantTypeWithDetails } from "../db/tenants/tenantTypes.db.server";
import { PlanFeatureUsageDto } from "~/application/dtos/subscriptions/PlanFeatureUsageDto";
import type { ProductAccessDecision } from "@toolctrl/entitlements";
export type AppOrAdminData = {
// i18n: Record<string, Language>;
user: UserWithoutPassword;
myTenants: TenantSimple[];
currentTenant: TenantSimple | null;
allRoles: { id: string; name: string; description: string }[];
// roles: UserRoleWithPermission[];
permissions: DefaultPermission[];
entities: EntityWithDetails[];
entityGroups: EntityGroupWithDetails[];
isSuperUser: boolean;
isSuperAdmin: boolean;
myGroups: GroupWithDetails[];
lng?: string;
onboardingSession: OnboardingSessionWithDetails | null;
tenantTypes: TenantTypeWithDetails[];
credits?: PlanFeatureUsageDto | undefined;
productAccess?: Record<string, ProductAccessDecision>;
};
export function useAppOrAdminData(): AppOrAdminData {
const appPaths: string[] = ["routes/app.$tenant", "routes/app"];
const appData = (useMatches().find((f) => appPaths.includes(f.id.toLowerCase()))?.data ?? {}) as AppLoaderData;
const adminPaths: string[] = ["routes/admin", "routes/admin.__host", "routes/dashboard"];
const adminData = (useMatches().find((f) => adminPaths.includes(f.id.toLowerCase()) || Boolean((f.data as Partial<AdminLoaderData> | undefined)?.isSuperAdmin))?.data ?? {}) as AdminLoaderData;
const appOrAdminData = appData.user ? appData : adminData;
return appOrAdminData;
}
@@ -0,0 +1,20 @@
import { useMatches } from "react-router";
import { getTenantUsersCount } from "../db/tenants.db.server";
import { Params } from "react-router";
import { getTenantIdFromUrl } from "../services/.server/urlService";
export type DashboardLoaderData = {
users: number;
};
export function useDashboardData(): DashboardLoaderData {
return (useMatches().find((f) => f.id === "routes/app.$tenant/dashboard")?.data ?? {}) as DashboardLoaderData;
}
export async function loadDashboardData({ request, params }: { request: Request; params: Params }): Promise<DashboardLoaderData> {
const tenantId = await getTenantIdFromUrl(params);
const data: DashboardLoaderData = {
users: await getTenantUsersCount(tenantId),
};
return data;
}
@@ -0,0 +1,12 @@
import { Params } from "react-router";
import { useMatches } from "react-router";
import UrlUtils from "../app/UrlUtils";
import { TenantUserWithUser } from "../db/tenants.db.server";
export type MembersLoaderData = {
users: TenantUserWithUser[];
};
export function useMembersData(params: Params): MembersLoaderData {
return (useMatches().find((f) => f.pathname === UrlUtils.currentTenantUrl(params, "settings/members"))?.data ?? {}) as MembersLoaderData;
}
@@ -0,0 +1,37 @@
import { useMatches } from "react-router";
import { AppConfiguration } from "../db/appConfiguration.db.server";
import { UserSession } from "../session.server";
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
import { ImpersonatingSessionDto } from "~/application/dtos/session/ImpersonatingSessionDto";
import { AnalyticsInfoDto } from "~/application/dtos/marketing/AnalyticsInfoDto";
import { UserWithoutPassword } from "../db/users.db.server";
import { TenantSimple } from "../db/tenants.db.server";
import type { PublicDomainRoutingConfig } from "~/utils/app/CrossHostUrls";
export type AppRootData = {
metatags: MetaTagsDto;
user: UserWithoutPassword | null;
currentTenant: TenantSimple | null;
theme: string;
locale: string;
serverUrl: string;
publicBaseUrl: string;
apiBaseUrl: string;
routing: PublicDomainRoutingConfig;
domainName: string;
userSession: UserSession;
authenticated: boolean;
debug: boolean;
isStripeTest: boolean;
chatWebsiteId?: string;
appConfiguration: AppConfiguration;
csrf?: string;
analytics?: AnalyticsInfoDto;
analyticsToken?: string;
featureFlags: string[];
impersonatingSession: ImpersonatingSessionDto | null;
};
export function useRootData(): AppRootData {
return (useMatches().find((f) => f.pathname === "/" || f.pathname === "")?.data ?? {}) as AppRootData;
}
Loaded 100 of 5784 files, more files were not shown because too many files have changed in this diff. Show more