Track HiveGrid Cloud planning reference
This commit is contained in:
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}:`);
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user