feat(db): mesh data model — meshes, members, invites, audit log
- pgSchema "mesh" with 4 tables isolating the peer mesh domain - Enums: visibility, transport, tier, role - audit_log is metadata-only (E2E encryption enforced at broker/client) - Cascade on mesh delete, soft-delete via archivedAt/revokedAt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
3
packages/shared/eslint.config.js
Normal file
3
packages/shared/eslint.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from "@turbostarter/eslint-config/base";
|
||||
|
||||
export default baseConfig;
|
||||
36
packages/shared/package.json
Normal file
36
packages/shared/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@turbostarter/shared",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./*": "./src/*/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .cache .turbo dist node_modules",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"prettier": "@turbostarter/prettier-config",
|
||||
"dependencies": {
|
||||
"lodash": "4.17.21",
|
||||
"pino": "10.1.0",
|
||||
"react": "catalog:react19",
|
||||
"slugify": "1.6.6",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@turbostarter/eslint-config": "workspace:*",
|
||||
"@turbostarter/prettier-config": "workspace:*",
|
||||
"@turbostarter/tsconfig": "workspace:*",
|
||||
"@turbostarter/vitest-config": "workspace:*",
|
||||
"@types/lodash": "4.17.20",
|
||||
"@types/react": "catalog:react19",
|
||||
"eslint": "catalog:",
|
||||
"prettier": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
20
packages/shared/src/constants/common.ts
Normal file
20
packages/shared/src/constants/common.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/* eslint-disable no-restricted-properties */
|
||||
export const NodeEnv = {
|
||||
DEVELOPMENT: "development",
|
||||
PRODUCTION: "production",
|
||||
TEST: "test",
|
||||
} as const;
|
||||
|
||||
export const SortOrder = {
|
||||
ASCENDING: "asc",
|
||||
DESCENDING: "desc",
|
||||
} as const;
|
||||
|
||||
export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder];
|
||||
|
||||
export const envConfig = {
|
||||
skip:
|
||||
(!!process.env.SKIP_ENV_VALIDATION &&
|
||||
["1", "true"].includes(process.env.SKIP_ENV_VALIDATION)) ||
|
||||
["postinstall", "lint"].includes(process.env.npm_lifecycle_event ?? ""),
|
||||
} as const;
|
||||
66
packages/shared/src/constants/exceptions.ts
Normal file
66
packages/shared/src/constants/exceptions.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
export const HttpStatusCode = {
|
||||
CONTINUE: 100,
|
||||
SWITCHING_PROTOCOLS: 101,
|
||||
PROCESSING: 102,
|
||||
OK: 200,
|
||||
CREATED: 201,
|
||||
ACCEPTED: 202,
|
||||
NON_AUTHORITATIVE_INFORMATION: 203,
|
||||
NO_CONTENT: 204,
|
||||
RESET_CONTENT: 205,
|
||||
PARTIAL_CONTENT: 206,
|
||||
MULTI_STATUS: 207,
|
||||
ALREADY_REPORTED: 208,
|
||||
IM_USED: 226,
|
||||
MULTIPLE_CHOICES: 300,
|
||||
MOVED_PERMANENTLY: 301,
|
||||
FOUND: 302,
|
||||
SEE_OTHER: 303,
|
||||
NOT_MODIFIED: 304,
|
||||
USE_PROXY: 305,
|
||||
SWITCH_PROXY: 306,
|
||||
TEMPORARY_REDIRECT: 307,
|
||||
PERMANENT_REDIRECT: 308,
|
||||
BAD_REQUEST: 400,
|
||||
UNAUTHORIZED: 401,
|
||||
PAYMENT_REQUIRED: 402,
|
||||
FORBIDDEN: 403,
|
||||
NOT_FOUND: 404,
|
||||
METHOD_NOT_ALLOWED: 405,
|
||||
NOT_ACCEPTABLE: 406,
|
||||
PROXY_AUTHENTICATION_REQUIRED: 407,
|
||||
REQUEST_TIMEOUT: 408,
|
||||
CONFLICT: 409,
|
||||
GONE: 410,
|
||||
LENGTH_REQUIRED: 411,
|
||||
PRECONDITION_FAILED: 412,
|
||||
PAYLOAD_TOO_LARGE: 413,
|
||||
URI_TOO_LONG: 414,
|
||||
UNSUPPORTED_MEDIA_TYPE: 415,
|
||||
RANGE_NOT_SATISFIABLE: 416,
|
||||
EXPECTATION_FAILED: 417,
|
||||
I_AM_A_TEAPOT: 418,
|
||||
MISDIRECTED_REQUEST: 421,
|
||||
UNPROCESSABLE_ENTITY: 422,
|
||||
LOCKED: 423,
|
||||
FAILED_DEPENDENCY: 424,
|
||||
UPGRADE_REQUIRED: 426,
|
||||
PRECONDITION_REQUIRED: 428,
|
||||
TOO_MANY_REQUESTS: 429,
|
||||
REQUEST_HEADER_FIELDS_TOO_LARGE: 431,
|
||||
UNAVAILABLE_FOR_LEGAL_REASONS: 451,
|
||||
INTERNAL_SERVER_ERROR: 500,
|
||||
NOT_IMPLEMENTED: 501,
|
||||
BAD_GATEWAY: 502,
|
||||
SERVICE_UNAVAILABLE: 503,
|
||||
GATEWAY_TIMEOUT: 504,
|
||||
HTTP_VERSION_NOT_SUPPORTED: 505,
|
||||
VARIANT_ALSO_NEGOTIATES: 506,
|
||||
INSUFFICIENT_STORAGE: 507,
|
||||
LOOP_DETECTED: 508,
|
||||
NOT_EXTENDED: 510,
|
||||
NETWORK_AUTHENTICATION_REQUIRED: 511,
|
||||
} as const;
|
||||
|
||||
export type HttpStatusCode =
|
||||
(typeof HttpStatusCode)[keyof typeof HttpStatusCode];
|
||||
2
packages/shared/src/constants/index.ts
Normal file
2
packages/shared/src/constants/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./common";
|
||||
export * from "./exceptions";
|
||||
3
packages/shared/src/hooks/index.ts
Normal file
3
packages/shared/src/hooks/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./use-debounced-callback";
|
||||
export * from "./use-unmount";
|
||||
export * from "./use-date-groups";
|
||||
96
packages/shared/src/hooks/use-date-groups.ts
Normal file
96
packages/shared/src/hooks/use-date-groups.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface ItemWithDate {
|
||||
createdAt: Date | string | null;
|
||||
}
|
||||
|
||||
interface DateGroup<T> {
|
||||
label: string;
|
||||
items: T[];
|
||||
}
|
||||
|
||||
const isToday = (date: Date): boolean => {
|
||||
const today = new Date();
|
||||
return (
|
||||
date.getDate() === today.getDate() &&
|
||||
date.getMonth() === today.getMonth() &&
|
||||
date.getFullYear() === today.getFullYear()
|
||||
);
|
||||
};
|
||||
|
||||
const isYesterday = (date: Date): boolean => {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
return (
|
||||
date.getDate() === yesterday.getDate() &&
|
||||
date.getMonth() === yesterday.getMonth() &&
|
||||
date.getFullYear() === yesterday.getFullYear()
|
||||
);
|
||||
};
|
||||
|
||||
const isThisWeek = (date: Date): boolean => {
|
||||
const now = new Date();
|
||||
const weekStart = new Date(now);
|
||||
weekStart.setDate(now.getDate() - now.getDay());
|
||||
weekStart.setHours(0, 0, 0, 0);
|
||||
return date >= weekStart && !isToday(date) && !isYesterday(date);
|
||||
};
|
||||
|
||||
const isThisMonth = (date: Date): boolean => {
|
||||
const now = new Date();
|
||||
return (
|
||||
date.getMonth() === now.getMonth() &&
|
||||
date.getFullYear() === now.getFullYear() &&
|
||||
!isThisWeek(date) &&
|
||||
!isToday(date) &&
|
||||
!isYesterday(date)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Groups items by date ranges: Today, Yesterday, This week, This month, Older
|
||||
* Items must have a `createdAt` property (Date or string)
|
||||
*/
|
||||
export function useDateGroups<T extends ItemWithDate>(
|
||||
items: T[],
|
||||
): DateGroup<T>[] {
|
||||
return useMemo(() => {
|
||||
const today: T[] = [];
|
||||
const yesterday: T[] = [];
|
||||
const thisWeek: T[] = [];
|
||||
const thisMonth: T[] = [];
|
||||
const older: T[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (item.createdAt === null) {
|
||||
older.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
const date =
|
||||
typeof item.createdAt === "string"
|
||||
? new Date(item.createdAt)
|
||||
: item.createdAt;
|
||||
|
||||
if (isToday(date)) {
|
||||
today.push(item);
|
||||
} else if (isYesterday(date)) {
|
||||
yesterday.push(item);
|
||||
} else if (isThisWeek(date)) {
|
||||
thisWeek.push(item);
|
||||
} else if (isThisMonth(date)) {
|
||||
thisMonth.push(item);
|
||||
} else {
|
||||
older.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
{ label: "Today", items: today },
|
||||
{ label: "Yesterday", items: yesterday },
|
||||
{ label: "This week", items: thisWeek },
|
||||
{ label: "This month", items: thisMonth },
|
||||
{ label: "Older", items: older },
|
||||
];
|
||||
}, [items]);
|
||||
}
|
||||
63
packages/shared/src/hooks/use-debounced-callback.ts
Normal file
63
packages/shared/src/hooks/use-debounced-callback.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import debounce from "lodash/debounce";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
|
||||
import { useUnmount } from "./use-unmount";
|
||||
|
||||
interface DebounceOptions {
|
||||
leading?: boolean;
|
||||
trailing?: boolean;
|
||||
maxWait?: number;
|
||||
}
|
||||
|
||||
interface ControlFunctions {
|
||||
cancel: () => void;
|
||||
flush: () => void;
|
||||
isPending: () => boolean;
|
||||
}
|
||||
|
||||
export type DebouncedState<T extends (...args: any[]) => ReturnType<T>> = ((
|
||||
...args: Parameters<T>
|
||||
) => ReturnType<T> | undefined) &
|
||||
ControlFunctions;
|
||||
|
||||
export function useDebounceCallback<
|
||||
T extends (...args: any[]) => ReturnType<T>,
|
||||
>(func: T, delay = 500, options?: DebounceOptions): DebouncedState<T> {
|
||||
const debouncedFunc = useRef<ReturnType<typeof debounce>>(null);
|
||||
|
||||
useUnmount(() => {
|
||||
if (debouncedFunc.current) {
|
||||
debouncedFunc.current.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
const debounced = useMemo(() => {
|
||||
const debouncedFuncInstance = debounce(func, delay, options);
|
||||
|
||||
const wrappedFunc: DebouncedState<T> = (...args: Parameters<T>) => {
|
||||
return debouncedFuncInstance(...args);
|
||||
};
|
||||
|
||||
wrappedFunc.cancel = () => {
|
||||
debouncedFuncInstance.cancel();
|
||||
};
|
||||
|
||||
wrappedFunc.isPending = () => {
|
||||
return !!debouncedFunc.current;
|
||||
};
|
||||
|
||||
wrappedFunc.flush = () => {
|
||||
return debouncedFuncInstance.flush();
|
||||
};
|
||||
|
||||
return wrappedFunc;
|
||||
}, [func, delay, options]);
|
||||
|
||||
// Update the debounced function ref whenever func, wait, or options change
|
||||
useEffect(() => {
|
||||
debouncedFunc.current = debounce(func, delay, options);
|
||||
}, [func, delay, options]);
|
||||
|
||||
return debounced;
|
||||
}
|
||||
14
packages/shared/src/hooks/use-unmount.ts
Normal file
14
packages/shared/src/hooks/use-unmount.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function useUnmount(func: () => void) {
|
||||
const funcRef = useRef(func);
|
||||
|
||||
funcRef.current = func;
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
funcRef.current();
|
||||
},
|
||||
[],
|
||||
);
|
||||
}
|
||||
1
packages/shared/src/logger/index.ts
Normal file
1
packages/shared/src/logger/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./providers/console";
|
||||
8
packages/shared/src/logger/providers/console.ts
Normal file
8
packages/shared/src/logger/providers/console.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { Logger } from "../types";
|
||||
|
||||
export const logger: Logger = {
|
||||
info: console.info,
|
||||
error: console.error,
|
||||
warn: console.warn,
|
||||
debug: console.debug,
|
||||
};
|
||||
15
packages/shared/src/logger/providers/pino.ts
Normal file
15
packages/shared/src/logger/providers/pino.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/* eslint-disable no-restricted-properties */
|
||||
import pino from "pino";
|
||||
|
||||
import type { Logger } from "../types";
|
||||
|
||||
export const logger: Logger = pino({
|
||||
browser: {
|
||||
asObject: true,
|
||||
},
|
||||
level: "debug",
|
||||
base: {
|
||||
env: process.env.NODE_ENV,
|
||||
},
|
||||
errorKey: "error",
|
||||
});
|
||||
12
packages/shared/src/logger/types.ts
Normal file
12
packages/shared/src/logger/types.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
interface LogFn {
|
||||
<T extends object>(obj: T, msg?: string, ...args: unknown[]): void;
|
||||
(obj: unknown, msg?: string, ...args: unknown[]): void;
|
||||
(msg: string, ...args: unknown[]): void;
|
||||
}
|
||||
|
||||
export interface Logger {
|
||||
info: LogFn;
|
||||
error: LogFn;
|
||||
warn: LogFn;
|
||||
debug: LogFn;
|
||||
}
|
||||
2
packages/shared/src/schema/index.ts
Normal file
2
packages/shared/src/schema/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./pagination";
|
||||
export * from "./sort";
|
||||
15
packages/shared/src/schema/pagination.ts
Normal file
15
packages/shared/src/schema/pagination.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import * as z from "zod";
|
||||
|
||||
export const offsetPaginationSchema = z.object({
|
||||
page: z.coerce.number().int().default(1),
|
||||
perPage: z.coerce.number().int().default(10),
|
||||
});
|
||||
|
||||
export type OffsetPaginationPayload = z.infer<typeof offsetPaginationSchema>;
|
||||
|
||||
export const cursorPaginationSchema = z.object({
|
||||
cursor: z.string().optional(),
|
||||
limit: z.coerce.number().int().default(10),
|
||||
});
|
||||
|
||||
export type CursorPaginationPayload = z.infer<typeof cursorPaginationSchema>;
|
||||
8
packages/shared/src/schema/sort.ts
Normal file
8
packages/shared/src/schema/sort.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import * as z from "zod";
|
||||
|
||||
export const sortSchema = z.object({
|
||||
id: z.string(),
|
||||
desc: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
export type SortPayload = z.infer<typeof sortSchema>;
|
||||
14
packages/shared/src/types/index.ts
Normal file
14
packages/shared/src/types/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
type Replace<
|
||||
S extends string,
|
||||
From extends string,
|
||||
To extends string,
|
||||
> = S extends `${infer L}${From}${infer R}`
|
||||
? `${L}${To}${Replace<R, From, To>}`
|
||||
: S;
|
||||
|
||||
export type EnumToConstant<T extends readonly string[]> = {
|
||||
[K in Uppercase<Replace<T[number], "-", "_">>]: Extract<
|
||||
T[number],
|
||||
Lowercase<Replace<K, "_", "-">>
|
||||
>;
|
||||
};
|
||||
19
packages/shared/src/utils/common.ts
Normal file
19
packages/shared/src/utils/common.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export { default as capitalize } from "lodash/capitalize";
|
||||
export { default as debounce } from "lodash/debounce";
|
||||
export { default as groupBy } from "lodash/groupBy";
|
||||
export { default as mapValues } from "lodash/mapValues";
|
||||
export { default as merge } from "lodash/merge";
|
||||
export { default as omitBy } from "lodash/omitBy";
|
||||
export { default as pickBy } from "lodash/pickBy";
|
||||
export { default as random } from "lodash/random";
|
||||
export { default as sortBy } from "lodash/sortBy";
|
||||
export { default as transform } from "lodash/transform";
|
||||
export { default as slugify } from "slugify";
|
||||
|
||||
export function splitArray<T>(array: T[], chunkSize: number): T[][] {
|
||||
const result: T[][] = [];
|
||||
for (let i = 0; i < array.length; i += chunkSize) {
|
||||
result.push(array.slice(i, i + chunkSize));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
32
packages/shared/src/utils/exceptions.ts
Normal file
32
packages/shared/src/utils/exceptions.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { HttpStatusCode } from "../constants";
|
||||
|
||||
export const isHttpStatus = (status: number): status is HttpStatusCode =>
|
||||
Object.values<number>(HttpStatusCode).includes(status);
|
||||
|
||||
interface HttpExceptionOptions {
|
||||
message?: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export class HttpException extends Error {
|
||||
readonly status?: HttpStatusCode;
|
||||
readonly code?: string;
|
||||
|
||||
constructor(status?: HttpStatusCode, options?: HttpExceptionOptions) {
|
||||
super(options?.message);
|
||||
this.status = status;
|
||||
this.code = options?.code;
|
||||
}
|
||||
}
|
||||
|
||||
export const getStatusCode = (e: unknown) => {
|
||||
if (typeof e === "object" && e && "status" in e) {
|
||||
const status = Number(e.status);
|
||||
// Guard against NaN or invalid status codes
|
||||
if (!Number.isNaN(status) && status >= 200 && status <= 599) {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
return HttpStatusCode.INTERNAL_SERVER_ERROR;
|
||||
};
|
||||
25
packages/shared/src/utils/greeting.ts
Normal file
25
packages/shared/src/utils/greeting.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export type GreetingKey = "morning" | "afternoon" | "evening" | "night";
|
||||
|
||||
interface Greeting {
|
||||
text: GreetingKey;
|
||||
emoji: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a time-appropriate greeting key with matching emoji
|
||||
* The text property is an i18n key to be used with t("greeting.{key}")
|
||||
*/
|
||||
export function getGreeting(): Greeting {
|
||||
const hour = new Date().getHours();
|
||||
|
||||
if (hour >= 5 && hour < 12) {
|
||||
return { text: "morning", emoji: "🌅" };
|
||||
}
|
||||
if (hour >= 12 && hour < 17) {
|
||||
return { text: "afternoon", emoji: "☀️" };
|
||||
}
|
||||
if (hour >= 17 && hour < 21) {
|
||||
return { text: "evening", emoji: "🌆" };
|
||||
}
|
||||
return { text: "night", emoji: "🌙" };
|
||||
}
|
||||
45
packages/shared/src/utils/id.ts
Normal file
45
packages/shared/src/utils/id.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
Creates an ID generator.
|
||||
The total length of the ID is the sum of the prefix, separator, and random part length.
|
||||
Not cryptographically secure.
|
||||
|
||||
@param alphabet - The alphabet to use for the ID. Default: '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.
|
||||
@param prefix - The prefix of the ID to generate. Optional.
|
||||
@param separator - The separator between the prefix and the random part of the ID. Default: '-'.
|
||||
@param size - The size of the random part of the ID to generate. Default: 16.
|
||||
*/
|
||||
export const createIdGenerator = ({
|
||||
prefix,
|
||||
size = 32,
|
||||
alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
|
||||
separator = "-",
|
||||
}: {
|
||||
prefix?: string;
|
||||
separator?: string;
|
||||
size?: number;
|
||||
alphabet?: string;
|
||||
} = {}) => {
|
||||
const generator = () => {
|
||||
const alphabetLength = alphabet.length;
|
||||
const chars = new Array(size);
|
||||
for (let i = 0; i < size; i++) {
|
||||
chars[i] = alphabet[(Math.random() * alphabetLength) | 0];
|
||||
}
|
||||
return chars.join("");
|
||||
};
|
||||
|
||||
if (prefix == null) {
|
||||
return generator;
|
||||
}
|
||||
|
||||
// check that the prefix is not part of the alphabet (otherwise prefix checking can fail randomly)
|
||||
if (alphabet.includes(separator)) {
|
||||
throw new Error(
|
||||
`The separator "${separator}" must not be part of the alphabet "${alphabet}".`,
|
||||
);
|
||||
}
|
||||
|
||||
return () => `${prefix}${separator}${generator()}`;
|
||||
};
|
||||
|
||||
export const generateId = createIdGenerator();
|
||||
6
packages/shared/src/utils/index.ts
Normal file
6
packages/shared/src/utils/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from "./exceptions";
|
||||
export * from "./common";
|
||||
export * from "./id";
|
||||
export * from "./url";
|
||||
export * from "./wildcard-match";
|
||||
export * from "./greeting";
|
||||
45
packages/shared/src/utils/test/exceptions.test.ts
Normal file
45
packages/shared/src/utils/test/exceptions.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { HttpStatusCode } from "../../constants";
|
||||
import { HttpException, getStatusCode, isHttpStatus } from "../exceptions";
|
||||
|
||||
describe("isHttpStatus", () => {
|
||||
it.each([
|
||||
[200, true],
|
||||
[404, true],
|
||||
[500, true],
|
||||
[999, false],
|
||||
[1, false],
|
||||
])("should return $expected for status $status", (status, expected) => {
|
||||
expect(isHttpStatus(status)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HttpException", () => {
|
||||
it("should create an instance with status and message", () => {
|
||||
const error = new HttpException(HttpStatusCode.BAD_REQUEST, {
|
||||
message: "Bad Request",
|
||||
});
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error.status).toBe(HttpStatusCode.BAD_REQUEST);
|
||||
expect(error.message).toBe("Bad Request");
|
||||
});
|
||||
|
||||
it("should create an instance with code", () => {
|
||||
const error = new HttpException(HttpStatusCode.BAD_REQUEST, {
|
||||
code: "INVALID_INPUT",
|
||||
});
|
||||
expect(error.code).toBe("INVALID_INPUT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getStatusCode", () => {
|
||||
it.each([
|
||||
[{ status: 404 }, 404],
|
||||
[{ message: "error" }, HttpStatusCode.INTERNAL_SERVER_ERROR],
|
||||
["error", HttpStatusCode.INTERNAL_SERVER_ERROR],
|
||||
[null, HttpStatusCode.INTERNAL_SERVER_ERROR],
|
||||
])("should return %s for input %s", (input, expected) => {
|
||||
expect(getStatusCode(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
79
packages/shared/src/utils/test/id.test.ts
Normal file
79
packages/shared/src/utils/test/id.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createIdGenerator, generateId } from "../id";
|
||||
|
||||
describe("generateId", () => {
|
||||
it("should generate a string of length 32 by default", () => {
|
||||
const id = generateId();
|
||||
expect(typeof id).toBe("string");
|
||||
expect(id.length).toBe(32);
|
||||
});
|
||||
|
||||
it("should generate unique ids", () => {
|
||||
const id1 = generateId();
|
||||
const id2 = generateId();
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
|
||||
it("should only contain allowed characters (alphanumeric)", () => {
|
||||
const id = generateId();
|
||||
expect(id).toMatch(/^[0-9a-zA-Z]+$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createIdGenerator", () => {
|
||||
it("should allow custom size", () => {
|
||||
const generateShortId = createIdGenerator({ size: 10 });
|
||||
const id = generateShortId();
|
||||
expect(id.length).toBe(10);
|
||||
});
|
||||
|
||||
it("should allow custom alphabet", () => {
|
||||
const generateBinaryId = createIdGenerator({ alphabet: "01" });
|
||||
const id = generateBinaryId();
|
||||
expect(id).toMatch(/^[01]+$/);
|
||||
});
|
||||
|
||||
it("should allow custom alphabet with special characters", () => {
|
||||
const specialChars = "!@#$%^&*";
|
||||
const generateSpecialId = createIdGenerator({ alphabet: specialChars });
|
||||
const id = generateSpecialId();
|
||||
for (const char of id) {
|
||||
expect(specialChars).toContain(char);
|
||||
}
|
||||
});
|
||||
|
||||
it("should allow prefix", () => {
|
||||
const generatePrefixedId = createIdGenerator({ prefix: "user" });
|
||||
const id = generatePrefixedId();
|
||||
expect(id.startsWith("user-")).toBe(true);
|
||||
expect(id.length).toBe(32 + 5); // 32 random + 4 prefix + 1 separator
|
||||
});
|
||||
|
||||
it("should allow custom separator", () => {
|
||||
const generateUnderscoreId = createIdGenerator({
|
||||
prefix: "test",
|
||||
separator: "_",
|
||||
});
|
||||
const id = generateUnderscoreId();
|
||||
expect(id.startsWith("test_")).toBe(true);
|
||||
});
|
||||
|
||||
it("should throw if separator is in alphabet", () => {
|
||||
expect(() =>
|
||||
createIdGenerator({
|
||||
prefix: "fail",
|
||||
separator: "a",
|
||||
alphabet: "abc",
|
||||
}),
|
||||
).toThrow('The separator "a" must not be part of the alphabet "abc".');
|
||||
});
|
||||
|
||||
it("should allow empty prefix", () => {
|
||||
// If prefix is provided as empty string, it works like a prefix
|
||||
const generateEmptyPrefixedId = createIdGenerator({ prefix: "" });
|
||||
// prefix="" -> `${""}-${generator()}` -> `-${generator()}`
|
||||
const id = generateEmptyPrefixedId();
|
||||
expect(id.startsWith("-")).toBe(true);
|
||||
});
|
||||
});
|
||||
93
packages/shared/src/utils/test/url.test.ts
Normal file
93
packages/shared/src/utils/test/url.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
getHost,
|
||||
getOrigin,
|
||||
getProtocol,
|
||||
isExternal,
|
||||
matchesPattern,
|
||||
mergeSearchParams,
|
||||
} from "../url";
|
||||
|
||||
describe("isExternal", () => {
|
||||
it.each([
|
||||
["https://google.com", true],
|
||||
["http://example.com", true],
|
||||
["//cdn.example.com", true],
|
||||
["mailto:user@example.com", true],
|
||||
["/dashboard", false],
|
||||
["about", false],
|
||||
])("should return $expected for url $url", (url, expected) => {
|
||||
expect(isExternal(url)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOrigin", () => {
|
||||
it.each([
|
||||
["https://example.com/path", "https://example.com"],
|
||||
["invalid-url", null],
|
||||
["exp://192.168.1.1:8081", null],
|
||||
])("should return %s for %s", (url, expected) => {
|
||||
expect(getOrigin(url)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getProtocol", () => {
|
||||
it.each([
|
||||
["https://example.com", "https:"],
|
||||
["mailto:user@example.com", "mailto:"],
|
||||
["not-a-url", null],
|
||||
])("should return %s for %s", (url, expected) => {
|
||||
expect(getProtocol(url)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getHost", () => {
|
||||
it.each([
|
||||
["https://example.com:8080/path", "example.com:8080"],
|
||||
["invalid", null],
|
||||
])("should return %s for %s", (url, expected) => {
|
||||
expect(getHost(url)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeSearchParams", () => {
|
||||
it("should merge params without overwrite by default", () => {
|
||||
const target = new URL("https://example.com?a=1");
|
||||
const source = new URL("https://other.com?a=2&b=3");
|
||||
mergeSearchParams(target, source);
|
||||
expect(target.searchParams.get("a")).toBe("1");
|
||||
expect(target.searchParams.get("b")).toBe("3");
|
||||
});
|
||||
|
||||
it("should overwrite params if specified", () => {
|
||||
const target = new URL("https://example.com?a=1");
|
||||
const source = new URL("https://other.com?a=2");
|
||||
mergeSearchParams(target, source, { overwrite: true });
|
||||
expect(target.searchParams.get("a")).toBe("2");
|
||||
});
|
||||
|
||||
it("should replace params if specified", () => {
|
||||
const target = new URL("https://example.com?a=1");
|
||||
const source = new URL("https://other.com?b=2");
|
||||
mergeSearchParams(target, source, { replace: true });
|
||||
expect(target.searchParams.has("a")).toBe(false);
|
||||
expect(target.searchParams.get("b")).toBe("2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchesPattern", () => {
|
||||
it.each([
|
||||
["/path", "https://example.com", false],
|
||||
["https://sub.example.com", "*.example.com", true],
|
||||
["https://example.com", "https://example.com", true],
|
||||
["https://example.org", "https://example.com", false],
|
||||
["https://example.com/foo", "https://example.com", true],
|
||||
["https://example.com", "https://*", true],
|
||||
])(
|
||||
"should return %s when matching %s with pattern %s",
|
||||
(url, pattern, expected) => {
|
||||
expect(matchesPattern(url, pattern)).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
86
packages/shared/src/utils/url.ts
Normal file
86
packages/shared/src/utils/url.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { wildcardMatch } from "./wildcard-match";
|
||||
|
||||
const isExternal = (url: string) =>
|
||||
["http", "https", "mailto", "tel", "//", "www"].some((protocol) =>
|
||||
url.startsWith(protocol),
|
||||
);
|
||||
|
||||
const getOrigin = (url: string) => {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
// For custom URL schemes (like exp://), the origin property returns the string "null"
|
||||
// instead of null. We need to handle this case and return null so the fallback logic works.
|
||||
return parsedUrl.origin === "null" ? null : parsedUrl.origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getProtocol = (url: string) => {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
return parsedUrl.protocol;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getHost = (url: string) => {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
return parsedUrl.host;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const mergeSearchParams = (
|
||||
target: URL,
|
||||
source: URL,
|
||||
options?: { overwrite?: boolean; replace?: boolean },
|
||||
) => {
|
||||
const overwrite = options?.overwrite ?? false;
|
||||
const replace = options?.replace ?? false;
|
||||
|
||||
if (replace) {
|
||||
target.search = source.search;
|
||||
return;
|
||||
}
|
||||
|
||||
source.searchParams.forEach((value, key) => {
|
||||
if (overwrite || !target.searchParams.has(key)) {
|
||||
target.searchParams.set(key, value);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const matchesPattern = (url: string, pattern: string): boolean => {
|
||||
if (url.startsWith("/")) {
|
||||
return false;
|
||||
}
|
||||
if (pattern.includes("*")) {
|
||||
// For protocol-specific wildcards, match the full origin
|
||||
if (pattern.includes("://")) {
|
||||
return wildcardMatch(pattern)(getOrigin(url) ?? url);
|
||||
}
|
||||
const host = getHost(url);
|
||||
if (!host) {
|
||||
return false;
|
||||
}
|
||||
return wildcardMatch(pattern)(host);
|
||||
}
|
||||
|
||||
const protocol = getProtocol(url);
|
||||
return protocol === "http:" || protocol === "https:" || !protocol
|
||||
? pattern === getOrigin(url)
|
||||
: url.startsWith(pattern);
|
||||
};
|
||||
|
||||
export {
|
||||
matchesPattern,
|
||||
getOrigin,
|
||||
getProtocol,
|
||||
getHost,
|
||||
mergeSearchParams,
|
||||
isExternal,
|
||||
};
|
||||
246
packages/shared/src/utils/wildcard-match.ts
Normal file
246
packages/shared/src/utils/wildcard-match.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/* https://github.com/axtgr/wildcard-match */
|
||||
|
||||
/**
|
||||
* Escapes a character if it has a special meaning in regular expressions
|
||||
* and returns the character as is if it doesn't
|
||||
*/
|
||||
function escapeRegExpChar(char: string) {
|
||||
if (
|
||||
char === "-" ||
|
||||
char === "^" ||
|
||||
char === "$" ||
|
||||
char === "+" ||
|
||||
char === "." ||
|
||||
char === "(" ||
|
||||
char === ")" ||
|
||||
char === "|" ||
|
||||
char === "[" ||
|
||||
char === "]" ||
|
||||
char === "{" ||
|
||||
char === "}" ||
|
||||
char === "*" ||
|
||||
char === "?" ||
|
||||
char === "\\"
|
||||
) {
|
||||
return `\\${char}`;
|
||||
} else {
|
||||
return char;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes all characters in a given string that have a special meaning in regular expressions
|
||||
*/
|
||||
function escapeRegExpString(str: string) {
|
||||
let result = "";
|
||||
for (const char of str) {
|
||||
result += escapeRegExpChar(char);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms one or more glob patterns into a RegExp pattern
|
||||
*/
|
||||
function transform(
|
||||
pattern: string | string[],
|
||||
separator: string | boolean = true,
|
||||
): string {
|
||||
if (Array.isArray(pattern)) {
|
||||
const regExpPatterns = pattern.map((p) => `^${transform(p, separator)}$`);
|
||||
return `(?:${regExpPatterns.join("|")})`;
|
||||
}
|
||||
|
||||
let separatorSplitter = "";
|
||||
let separatorMatcher = "";
|
||||
let wildcard = ".";
|
||||
|
||||
if (separator === true) {
|
||||
// In this case forward slashes in patterns match both forward and backslashes in samples:
|
||||
//
|
||||
// `foo/bar` will match `foo/bar`
|
||||
// will match `foo\bar`
|
||||
//
|
||||
separatorSplitter = "/";
|
||||
separatorMatcher = "[/\\\\]";
|
||||
wildcard = "[^/\\\\]";
|
||||
} else if (separator) {
|
||||
separatorSplitter = separator;
|
||||
separatorMatcher = escapeRegExpString(separatorSplitter);
|
||||
|
||||
if (separatorMatcher.length > 1) {
|
||||
separatorMatcher = `(?:${separatorMatcher})`;
|
||||
wildcard = `((?!${separatorMatcher}).)`;
|
||||
} else {
|
||||
wildcard = `[^${separatorMatcher}]`;
|
||||
}
|
||||
}
|
||||
|
||||
// When a separator is explicitly specified in a pattern,
|
||||
// it MUST match ONE OR MORE separators in a sample:
|
||||
//
|
||||
// `foo/bar/` will match `foo//bar///`
|
||||
// won't match `foo/bar`
|
||||
//
|
||||
// When a pattern doesn't have a trailing separator,
|
||||
// a sample can still optionally have them:
|
||||
//
|
||||
// `foo/bar` will match `foo/bar//`
|
||||
//
|
||||
// So we use different quantifiers depending on the index of a segment.
|
||||
const requiredSeparator = separator ? `${separatorMatcher}+?` : "";
|
||||
const optionalSeparator = separator ? `${separatorMatcher}*?` : "";
|
||||
|
||||
const segments = separator ? pattern.split(separatorSplitter) : [pattern];
|
||||
let result = "";
|
||||
|
||||
for (let s = 0; s < segments.length; s++) {
|
||||
const segment = segments[s]!;
|
||||
const nextSegment = segments[s + 1]!;
|
||||
let currentSeparator = "";
|
||||
|
||||
if (!segment && s > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (separator) {
|
||||
if (s === segments.length - 1) {
|
||||
currentSeparator = optionalSeparator;
|
||||
} else if (nextSegment !== "**") {
|
||||
currentSeparator = requiredSeparator;
|
||||
} else {
|
||||
currentSeparator = "";
|
||||
}
|
||||
}
|
||||
|
||||
if (separator && segment === "**") {
|
||||
if (currentSeparator) {
|
||||
result += s === 0 ? "" : currentSeparator;
|
||||
result += `(?:${wildcard}*?${currentSeparator})*?`;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
for (let c = 0; c < segment.length; c++) {
|
||||
const char = segment[c]!;
|
||||
|
||||
if (char === "\\") {
|
||||
if (c < segment.length - 1) {
|
||||
result += escapeRegExpChar(segment[c + 1]!);
|
||||
c++;
|
||||
}
|
||||
} else if (char === "?") {
|
||||
result += wildcard;
|
||||
} else if (char === "*") {
|
||||
result += `${wildcard}*?`;
|
||||
} else {
|
||||
result += escapeRegExpChar(char);
|
||||
}
|
||||
}
|
||||
|
||||
result += currentSeparator;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export default transform;
|
||||
|
||||
interface WildcardMatchOptions {
|
||||
/** Separator to be used to split patterns and samples into segments */
|
||||
separator?: string | boolean;
|
||||
|
||||
/** Flags to pass to the RegExp */
|
||||
flags?: string;
|
||||
}
|
||||
|
||||
// This overrides the function's signature because for the end user
|
||||
// the function is always bound to a RegExp
|
||||
interface isMatch {
|
||||
/**
|
||||
* Tests if a sample string matches the pattern(s)
|
||||
*
|
||||
* ```js
|
||||
* isMatch('foo') //=> true
|
||||
* ```
|
||||
*/
|
||||
(sample: string): boolean;
|
||||
|
||||
/** Compiled regular expression */
|
||||
regexp: RegExp;
|
||||
|
||||
/** Original pattern or array of patterns that was used to compile the RegExp */
|
||||
pattern: string | string[];
|
||||
|
||||
/** Options that were used to compile the RegExp */
|
||||
options: WildcardMatchOptions;
|
||||
}
|
||||
|
||||
function isMatch(regexp: RegExp, sample: string) {
|
||||
if (typeof sample !== "string") {
|
||||
throw new TypeError(`Sample must be a string, but ${typeof sample} given`);
|
||||
}
|
||||
|
||||
return regexp.test(sample);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compiles one or more glob patterns into a RegExp and returns an isMatch function.
|
||||
* The isMatch function takes a sample string as its only argument and returns `true`
|
||||
* if the string matches the pattern(s).
|
||||
*
|
||||
* ```js
|
||||
* wildcardMatch('src/*.js')('src/index.js') //=> true
|
||||
* ```
|
||||
*
|
||||
* ```js
|
||||
* const isMatch = wildcardMatch('*.example.com', '.')
|
||||
* isMatch('foo.example.com') //=> true
|
||||
* isMatch('foo.bar.com') //=> false
|
||||
* ```
|
||||
*/
|
||||
function wildcardMatch(
|
||||
pattern: string | string[],
|
||||
options?: string | boolean | WildcardMatchOptions,
|
||||
) {
|
||||
if (typeof pattern !== "string" && !Array.isArray(pattern)) {
|
||||
throw new TypeError(
|
||||
`The first argument must be a single pattern string or an array of patterns, but ${typeof pattern} given`,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof options === "string" || typeof options === "boolean") {
|
||||
options = { separator: options };
|
||||
}
|
||||
|
||||
if (
|
||||
arguments.length === 2 &&
|
||||
!(
|
||||
typeof options === "undefined" ||
|
||||
(typeof options === "object" && !Array.isArray(options))
|
||||
)
|
||||
) {
|
||||
throw new TypeError(
|
||||
`The second argument must be an options object or a string/boolean separator, but ${typeof options} given`,
|
||||
);
|
||||
}
|
||||
|
||||
options = options ?? {};
|
||||
|
||||
if (options.separator === "\\") {
|
||||
throw new Error(
|
||||
"\\ is not a valid separator because it is used for escaping. Try setting the separator to `true` instead",
|
||||
);
|
||||
}
|
||||
|
||||
const regexpPattern = transform(pattern, options.separator);
|
||||
const regexp = new RegExp(`^${regexpPattern}$`, options.flags);
|
||||
|
||||
const fn = isMatch.bind(null, regexp) as isMatch;
|
||||
fn.options = options;
|
||||
fn.pattern = pattern;
|
||||
fn.regexp = regexp;
|
||||
return fn;
|
||||
}
|
||||
|
||||
export { wildcardMatch, isMatch };
|
||||
137
packages/shared/src/validate-env/index.ts
Normal file
137
packages/shared/src/validate-env/index.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Runtime Environment Validation
|
||||
*
|
||||
* Runs once on server startup (via Next.js instrumentation).
|
||||
* Validates that required env vars are present BEFORE the app serves requests.
|
||||
*
|
||||
* Design:
|
||||
* - Build time: all env vars are optional (so `next build` works without them)
|
||||
* - Runtime: this module checks process.env directly and fails fast with clear messages
|
||||
* - Feature-gated: optional features only require their vars when explicitly enabled
|
||||
*/
|
||||
|
||||
type EnvRule = {
|
||||
key: string;
|
||||
required: boolean;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
type FeatureGate = {
|
||||
name: string;
|
||||
/** The env var that enables this feature (if set and non-empty, feature is "on") */
|
||||
gate: string;
|
||||
vars: string[];
|
||||
};
|
||||
|
||||
/** Always required in production */
|
||||
const REQUIRED_VARS: EnvRule[] = [
|
||||
{
|
||||
key: "DATABASE_URL",
|
||||
required: true,
|
||||
reason: "App cannot start without a database connection",
|
||||
},
|
||||
{
|
||||
key: "BETTER_AUTH_SECRET",
|
||||
required: true,
|
||||
reason: "Auth sessions will be insecure without a proper secret",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Feature-gated vars: only required when the gate var is set.
|
||||
* This lets you skip entire features (S3, Stripe, email) without errors.
|
||||
*/
|
||||
const FEATURE_GATES: FeatureGate[] = [
|
||||
{
|
||||
name: "S3 Storage",
|
||||
gate: "S3_BUCKET",
|
||||
vars: ["S3_ENDPOINT", "S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY"],
|
||||
},
|
||||
{
|
||||
name: "Stripe Billing",
|
||||
gate: "STRIPE_SECRET_KEY",
|
||||
vars: ["STRIPE_WEBHOOK_SECRET"],
|
||||
},
|
||||
{
|
||||
name: "Resend Email",
|
||||
gate: "RESEND_API_KEY",
|
||||
vars: ["EMAIL_FROM"],
|
||||
},
|
||||
{
|
||||
name: "Nodemailer Email",
|
||||
gate: "NODEMAILER_HOST",
|
||||
vars: ["NODEMAILER_PORT", "NODEMAILER_USER", "NODEMAILER_PASSWORD", "EMAIL_FROM"],
|
||||
},
|
||||
];
|
||||
|
||||
/** Vars that trigger a warning (not a crash) if missing */
|
||||
const WARN_VARS = [
|
||||
{ key: "BETTER_AUTH_TRUSTED_ORIGINS", reason: "CSRF protection may reject external requests" },
|
||||
{ key: "EMAIL_FROM", reason: "Emails will use 'noreply@example.com' as sender" },
|
||||
];
|
||||
|
||||
function getEnv(key: string): string | undefined {
|
||||
const val = process.env[key];
|
||||
if (!val || val.trim() === "") return undefined;
|
||||
return val;
|
||||
}
|
||||
|
||||
export function validateRuntimeEnv(): void {
|
||||
// Skip in development or when explicitly disabled
|
||||
if (process.env.NODE_ENV !== "production") return;
|
||||
if (process.env.SKIP_ENV_VALIDATION === "1" || process.env.SKIP_ENV_VALIDATION === "true") return;
|
||||
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Check required vars
|
||||
for (const rule of REQUIRED_VARS) {
|
||||
if (!getEnv(rule.key)) {
|
||||
errors.push(` ${rule.key} — ${rule.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check feature-gated vars
|
||||
for (const feature of FEATURE_GATES) {
|
||||
if (getEnv(feature.gate)) {
|
||||
for (const varName of feature.vars) {
|
||||
if (!getEnv(varName)) {
|
||||
errors.push(` ${varName} — Required when ${feature.name} is enabled (${feature.gate} is set)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check warning vars
|
||||
for (const rule of WARN_VARS) {
|
||||
if (!getEnv(rule.key)) {
|
||||
warnings.push(` ${rule.key} — ${rule.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Print warnings
|
||||
if (warnings.length > 0) {
|
||||
console.warn(
|
||||
`\n⚠️ Environment warnings:\n${warnings.join("\n")}\n`
|
||||
);
|
||||
}
|
||||
|
||||
// Fail on errors
|
||||
if (errors.length > 0) {
|
||||
const msg = [
|
||||
"",
|
||||
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
|
||||
"❌ Missing required environment variables:",
|
||||
"",
|
||||
...errors,
|
||||
"",
|
||||
"The app cannot start safely without these.",
|
||||
"Set them in your docker-compose or .env file.",
|
||||
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
// Throw instead of process.exit — works in both Node and Edge runtimes
|
||||
throw new Error(msg);
|
||||
}
|
||||
}
|
||||
6
packages/shared/tsconfig.json
Normal file
6
packages/shared/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "@turbostarter/tsconfig/internal.json",
|
||||
"compilerOptions": {},
|
||||
"include": ["*.ts", "src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
3
packages/shared/vitest.config.ts
Normal file
3
packages/shared/vitest.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from "@turbostarter/vitest-config/base";
|
||||
|
||||
export default baseConfig;
|
||||
Reference in New Issue
Block a user