First Commit. Duplicate bunext-sqlite preset

This commit is contained in:
2026-10-18 16:47:11 +01:00
commit 81e4a8aecc
718 changed files with 41695 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
/**
* # App Error
* An error whose message is safe to show to the client. Anything else is
* treated as internal and replaced with a generic message at the API edge.
*/
export default class AppError extends Error {
constructor(message: string) {
super(message);
this.name = "AppError";
}
}
+46
View File
@@ -0,0 +1,46 @@
export default async function bufferFromBase64(
base_64: string,
): Promise<{ buffer: Buffer; ext: string }> {
let mimeType = "";
let data = base_64;
const dataUrlMatch = base_64.match(
/^data:([^;]+);base64,([\s\S]+)$/i,
);
if (dataUrlMatch?.[1] && dataUrlMatch[2]) {
mimeType = dataUrlMatch[1].trim().toLowerCase();
data = dataUrlMatch[2];
}
const buffer = Buffer.from(data, "base64");
let ext = "bin";
if (mimeType.includes("image/jpeg") || mimeType.includes("image/jpg"))
ext = "jpeg";
else if (mimeType.includes("image/png")) ext = "png";
else if (mimeType.includes("image/webp")) ext = "webp";
else if (mimeType.includes("image/gif")) ext = "gif";
else if (mimeType.includes("image/svg")) ext = "svg";
else if (mimeType.includes("application/pdf")) ext = "pdf";
else if (
mimeType.includes(
"application/vnd.openxmlformats-officedocument.wordprocessingml",
)
)
ext = "docx";
else if (
mimeType.includes(
"application/vnd.openxmlformats-officedocument.spreadsheetml",
)
)
ext = "xlsx";
else if (mimeType.includes("text/csv")) ext = "csv";
else if (mimeType.includes("text/markdown")) ext = "md";
else if (mimeType.includes("text/plain")) ext = "txt";
else if (mimeType.includes("/")) {
ext = mimeType.split("/").pop()?.split("+")[0] || "bin";
}
return { buffer, ext };
}
+34
View File
@@ -0,0 +1,34 @@
export default async function bufferFromUrl(
url: string,
): Promise<{ buffer: Buffer; ext: string }> {
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status}`);
const contentType = res.headers.get("content-type") ?? "";
const arrayBuffer = await res.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// Derive ext from content-type first, fall back to URL path
let ext = "txt";
if (contentType.includes("application/pdf")) ext = "pdf";
else if (
contentType.includes(
"application/vnd.openxmlformats-officedocument.wordprocessingml",
)
)
ext = "docx";
else if (
contentType.includes(
"application/vnd.openxmlformats-officedocument.spreadsheetml",
)
)
ext = "xlsx";
else if (contentType.includes("text/csv")) ext = "csv";
else if (contentType.includes("text/markdown")) ext = "md";
else {
const urlPath = new URL(url).pathname;
ext = urlPath.split(".").pop()?.toLowerCase() ?? "txt";
}
return { buffer, ext };
}
+109
View File
@@ -0,0 +1,109 @@
import { describe, expect, test } from "bun:test";
import type { CookieOptions } from "./cookies";
import {
deleteCookies,
getCookie,
setCookies,
} from "./cookies";
describe("getCookie", () => {
test("extracts a single cookie", () => {
const req = new Request("http://localhost", {
headers: { Cookie: `bmp_auth_key=abc123` },
});
expect(getCookie(req, "bmp_auth_key")).toBe("abc123");
});
test("extracts a cookie from multiple", () => {
const req = new Request("http://localhost", {
headers: { Cookie: `foo=1; bmp_auth_key=abc; bar=2` },
});
expect(getCookie(req, "bmp_auth_key")).toBe("abc");
});
test("handles values with equals signs", () => {
const req = new Request("http://localhost", {
headers: { Cookie: `bmp_auth_key=a=b=c` },
});
expect(getCookie(req, "bmp_auth_key")).toBe("a=b=c");
});
test("returns undefined on empty or missing header", () => {
const no_header = new Request("http://localhost");
expect(getCookie(no_header, "bmp_auth_key")).toBeUndefined();
const empty = new Request("http://localhost", {
headers: { Cookie: "" },
});
expect(getCookie(empty, "bmp_auth_key")).toBeUndefined();
});
test("returns undefined for a missing cookie name", () => {
const req = new Request("http://localhost", {
headers: { Cookie: `foo=1` },
});
expect(getCookie(req, "bmp_auth_key")).toBeUndefined();
});
});
describe("setCookies", () => {
test("applies secure defaults", () => {
const res = new Response();
setCookies(res, [{ name: "bmp_auth_key", value: "abc" }]);
const [header] = res.headers.getSetCookie();
expect(header).toContain(`bmp_auth_key=abc`);
expect(header).toContain("HttpOnly");
expect(header).toContain("Secure");
expect(header).toContain("SameSite=Lax");
expect(header).toContain("Path=/");
});
test("appends multiple cookies without overwriting", () => {
const res = new Response();
setCookies(res, [
{ name: "bmp_auth_key", value: "abc" },
{ name: "bmp_auth_csrf", value: "xyz" },
]);
expect(res.headers.getSetCookie()).toHaveLength(2);
});
test("honors maxAge and custom options", () => {
const res = new Response();
setCookies(res, [
{
name: "bmp_auth_key",
value: "abc",
maxAge: 3600,
sameSite: "Strict",
path: "/admin",
httpOnly: false,
secure: false,
},
]);
const [header] = res.headers.getSetCookie();
expect(header).toContain("Max-Age=3600");
expect(header).toContain("SameSite=Strict");
expect(header).toContain("Path=/admin");
expect(header).not.toContain("HttpOnly");
expect(header).not.toContain("Secure");
});
});
describe("deleteCookies", () => {
test("expires the named cookies", () => {
const res = new Response();
deleteCookies(res, [
{ name: "bmp_auth_key", httpOnly: true },
{ name: "bmp_auth_csrf", httpOnly: true },
]);
const headers = res.headers.getSetCookie();
expect(headers).toHaveLength(2);
for (const header of headers) {
expect(header).toContain("Max-Age=0");
expect(header).toContain("bmp_");
}
});
});
+86
View File
@@ -0,0 +1,86 @@
/**
* Extracts a specific cookie value from a Request's Cookie header.
* Returns undefined if the cookie is not found.
*/
export function getCookie(request: Request, name: string): string | undefined {
const header = request.headers.get("Cookie");
if (!header) return undefined;
const cookies = header.split(";");
for (const cookie of cookies) {
const [key, ...rest] = cookie.split("=");
if (!key) continue;
if (key.trim() === name) {
return rest.join("=").trim();
}
}
return undefined;
}
export type CookieOptions = {
/** Cookie name */
name: string;
/** Cookie value */
value: string;
/** Prevents client-side JS access (default: true) */
httpOnly?: boolean;
/** Only send over HTTPS (default: true) */
secure?: boolean;
/** CSRF protection strategy (default: "Lax") */
sameSite?: "Strict" | "Lax" | "None";
/** URL path scope (default: "/") */
path?: string;
/** Lifetime in seconds. Omit for a session cookie. */
maxAge?: number;
/** Restrict to a specific domain */
domain?: string;
};
/**
* Appends one or more Set-Cookie headers to a Response.
* Uses `append` so multiple cookies don't overwrite each other.
*/
export function setCookies(response: Response, cookies: CookieOptions[]): void {
for (const cookie of cookies) {
const {
name,
value,
httpOnly = true,
secure = true,
sameSite = "Lax",
path = "/",
maxAge,
domain,
} = cookie;
let header = `${name}=${value}; Path=${path}; SameSite=${sameSite}`;
if (httpOnly) header += "; HttpOnly";
if (secure) header += "; Secure";
if (maxAge !== undefined) header += `; Max-Age=${maxAge}`;
if (domain) header += `; Domain=${domain}`;
response.headers.append("Set-Cookie", header);
}
}
/**
* Appends a Set-Cookie header that expires the named cookie immediately.
* Sets Max-Age=0 and an empty value to instruct the browser to remove it.
* Path and domain must match the original cookie for deletion to work.
*/
export function deleteCookies(
response: Response,
cookies: Pick<CookieOptions, "name" | "path" | "domain" | "httpOnly">[],
): void {
for (const { name, path = "/", domain, httpOnly } of cookies) {
let header = `${name}=; Path=${path}; Max-Age=0`;
if (httpOnly) header += "; HttpOnly";
if (domain) header += `; Domain=${domain}`;
response.headers.append("Set-Cookie", header);
}
}
+48
View File
@@ -0,0 +1,48 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { decrypt, encrypt } from "./crypt";
const original_key = process.env.ENCRYPTION_KEY;
const original_salt = process.env.ENCRYPTION_SALT;
beforeEach(() => {
process.env.ENCRYPTION_KEY = "test-key-1234567890";
process.env.ENCRYPTION_SALT = "test-salt-1234567890";
});
afterEach(() => {
if (original_key) process.env.ENCRYPTION_KEY = original_key;
else delete process.env.ENCRYPTION_KEY;
if (original_salt) process.env.ENCRYPTION_SALT = original_salt;
else delete process.env.ENCRYPTION_SALT;
});
describe("encrypt/decrypt", () => {
test("round-trips plain text", async () => {
const text = "hello world";
const encrypted = await encrypt(text);
expect(encrypted).not.toBe(text);
expect(await decrypt(encrypted)).toBe(text);
});
test("round-trips unicode and long strings", async () => {
const text = "hello 世界 🎉 " + "x".repeat(5000);
expect(await decrypt(await encrypt(text))).toBe(text);
});
test("round-trips empty strings", async () => {
expect(await decrypt(await encrypt(""))).toBe("");
});
test("produces unique ciphertext per call", async () => {
const text = "same plaintext";
const a = await encrypt(text);
const b = await encrypt(text);
expect(a).not.toBe(b);
});
test("decrypt fails on tampered input", async () => {
const encrypted = await encrypt("secret");
const tampered = "A" + encrypted.slice(1);
expect(async () => await decrypt(tampered)).toThrow();
});
});
+53
View File
@@ -0,0 +1,53 @@
const key = process.env.ENCRYPTION_KEY;
const salt = process.env.ENCRYPTION_SALT;
async function getKey() {
const material = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(key),
"PBKDF2",
false,
["deriveKey"],
);
return crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: new TextEncoder().encode(salt),
iterations: 100000,
hash: "SHA-256",
},
material,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
}
export async function encrypt(text: string): Promise<string> {
const derivedKey = await getKey();
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
derivedKey,
new TextEncoder().encode(text),
);
const combined = new Uint8Array(
iv.length + new Uint8Array(ciphertext).length,
);
combined.set(iv);
combined.set(new Uint8Array(ciphertext), iv.length);
return btoa(String.fromCharCode(...combined));
}
export async function decrypt(encoded: string): Promise<string> {
const derivedKey = await getKey();
const combined = Uint8Array.from(atob(encoded), (c) => c.charCodeAt(0));
const iv = combined.slice(0, 12);
const ciphertext = combined.slice(12);
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv },
derivedKey,
ciphertext,
);
return new TextDecoder().decode(decrypted);
}
+38
View File
@@ -0,0 +1,38 @@
/**
* # EJSON parse string
*/
function parse(
string: string | null | number,
reviver?: (this: any, key: string, value: any) => any
): { [s: string]: any } | { [s: string]: any }[] | undefined {
if (!string) return undefined;
if (typeof string == "object") return string;
if (typeof string !== "string") return undefined;
try {
return JSON.parse(string, reviver);
} catch (error) {
return undefined;
}
}
/**
* # EJSON stringify object
*/
function stringify(
value: any,
replacer?: ((this: any, key: string, value: any) => any) | null,
space?: string | number
): string | undefined {
try {
return JSON.stringify(value, replacer || undefined, space);
} catch (error) {
return undefined;
}
}
const EJSON = {
parse,
stringify,
};
export default EJSON;
+10
View File
@@ -0,0 +1,10 @@
import { expect, test } from "bun:test";
import formatBytes from "./format-bytes";
test("formatBytes formats sizes", () => {
expect(formatBytes({ bytes: 0 })).toBe("0 B");
expect(formatBytes({ bytes: 512 })).toBe("512 B");
expect(formatBytes({ bytes: 12449 })).toBe("12.2 KB");
expect(formatBytes({ bytes: 5 * 1024 * 1024 })).toBe("5.0 MB");
expect(formatBytes({ bytes: NaN })).toBe("0 B");
});
+23
View File
@@ -0,0 +1,23 @@
const Units = ["B", "KB", "MB", "GB", "TB"];
type Params = {
bytes: number;
};
/**
* Human readable file size, e.g. `12.2 KB`.
*/
export default function formatBytes({ bytes }: Params) {
if (!Number.isFinite(bytes) || bytes <= 0) {
return "0 B";
}
const unit_index = Math.min(
Math.floor(Math.log(bytes) / Math.log(1024)),
Units.length - 1,
);
const value = bytes / 1024 ** unit_index;
return `${unit_index == 0 ? value : value.toFixed(1)} ${Units[unit_index]}`;
}
+41
View File
@@ -0,0 +1,41 @@
import path from "path";
import type { User } from "../types";
import type { BUN_SQLITE_BSQLITE_USERS } from "@/db/types/db";
type Params = {
user?: BUN_SQLITE_BSQLITE_USERS | User;
};
export default function grabDirNames(params?: Params) {
const DIRNAME = __dirname;
const ROOT_DIR = DIRNAME.includes(`/.bunext`)
? process.cwd()
: path.resolve(__dirname, "../..");
const DATA_DIR = process.env.DATA_DIR || path.join(ROOT_DIR, ".data");
const MEDIA_RELATIVE_DIR = "media";
const MEDIA_DIR = path.join(DATA_DIR, MEDIA_RELATIVE_DIR);
const USER_MEDIA_PUBLIC_DIR = params?.user?.id
? path.join(MEDIA_DIR, String(params.user.id), "public")
: undefined;
const USER_MEDIA_PUBLIC_RELATIVE_DIR = params?.user?.id
? path.join(MEDIA_RELATIVE_DIR, String(params.user.id), "public")
: undefined;
const USER_MEDIA_PRIVATE_DIR = params?.user?.id
? path.join(MEDIA_DIR, String(params.user.id), "private")
: undefined;
const USER_MEDIA_PRIVATE_RELATIVE_DIR = params?.user?.id
? path.join(MEDIA_RELATIVE_DIR, String(params.user.id), "private")
: undefined;
return {
ROOT_DIR,
MEDIA_DIR,
USER_MEDIA_PUBLIC_DIR,
USER_MEDIA_PRIVATE_DIR,
DATA_DIR,
USER_MEDIA_PUBLIC_RELATIVE_DIR,
USER_MEDIA_PRIVATE_RELATIVE_DIR,
};
}
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test";
import hexToRgbFractions from "./hex-to-rgb-fractions";
describe("hexToRgbFractions", () => {
test("converts 6-digit hex", () => {
expect(hexToRgbFractions({ hex: "#ff0000" })).toEqual({
r: 1,
g: 0,
b: 0,
});
});
test("expands 3-digit hex", () => {
expect(hexToRgbFractions({ hex: "#fff" })).toEqual({
r: 1,
g: 1,
b: 1,
});
});
test("ignores alpha", () => {
expect(hexToRgbFractions({ hex: "#00000080" })).toEqual({
r: 0,
g: 0,
b: 0,
});
});
});
+24
View File
@@ -0,0 +1,24 @@
/**
* Converts `#rgb` / `#rrggbb` hex into `0-1` channel fractions (pdf-lib `rgb()`
* input). Alpha in `#rrggbbaa` is ignored.
*/
export default function hexToRgbFractions({ hex }: { hex: string }): {
r: number;
g: number;
b: number;
} {
let clean = hex.replace("#", "");
if (clean.length == 3 || clean.length == 4) {
clean = clean
.slice(0, 3)
.split("")
.map((char) => char + char)
.join("");
}
const channel = (offset: number) =>
(parseInt(clean.slice(offset, offset + 2), 16) || 0) / 255;
return { r: channel(0), g: channel(2), b: channel(4) };
}
+42
View File
@@ -0,0 +1,42 @@
import { expect, test } from "bun:test";
import {
formatNaira,
nairaToKobo,
sumInvoiceItemsKobo,
} from "./money";
test("formatNaira formats kobo amounts", () => {
expect(formatNaira(150050)).toBe("₦1,500.50");
expect(formatNaira(100)).toBe("₦1.00");
expect(formatNaira(0)).toBe("₦0.00");
expect(formatNaira(500000000)).toBe("₦5,000,000.00");
expect(formatNaira(1)).toBe("₦0.01");
expect(formatNaira("250000")).toBe("₦2,500.00");
});
test("formatNaira handles strings, undefined and negative", () => {
expect(formatNaira(undefined)).toBe("₦0.00");
expect(formatNaira(null)).toBe("₦0.00");
expect(formatNaira(1000)).toBe("₦10.00");
expect(formatNaira(-5050)).toBe("-₦50.50");
});
test("nairaToKobo parses naira strings", () => {
expect(nairaToKobo("₦1,500.50")).toBe(150050);
expect(nairaToKobo("1500")).toBe(150000);
expect(nairaToKobo("1,234.56")).toBe(123456);
expect(nairaToKobo(undefined)).toBe(0);
expect(nairaToKobo("")).toBe(0);
expect(nairaToKobo("abc")).toBe(0);
});
test("sumInvoiceItemsKobo totals line items", () => {
const items = [
{ quantity: 2, unit_price_kobo: 5000 }, // ₦50.00 x2
{ quantity: 1, unit_price_kobo: 150000 }, // ₦1,500.00
{ quantity: "", unit_price_kobo: "" },
];
expect(sumInvoiceItemsKobo(items)).toBe(160000);
expect(sumInvoiceItemsKobo([])).toBe(0);
});
+92
View File
@@ -0,0 +1,92 @@
/**
* # Format a kobo (₦/100) amount as a Naira currency string.
* @example formatNaira(150050) // "₦1,500.50"
*/
export function formatNaira(kobo: number | string | undefined | null): string {
const amount = Math.round(Number(kobo || 0));
const negative = amount < 0;
const absolute = Math.abs(amount);
const whole = Math.floor(absolute / 100);
const fraction = String(absolute % 100).padStart(2, "0");
const digits = String(whole);
let grouped = "";
for (let i = digits.length - 1, c = 0; i >= 0; i--, c++) {
if (c > 0 && c % 3 == 0) {
grouped = `,${grouped}`;
}
grouped = digits[i] + grouped;
}
return `${negative ? "-" : ""}₦${grouped}.${fraction}`;
}
/**
* # Format a kobo amount with an ISO currency prefix.
* Standard PDF fonts (WinAnsi) cannot encode the ₦ glyph,
* so PDFs use "NGN" instead of "₦".
* @example formatNairaPdf(150050) // "NGN 1,500.50"
*/
export function formatNairaPdf(
kobo: number | string | undefined | null,
): string {
const amount = Math.round(Number(kobo || 0));
const negative = amount < 0;
const absolute = Math.abs(amount);
const whole = Math.floor(absolute / 100);
const fraction = String(absolute % 100).padStart(2, "0");
const digits = String(whole);
let grouped = "";
for (let i = digits.length - 1, c = 0; i >= 0; i--, c++) {
if (c > 0 && c % 3 == 0) {
grouped = `,${grouped}`;
}
grouped = digits[i] + grouped;
}
return `${negative ? "-" : ""}NGN ${grouped}.${fraction}`;
}
/**
* # Convert a Naira string/number to kobo (smallest unit).
* @example nairaToKobo("₦1,500.50") // 150050
*/
export function nairaToKobo(value: string | number | undefined): number {
const cleaned = String(value || 0).replace(/[^0-9.]/g, "");
return Math.round((Number(cleaned) || 0) * 100);
}
/**
* # Convert a kobo amount to a plain Naira string for form inputs.
* @example koboToNairaString(150050) // "1500.5"
*/
export function koboToNairaString(
kobo: number | string | undefined | null,
): string {
const amount = Math.abs(Number(kobo || 0));
const naira = amount / 100;
return String(Math.round(naira * 100) / 100);
}
export type InvoiceItemAmountInput = {
quantity?: number | string | "";
unit_price_kobo?: number | string | "";
};
/**
* # Sum line items into a kobo total.
*/
export function sumInvoiceItemsKobo(
items: InvoiceItemAmountInput[],
): number {
return items.reduce((acc, item) => {
const quantity = Number(item.quantity) || 0;
const unit_price = Number(item.unit_price_kobo) || 0;
return acc + quantity * unit_price;
}, 0);
}
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, test } from "bun:test";
import parseLoginMethod from "./parse-login-method";
describe("parseLoginMethod", () => {
test("reads password", () => {
expect(parseLoginMethod({ value: "password" })).toBe("password");
expect(parseLoginMethod({ value: " Password " })).toBe("password");
});
test("reads code", () => {
expect(parseLoginMethod({ value: "code" })).toBe("code");
});
test("falls back to code when unset or unknown", () => {
expect(parseLoginMethod({})).toBe("code");
expect(parseLoginMethod({ value: "" })).toBe("code");
expect(parseLoginMethod({ value: "passwrd" })).toBe("code");
});
});
+14
View File
@@ -0,0 +1,14 @@
import type { LoginMethod } from "@/src/types";
type Params = {
value?: string | null;
};
/**
* Reads the site-wide login method (`AUTH_LOGIN_METHOD`). Anything other
* than `password` falls back to passwordless `code` login, so a typo can
* never lock out accounts that have no password.
*/
export default function parseLoginMethod({ value }: Params): LoginMethod {
return value?.trim().toLowerCase() == "password" ? "password" : "code";
}
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, test } from "bun:test";
import parseThemeColors from "./parse-theme-colors";
describe("parseThemeColors", () => {
test("extracts color tokens from the @theme inline block", () => {
const css = `
@import "x.css";
@theme inline {
--font-sans: "Geist";
--color-primary: #101828;
--color-slate-500: #667085;
--color-accent:#5B5BD6;
--radius-default: 6px;
}
.foo { --color-primary: #ff0000; }
`;
expect(parseThemeColors({ css })).toEqual({
primary: "#101828",
"slate-500": "#667085",
accent: "#5b5bd6",
});
});
test("returns an empty object without a theme block", () => {
expect(parseThemeColors({ css: ".a { color: red; }" })).toEqual({});
});
});
+24
View File
@@ -0,0 +1,24 @@
/**
* Extracts `--color-*: #hex;` tokens from the `@theme inline` block of a CSS
* string. Keys are returned without the `--color-` prefix (e.g. `primary`,
* `slate-500`).
*/
export default function parseThemeColors({
css,
}: {
css: string;
}): Record<string, string> {
const theme_block = css.match(/@theme\s+inline\s*\{([\s\S]*?)\n\}/)?.[1];
if (!theme_block) return {};
const colors: Record<string, string> = {};
const color_regex = /--color-([a-z0-9-]+)\s*:\s*(#[0-9a-fA-F]{3,8})\s*;/g;
for (const match of theme_block.matchAll(color_regex)) {
const [, name, value] = match;
if (name && value) colors[name] = value.toLowerCase();
}
return colors;
}
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, test } from "bun:test";
import { z } from "zod";
import AppError from "./app-error";
import parseWithSchema from "./parse-with-schema";
const Schema = z.object({
email: z.email("Please enter a valid email address"),
age: z.number().min(18, "You must be at least 18"),
});
describe("parseWithSchema", () => {
test("returns the parsed data for valid input", () => {
expect(
parseWithSchema({
schema: Schema,
data: { email: "[email protected]", age: 30 },
}),
).toEqual({ email: "[email protected]", age: 30 });
});
test("throws an AppError with the first issue message", () => {
expect(() =>
parseWithSchema({
schema: Schema,
data: { email: "not-an-email", age: 30 },
}),
).toThrow(AppError);
});
test("throws for non-object input", () => {
expect(() =>
parseWithSchema({ schema: Schema, data: null }),
).toThrow(AppError);
});
});
+24
View File
@@ -0,0 +1,24 @@
import type { ZodType } from "zod";
import AppError from "./app-error";
type Params<T> = {
schema: ZodType<T>;
data: unknown;
};
/**
* # Parse With Schema
* Validates untrusted input and throws a user-facing `AppError` with the
* first issue message when validation fails.
*/
export default function parseWithSchema<T>({ schema, data }: Params<T>): T {
const result = schema.safeParse(data);
if (!result.success) {
throw new AppError(
result.error.issues[0]?.message || "Invalid request",
);
}
return result.data;
}
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, test } from "bun:test";
import rateLimit, { resetRateLimit } from "./rate-limit";
describe("rateLimit", () => {
test("allows hits up to the limit then blocks", () => {
const key = `test:${crypto.randomUUID()}`;
for (let i = 0; i < 3; i++) {
expect(rateLimit({ key, limit: 3, window_ms: 60_000 }).allowed).toBe(
true,
);
}
expect(rateLimit({ key, limit: 3, window_ms: 60_000 }).allowed).toBe(
false,
);
});
test("resets after the window expires", async () => {
const key = `test:${crypto.randomUUID()}`;
rateLimit({ key, limit: 1, window_ms: 5 });
expect(rateLimit({ key, limit: 1, window_ms: 5 }).allowed).toBe(false);
await Bun.sleep(10);
expect(rateLimit({ key, limit: 1, window_ms: 5 }).allowed).toBe(true);
});
test("resetRateLimit clears the bucket", () => {
const key = `test:${crypto.randomUUID()}`;
rateLimit({ key, limit: 1, window_ms: 60_000 });
resetRateLimit({ key });
expect(rateLimit({ key, limit: 1, window_ms: 60_000 }).allowed).toBe(
true,
);
});
test("keeps buckets independent", () => {
const a = `test:${crypto.randomUUID()}`;
const b = `test:${crypto.randomUUID()}`;
rateLimit({ key: a, limit: 1, window_ms: 60_000 });
expect(rateLimit({ key: a, limit: 1, window_ms: 60_000 }).allowed).toBe(
false,
);
expect(rateLimit({ key: b, limit: 1, window_ms: 60_000 }).allowed).toBe(
true,
);
});
});
+65
View File
@@ -0,0 +1,65 @@
type RateLimitEntry = {
count: number;
reset_at: number;
};
const store = new Map<string, RateLimitEntry>();
type Params = {
/**
* Unique bucket key, e.g. `sso-login:<user_id>` or `contact:<ip>`
*/
key: string;
/**
* Max hits allowed inside the window
*/
limit: number;
window_ms: number;
};
export type RateLimitReturn = {
allowed: boolean;
remaining: number;
reset_at: number;
};
/**
* In-memory fixed-window rate limiter. Each call counts as one hit.
* State is per-process, which is enough for single-container deploys.
*/
export default function rateLimit({
key,
limit,
window_ms,
}: Params): RateLimitReturn {
const now = Date.now();
if (store.size > 10000) {
for (const [k, entry] of store) {
if (entry.reset_at <= now) store.delete(k);
}
}
const existing = store.get(key);
const entry: RateLimitEntry =
existing && existing.reset_at > now
? existing
: { count: 0, reset_at: now + window_ms };
entry.count += 1;
store.set(key, entry);
return {
allowed: entry.count <= limit,
remaining: Math.max(0, limit - entry.count),
reset_at: entry.reset_at,
};
}
/**
* Clear a bucket, e.g. after a successful login.
*/
export function resetRateLimit({ key }: { key: string }) {
store.delete(key);
}
+23
View File
@@ -0,0 +1,23 @@
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
import isPassThroughUser from "./is-pass-through-user";
import { UserManagementUserTypes } from "@/src/dict/user-types-dict";
type Params = {
user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
};
/**
* Whether the actor may create new user records. User type assignment on
* the new record is still limited by the assignment matrix.
*/
export default function canCreateUser({ user_types }: Params): boolean {
if (isPassThroughUser({ user_types })) {
return true;
}
return checkUserAccess({
user_types,
includes: UserManagementUserTypes,
}).success;
}
+23
View File
@@ -0,0 +1,23 @@
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
import isPassThroughUser from "./is-pass-through-user";
import { UserManagementUserTypes } from "@/src/dict/user-types-dict";
type Params = {
user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
};
/**
* Whether the actor may list (view) all user records, not just their
* own. Pass-through actors always pass.
*/
export default function canListUsers({ user_types }: Params): boolean {
if (isPassThroughUser({ user_types })) {
return true;
}
return checkUserAccess({
user_types,
includes: UserManagementUserTypes,
}).success;
}
+26
View File
@@ -0,0 +1,26 @@
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
import isPassThroughUser from "./is-pass-through-user";
import { UserManagementUserTypes } from "@/src/dict/user-types-dict";
type Params = {
user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
};
/**
* Whether the actor may manage user type records (insert or delete rows
* in the `user_types` table). Which individual types they may grant or
* remove is still enforced per type by the assignment matrix.
*/
export default function canManageUserTypes({
user_types,
}: Params): boolean {
if (isPassThroughUser({ user_types })) {
return true;
}
return checkUserAccess({
user_types,
includes: UserManagementUserTypes,
}).success;
}
+36
View File
@@ -0,0 +1,36 @@
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
import type { UserType } from "@/src/types";
import userTypeAssignment from "./user-type-assignment";
type Params = {
user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
target_user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
};
/**
* Whether the actor may revoke a user (i.e. add the `revoked` user type).
*
* - A user holding the `super_admin` type can never be revoked.
* - Otherwise governed by the assignment matrix for the `revoked` type:
* pass-through actors (`super_admin`) may revoke anyone, and `admin`
* may revoke users who do not hold an excluded protected type.
*/
export default function canRevokeUser({
user_types,
target_user_types,
}: Params): boolean {
if (target_user_types?.some((ut) => ut.user_type === "super_admin")) {
return false;
}
const target_user_type_values: UserType[] | undefined = target_user_types
?.map((ut) => ut.user_type)
.filter((ut) => Boolean(ut)) as UserType[] | undefined;
return userTypeAssignment({
actor_user_types: user_types,
target_user_type: "revoked",
target_user_types: target_user_type_values,
action: "assign",
});
}
+85
View File
@@ -0,0 +1,85 @@
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
import type { UserType } from "@/src/types";
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
import isPassThroughUser from "./is-pass-through-user";
type Params = {
actor_user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
actor_user_id?: number | string;
target_user_id?: number | string;
target_user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
/**
* The keys of the fields being updated (e.g. ["first_name", "email"]).
* Used to restrict contact fields on protected users.
*/
update_fields?: string[];
};
/**
* Fields treated as contact information.
*/
export const ContactUpdateFields = ["email"];
/**
* User types whose contact fields (email) are protected from changes by
* non-pass-through actors.
*/
export const ContactProtectedUserTypes: UserType[] = ["super_admin"];
/**
* Whether the actor may update a user record.
*
* - Any user may update their own record (profile fields only; user type
* changes are still governed by the assignment matrix).
* - Pass-through actors (`super_admin`) may update anyone.
* - `admin` may edit the basic info of any user but cannot change the
* email of protected users (`super_admin`). User type changes are
* still governed by the assignment matrix.
* - Everyone else can only update their own record.
*/
export default function canUpdateUser({
actor_user_types,
actor_user_id,
target_user_id,
target_user_types,
update_fields,
}: Params): boolean {
if (
target_user_id &&
actor_user_id &&
String(target_user_id) === String(actor_user_id)
) {
return true;
}
if (isPassThroughUser({ user_types: actor_user_types })) {
return true;
}
const can_edit_basic_info = checkUserAccess({
user_types: actor_user_types,
includes: ["admin"],
});
if (!can_edit_basic_info.success) {
return false;
}
const touches_contacts = update_fields?.some((field) =>
ContactUpdateFields.includes(field),
);
if (!touches_contacts) {
return true;
}
if (
target_user_types?.some((ut) =>
ContactProtectedUserTypes.includes(ut.user_type as UserType),
)
) {
return false;
}
return true;
}
@@ -0,0 +1,25 @@
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
import type { UserType } from "@/src/types";
import userTypeAssignment from "./user-type-assignment";
type Params = {
actor_user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
user_types: UserType[];
};
/**
* Filters a list of user types down to the ones the actor may assign
* (grant or remove), per the assignment matrix.
*/
export default function filterAssignableUserTypes({
actor_user_types,
user_types,
}: Params): UserType[] {
return user_types.filter((target_user_type) =>
userTypeAssignment({
actor_user_types,
target_user_type,
action: "assign",
}),
);
}
+193
View File
@@ -0,0 +1,193 @@
import { describe, expect, test } from "bun:test";
import {
canCreateUser,
canListUsers,
canManageUserTypes,
canRevokeUser,
canUpdateUser,
filterAssignableUserTypes,
isPassThroughUser,
} from "./";
import {
ALL_USER_TYPES,
makeUserTypes,
} from "./user-type-assignment.test-fixtures";
describe("isPassThroughUser", () => {
test("super_admin is pass-through", () => {
expect(isPassThroughUser({ user_types: makeUserTypes("super_admin") })).toBe(true);
});
test("regular types are not pass-through", () => {
for (const type of ["admin", "president", "secretary"]) {
expect(
isPassThroughUser({ user_types: makeUserTypes(type) }),
).toBe(false);
}
});
});
describe("canListUsers / canCreateUser / canManageUserTypes", () => {
test("pass-through always passes", () => {
for (const fn of [canListUsers, canCreateUser, canManageUserTypes]) {
expect(
fn({ user_types: makeUserTypes("super_admin") }),
).toBe(true);
}
});
test("admin passes", () => {
expect(canListUsers({ user_types: makeUserTypes("admin") })).toBe(true);
expect(canCreateUser({ user_types: makeUserTypes("admin") })).toBe(true);
expect(
canManageUserTypes({ user_types: makeUserTypes("admin") }),
).toBe(true);
});
test("non-admin types fail", () => {
for (const type of ["auditor", "president", "secretary", "read_only"]) {
expect(
canListUsers({ user_types: makeUserTypes(type) }),
).toBe(false);
expect(
canCreateUser({ user_types: makeUserTypes(type) }),
).toBe(false);
expect(
canManageUserTypes({ user_types: makeUserTypes(type) }),
).toBe(false);
}
});
});
describe("canUpdateUser", () => {
test("actors may update their own record", () => {
expect(
canUpdateUser({
actor_user_types: makeUserTypes("auditor"),
actor_user_id: 5,
target_user_id: 5,
}),
).toBe(true);
});
test("pass-through may update anyone including contacts", () => {
expect(
canUpdateUser({
actor_user_types: makeUserTypes("super_admin"),
actor_user_id: 1,
target_user_id: 2,
target_user_types: makeUserTypes("super_admin"),
update_fields: ["email"],
}),
).toBe(true);
});
test("admin may edit basic info of others", () => {
expect(
canUpdateUser({
actor_user_types: makeUserTypes("admin"),
actor_user_id: 1,
target_user_id: 2,
update_fields: ["first_name"],
}),
).toBe(true);
});
test("admin cannot change contacts of protected users", () => {
expect(
canUpdateUser({
actor_user_types: makeUserTypes("admin"),
actor_user_id: 1,
target_user_id: 2,
target_user_types: makeUserTypes("super_admin"),
update_fields: ["email"],
}),
).toBe(false);
});
test("non-admin actors cannot update others", () => {
expect(
canUpdateUser({
actor_user_types: makeUserTypes("auditor"),
actor_user_id: 1,
target_user_id: 2,
update_fields: ["first_name"],
}),
).toBe(false);
});
});
describe("canRevokeUser", () => {
test("super_admin targets can never be revoked", () => {
expect(
canRevokeUser({
user_types: makeUserTypes("super_admin"),
target_user_types: makeUserTypes("super_admin"),
}),
).toBe(false);
});
test("pass-through may revoke anyone else", () => {
expect(
canRevokeUser({
user_types: makeUserTypes("super_admin"),
target_user_types: makeUserTypes("admin"),
}),
).toBe(true);
});
test("admin may revoke revocable targets", () => {
expect(
canRevokeUser({
user_types: makeUserTypes("admin"),
target_user_types: makeUserTypes("read_only"),
}),
).toBe(true);
});
test("admin cannot revoke protected targets", () => {
expect(
canRevokeUser({
user_types: makeUserTypes("admin"),
target_user_types: makeUserTypes("admin"),
}),
).toBe(false);
expect(
canRevokeUser({
user_types: makeUserTypes("admin"),
target_user_types: makeUserTypes("president"),
}),
).toBe(false);
});
});
describe("filterAssignableUserTypes", () => {
test("super_admin can assign everything", () => {
const assignable = filterAssignableUserTypes({
actor_user_types: makeUserTypes("super_admin"),
user_types: ALL_USER_TYPES,
});
expect(assignable).toHaveLength(ALL_USER_TYPES.length);
});
test("admin sees only assignable types", () => {
const assignable = filterAssignableUserTypes({
actor_user_types: makeUserTypes("admin"),
user_types: ALL_USER_TYPES,
});
expect(assignable).toContain("auditor");
expect(assignable).toContain("read_only");
expect(assignable).not.toContain("admin");
expect(assignable).not.toContain("super_admin");
expect(assignable).not.toContain("president");
expect(assignable).not.toContain("secretary");
});
test("secretary can assign nothing", () => {
const assignable = filterAssignableUserTypes({
actor_user_types: makeUserTypes("secretary"),
user_types: ALL_USER_TYPES,
});
expect(assignable).toHaveLength(0);
});
});
+8
View File
@@ -0,0 +1,8 @@
export { default as userTypeAssignment } from "./user-type-assignment";
export { default as canCreateUser } from "./can-create-user";
export { default as canListUsers } from "./can-list-users";
export { default as canManageUserTypes } from "./can-manage-user-types";
export { default as canRevokeUser } from "./can-revoke-user";
export { default as canUpdateUser } from "./can-update-user";
export { default as filterAssignableUserTypes } from "./filter-assignable-user-types";
export { default as isPassThroughUser } from "./is-pass-through-user";
+14
View File
@@ -0,0 +1,14 @@
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
type Params = {
user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
};
/**
* Pass-through actors that bypass the assignment matrix: `super_admin`.
*/
export default function isPassThroughUser({ user_types }: Params): boolean {
return Boolean(
user_types?.find((ut) => ut.user_type === "super_admin"),
);
}
+12
View File
@@ -0,0 +1,12 @@
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
type Params = {
user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
};
/**
* Whether this is a `read_only` user
*/
export default function isReadOnlyUser({ user_types }: Params): boolean {
return Boolean(user_types?.find((ty) => ty.user_type == "read_only"));
}
@@ -0,0 +1,72 @@
import type {
UserType,
UserTypeAssignmentMatrixType,
} from "@/src/types";
/**
* Synthetic types (`orphan`, `board_member`) exercise the matrix interpreter
* independently of the preset's real `UserTypes`.
*/
export const ALL_USER_TYPES = [
"super_admin",
"admin",
"auditor",
"president",
"vice_president",
"secretary",
"orphan",
"revoked",
"read_only",
"board_member",
] as UserType[];
/**
* Matrix keyed by any type name, cast to the real matrix type for the
* interpreter.
*/
type FixtureMatrix = Record<string, any>;
type UserTypesInput = string[];
export function makeUserTypes(...types: UserTypesInput): { user_type: string }[] {
return types.map((user_type) => ({ user_type }));
}
export function makeMatrix(
overrides?: FixtureMatrix,
): UserTypeAssignmentMatrixType {
const base: FixtureMatrix = {
super_admin: {},
admin: { allowed_actors: [] },
president: { allowed_actors: [] },
vice_president: { allowed_actors: [] },
secretary: { allowed_actors: [] },
board_member: { allowed_actors: ["admin"] },
auditor: { allowed_actors: ["admin"] },
orphan: { allowed_actors: ["admin"] },
read_only: {
allowed_actors: {
admin: { excluded_targets: ["super_admin"] },
},
},
revoked: {
allowed_actors: {
admin: {
allowed_targets: ["board_member", "auditor", "orphan"],
excluded_targets: [
"super_admin",
"admin",
"president",
"vice_president",
"secretary",
],
},
},
},
};
return {
...base,
...overrides,
} as UserTypeAssignmentMatrixType;
}
+193
View File
@@ -0,0 +1,193 @@
import { describe, expect, test } from "bun:test";
import type { UserType } from "@/src/types";
import userTypeAssignment from "./user-type-assignment";
import {
ALL_USER_TYPES,
makeMatrix,
makeUserTypes,
} from "./user-type-assignment.test-fixtures";
const ASSIGNMENT_MATRIX = makeMatrix();
const DELETION_MATRIX = makeMatrix();
function assign(params: {
actor: string[];
target_type: string;
target?: string[];
action?: "assign" | "delete";
}) {
return userTypeAssignment({
actor_user_types: makeUserTypes(...params.actor),
target_user_type: params.target_type as UserType,
target_user_types: params.target as UserType[],
action: params.action || "assign",
assignment_matrix: ASSIGNMENT_MATRIX,
deletion_matrix: DELETION_MATRIX,
});
}
describe("userTypeAssignment", () => {
test("pass-through (super_admin) can assign anything", () => {
for (const target of ALL_USER_TYPES) {
expect(
assign({ actor: ["super_admin"], target_type: target }),
).toBe(true);
}
});
test("pass-through (super_admin) can delete anything", () => {
for (const target of ALL_USER_TYPES) {
expect(
assign({
actor: ["super_admin"],
target_type: target,
action: "delete",
}),
).toBe(true);
}
});
test("no actors allowed for super_admin", () => {
expect(
assign({ actor: ["admin"], target_type: "super_admin" }),
).toBe(false);
expect(
assign({
actor: ["admin"],
target_type: "super_admin",
action: "delete",
}),
).toBe(false);
});
test("empty allowed_actors means pass-through only", () => {
for (const target of ["admin", "president", "vice_president", "secretary"]) {
expect(assign({ actor: ["admin"], target_type: target })).toBe(
false,
);
}
});
test("array allowed_actors matches any actor", () => {
expect(assign({ actor: ["admin"], target_type: "board_member" })).toBe(
true,
);
expect(assign({ actor: ["auditor"], target_type: "board_member" })).toBe(
false,
);
expect(assign({ actor: ["admin"], target_type: "auditor" })).toBe(true);
});
test("per-actor rules respect excluded targets", () => {
expect(
assign({
actor: ["admin"],
target_type: "read_only",
target: ["admin"],
}),
).toBe(true);
expect(
assign({
actor: ["admin"],
target_type: "read_only",
target: ["super_admin"],
}),
).toBe(false);
});
test("per-actor rules respect allowed targets", () => {
expect(
assign({
actor: ["admin"],
target_type: "revoked",
target: ["board_member"],
}),
).toBe(true);
expect(
assign({
actor: ["admin"],
target_type: "revoked",
target: ["admin"],
}),
).toBe(false);
});
test("missing target user types fail allowed_targets checks", () => {
expect(
assign({
actor: ["admin"],
target_type: "revoked",
target: [],
}),
).toBe(false);
});
test("same_as clones rules of another actor", () => {
const matrix = makeMatrix({
read_only: {
allowed_actors: {
admin: { excluded_targets: ["super_admin"] },
auditor: { same_as: "admin" },
},
},
});
expect(
userTypeAssignment({
actor_user_types: makeUserTypes("auditor"),
target_user_type: "read_only",
target_user_types: ["admin"] as UserType[],
action: "assign",
assignment_matrix: matrix,
deletion_matrix: matrix,
}),
).toBe(true);
expect(
userTypeAssignment({
actor_user_types: makeUserTypes("auditor"),
target_user_type: "read_only",
target_user_types: ["super_admin"] as UserType[],
action: "assign",
assignment_matrix: matrix,
deletion_matrix: matrix,
}),
).toBe(false);
});
test("all flag grants unconditional access", () => {
const matrix = makeMatrix({
board_member: {
allowed_actors: {
admin: { all: true },
},
},
});
expect(
userTypeAssignment({
actor_user_types: makeUserTypes("admin"),
target_user_type: "board_member" as UserType,
target_user_types: ["super_admin"] as UserType[],
action: "assign",
assignment_matrix: matrix,
deletion_matrix: matrix,
}),
).toBe(true);
});
});
describe("unknown user types", () => {
test("types missing from the matrix are denied instead of throwing", () => {
expect(
userTypeAssignment({
actor_user_types: makeUserTypes("admin"),
target_user_type: "not_in_matrix" as UserType,
action: "assign",
assignment_matrix: makeMatrix(),
deletion_matrix: makeMatrix(),
}),
).toBe(false);
});
});
+165
View File
@@ -0,0 +1,165 @@
import type {
UserAssignmentFnParams,
UserTypeAssignmentRules,
} from "@/src/types";
import {
UserTypeAssignmentMatrix,
UserTypeDeletionMatrix,
} from "@/src/dict/user-types-dict";
import isPassThroughUser from "./is-pass-through-user";
/**
* Whether the actor may assign or remove `target_user_type` on a user.
* Governed by `UserTypeAssignmentMatrix` and `UserTypeDeletionMatrix` and
* applied symmetrically to granting and removing. Pass-through actors
* (`super_admin`) can always assign.
*/
export default function userTypeAssignment({
actor_user_types,
target_user_type,
target_user_types,
action,
assignment_matrix = UserTypeAssignmentMatrix,
deletion_matrix = UserTypeDeletionMatrix,
debug,
}: UserAssignmentFnParams): boolean {
if (isPassThroughUser({ user_types: actor_user_types })) {
if (debug) {
console.log(
`[RBAC] Pass-through actor detected for [${target_user_type}], access granted.`,
);
}
return true;
}
const matrix = action == "assign" ? assignment_matrix : deletion_matrix;
const assignable_by = matrix[target_user_type];
if (!assignable_by?.allowed_actors) {
if (debug) {
console.log(
`[RBAC] No allowed actors for [${target_user_type}] in the ${action} matrix.`,
);
}
return false;
}
const is_target_user_type_excluded =
target_user_types && assignable_by.excluded_targets
? target_user_types.find((tut) =>
assignable_by.excluded_targets?.find((ex) => ex == tut),
)
: false;
if (is_target_user_type_excluded) {
if (debug) {
console.log(
`[RBAC] Target user type [${target_user_types?.join(",")}] excluded for [${target_user_type}].`,
);
}
return false;
}
const actor_type_set = new Set<string>(
actor_user_types
?.map((ut) => ut.user_type)
.filter((ut): ut is string => Boolean(ut)),
);
if (Array.isArray(assignable_by.allowed_actors)) {
if (!assignable_by.allowed_actors.length) {
if (debug) {
console.log(
`[RBAC] Actor not allowed for [${target_user_type}], allowed actors list is empty.`,
);
}
return false;
}
return assignable_by.allowed_actors.some((actor_type) =>
actor_type_set.has(actor_type),
);
}
const allowed_actors_matrix = assignable_by.allowed_actors as Record<
string,
UserTypeAssignmentRules | undefined
>;
if (!Object.keys(allowed_actors_matrix).length) {
if (debug) {
console.log(
`[RBAC] No allowed actors matrix for [${target_user_type}].`,
);
}
return false;
}
return Array.from(actor_type_set).some((actor_type) => {
let actor_rules = allowed_actors_matrix[actor_type];
if (
actor_rules?.same_as &&
allowed_actors_matrix[actor_rules.same_as]
) {
actor_rules = {
...actor_rules,
...allowed_actors_matrix[actor_rules.same_as],
};
}
if (actor_rules?.all) {
if (debug) {
console.log(
`[RBAC] Actor [${actor_type}] has all access for [${target_user_type}].`,
);
}
return true;
}
if (!actor_rules?.allowed_targets && !actor_rules?.excluded_targets) {
if (debug) {
console.log(
`[RBAC] No actor rules for [${actor_type}] on [${target_user_type}].`,
);
}
return false;
}
if (
actor_rules.allowed_targets &&
!actor_rules.allowed_targets.find((t) =>
target_user_types?.includes(t),
)
) {
if (debug) {
console.log(
`[RBAC] Target user types [${target_user_types?.join(",")}] not included for [${actor_type}] -> [${target_user_type}].`,
);
}
return false;
}
if (
actor_rules.excluded_targets &&
actor_rules.excluded_targets.find((t) =>
target_user_types?.includes(t),
)
) {
if (debug) {
console.log(
`[RBAC] Target user types [${target_user_types?.join(",")}] excluded for [${actor_type}] -> [${target_user_type}].`,
);
}
return false;
}
if (debug) {
console.log(
`[RBAC] Actor [${actor_type}] granted [${action}] of [${target_user_type}].`,
);
}
return true;
});
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, test } from "bun:test";
import { safeCompare } from "./safe-compare";
describe("safeCompare", () => {
test("returns true for equal strings", () => {
expect(safeCompare("abc", "abc")).toBe(true);
expect(safeCompare("", "")).toBe(true);
});
test("returns false for different strings", () => {
expect(safeCompare("abc", "abd")).toBe(false);
expect(safeCompare("abc", "abc ")).toBe(false);
expect(safeCompare("abc", "")).toBe(false);
});
test("is length-independent", () => {
expect(safeCompare("a", "b")).toBe(false);
expect(safeCompare("short", "longer-than-short")).toBe(false);
});
});
+7
View File
@@ -0,0 +1,7 @@
import { createHash, timingSafeEqual } from "node:crypto";
export function safeCompare(a: string, b: string): boolean {
const ah = createHash("sha256").update(a).digest();
const bh = createHash("sha256").update(b).digest();
return timingSafeEqual(ah, bh);
}
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, test } from "bun:test";
import AppError from "./app-error";
import safeErrorMessage from "./safe-error-message";
describe("safeErrorMessage", () => {
test("returns the message of an AppError", () => {
expect(
safeErrorMessage({ error: new AppError("Please try again") }),
).toBe("Please try again");
});
test("hides the message of a plain Error", () => {
expect(
safeErrorMessage({
error: new Error("SQLITE_CONSTRAINT: users.email"),
}),
).toBe("Something went wrong. Please try again.");
});
test("hides non-Error values", () => {
expect(safeErrorMessage({ error: "boom" })).toBe(
"Something went wrong. Please try again.",
);
});
test("supports a custom fallback", () => {
expect(
safeErrorMessage({
error: new Error("internal"),
fallback: "Request failed",
}),
).toBe("Request failed");
});
});
+23
View File
@@ -0,0 +1,23 @@
import AppError from "./app-error";
type Params = {
error: unknown;
fallback?: string;
};
/**
* # Safe Error Message
* Returns the message of an `AppError` (deliberate, user-facing) and a
* generic fallback for everything else, so internal details (SQL errors,
* file paths, stack messages) never reach the client.
*/
export default function safeErrorMessage({
error,
fallback = "Something went wrong. Please try again.",
}: Params): string {
if (error instanceof AppError) {
return error.message;
}
return fallback;
}
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, test } from "bun:test";
import sanitizeRichTextHtml from "./sanitize-rich-text-html";
describe("sanitizeRichTextHtml", () => {
test("returns an empty string for empty input", () => {
expect(sanitizeRichTextHtml({ html: "" })).toBe("");
expect(sanitizeRichTextHtml({ html: null })).toBe("");
expect(sanitizeRichTextHtml({})).toBe("");
});
test("keeps safe formatting", () => {
expect(
sanitizeRichTextHtml({
html: "<p>Hello <strong>world</strong></p>",
}),
).toBe("<p>Hello <strong>world</strong></p>");
});
test("strips script tags and their contents", () => {
const clean = sanitizeRichTextHtml({
html: `<p>Hi</p><script>alert("xss")</script>`,
});
expect(clean).toBe("<p>Hi</p>");
expect(clean).not.toContain("script");
});
test("strips event handler attributes", () => {
const clean = sanitizeRichTextHtml({
html: `<p onclick="alert(1)">Hi</p><img src="x" onerror="alert(1)">`,
});
expect(clean).not.toContain("onclick");
expect(clean).not.toContain("onerror");
});
test("strips unsafe URL schemes, including encoded ones", () => {
const clean = sanitizeRichTextHtml({
html: [
`<a href="javascript:alert(1)">a</a>`,
`<a href="jav&#x61;script:alert(1)">b</a>`,
`<a href="data:text/html,<script>alert(1)</script>">c</a>`,
].join(""),
});
expect(clean).not.toContain("javascript");
expect(clean).not.toContain("data:");
});
test("strips iframes and forms", () => {
const clean = sanitizeRichTextHtml({
html: `<iframe src="https://example.com"></iframe><form><input></form><p>ok</p>`,
});
expect(clean).toBe("<p>ok</p>");
});
test("adds rel=noopener to links", () => {
const clean = sanitizeRichTextHtml({
html: `<a href="https://example.com" target="_blank">x</a>`,
});
expect(clean).toContain(`rel="noopener noreferrer"`);
});
});
+80
View File
@@ -0,0 +1,80 @@
import sanitizeHtml from "sanitize-html";
/**
* Tags allowed in admin-authored rich text (TinyMCE output).
*/
const AllowedTags = [
"p",
"br",
"hr",
"strong",
"b",
"em",
"i",
"u",
"s",
"sub",
"sup",
"span",
"ul",
"ol",
"li",
"blockquote",
"code",
"pre",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"a",
"img",
"table",
"thead",
"tbody",
"tfoot",
"tr",
"th",
"td",
"caption",
"colgroup",
"col",
];
const AllowedAttributes: sanitizeHtml.IOptions["allowedAttributes"] = {
a: ["href", "target", "rel", "title"],
img: ["src", "alt", "title", "width", "height"],
"*": ["class"],
};
type Params = {
html?: string | null;
};
/**
* # Sanitize rich text HTML.
* Strips scripts, event handlers, embedded frames and unsafe URL schemes
* while preserving the formatting TinyMCE produces. Use it on every write
* to an `html: true` column that accepts user input.
*/
export default function sanitizeRichTextHtml({ html }: Params): string {
if (!html) {
return "";
}
return sanitizeHtml(html, {
allowedTags: AllowedTags,
allowedAttributes: AllowedAttributes,
allowedSchemes: ["http", "https", "mailto"],
// TinyMCE can embed pasted images as data URLs.
allowedSchemesByTag: {
img: ["http", "https", "data"],
},
transformTags: {
a: sanitizeHtml.simpleTransform("a", {
rel: "noopener noreferrer",
}),
},
});
}
@@ -0,0 +1,20 @@
import type { ApiReqParams } from "../types";
import type { BUN_SQLITE_BSQLITE_USERS_JOIN } from "../types/sql-joins";
export default function searchableFieldToQueryField({
field,
}: {
field: keyof BUN_SQLITE_BSQLITE_USERS_JOIN;
}): keyof ApiReqParams {
switch (field) {
case "email":
return "search_term_email";
case "last_name":
return "search_term_last_name";
case "user_types":
return "search_term_user_type";
case "first_name":
default:
return "search_term_first_name";
}
}
+20
View File
@@ -0,0 +1,20 @@
import { expect, test } from "bun:test";
import textToSafeHtml from "./text-to-html";
test("textToSafeHtml passes HTML through unchanged", () => {
const html = `<p>Payment may be split: <strong>30% deposit</strong>.</p>`;
expect(textToSafeHtml(html)).toBe(html);
});
test("textToSafeHtml converts legacy plain-text line breaks", () => {
expect(textToSafeHtml("Line one\nLine two")).toBe(
"Line one<br />Line two",
);
expect(textToSafeHtml("A\r\nB\r\nC")).toBe("A<br />B<br />C");
});
test("textToSafeHtml handles empty and plain single-line input", () => {
expect(textToSafeHtml("")).toBe("");
expect(textToSafeHtml("Just a note")).toBe("Just a note");
expect(textToSafeHtml(undefined)).toBe("");
});
+15
View File
@@ -0,0 +1,15 @@
/**
* # Convert a stored text field to an HTML fragment for rendering.
* Documents that switched from plain text to rich HTML (e.g. invoice/quote
* notes) may still hold legacy plain-text rows — those get their line breaks
* converted to `<br />` so they render like they used to.
*/
export default function textToSafeHtml(text?: string): string {
const value = text || "";
if (/<[a-z][^>]*>/i.test(value)) {
return value;
}
return value.replace(/\r?\n/g, "<br />");
}
+3
View File
@@ -0,0 +1,3 @@
export default function uniqueStringArr(arr: string[]): string[] {
return [...new Set(arr)];
}
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, test } from "bun:test";
import { AppData } from "@/src/data/app-data";
import validatePassword from "./validate-password";
describe("validatePassword", () => {
test("rejects an empty password", () => {
expect(validatePassword({ password: "" })).toBeString();
expect(validatePassword({})).toBeString();
});
test("rejects passwords shorter than the minimum", () => {
const password = "a".repeat(AppData["PasswordMinLength"] - 1);
expect(validatePassword({ password })).toContain("at least");
});
test("rejects passwords longer than the maximum", () => {
const password = "a".repeat(AppData["PasswordMaxLength"] + 1);
expect(validatePassword({ password })).toContain("longer than");
});
test("rejects whitespace-only passwords", () => {
const password = " ".repeat(AppData["PasswordMinLength"]);
expect(validatePassword({ password })).toContain("spaces");
});
test("accepts a password at the minimum length", () => {
const password = "a".repeat(AppData["PasswordMinLength"]);
expect(validatePassword({ password })).toBeUndefined();
});
});
+29
View File
@@ -0,0 +1,29 @@
import { AppData } from "@/src/data/app-data";
type Params = {
password?: string;
};
/**
* Returns a user-facing error message, or `undefined` when the
* password is acceptable.
*/
export default function validatePassword({ password }: Params) {
if (!password) {
return `Please enter a password`;
}
if (password.length < AppData["PasswordMinLength"]) {
return `Passwords must be at least ${AppData["PasswordMinLength"]} characters`;
}
if (password.length > AppData["PasswordMaxLength"]) {
return `Passwords can't be longer than ${AppData["PasswordMaxLength"]} characters`;
}
if (!password.trim()) {
return `Passwords can't be only spaces`;
}
return undefined;
}