Merge branch 'master' into testing-improvements

This commit is contained in:
RevIQ
2026-01-10 15:31:37 +08:00
49 changed files with 2487 additions and 287 deletions

View File

@@ -26,6 +26,8 @@ export interface BootstrapInput {
tokenName?: string;
/** Optional token expiration in days (defaults to 365) */
tokenExpirationDays?: number;
/** Delete existing user and org if they exist (defaults to false) */
dangerouslyOverwriteExisting?: boolean;
}
/**
@@ -70,6 +72,7 @@ export const executeBootstrap = async (
orgDisplayName = "RevIQ",
tokenName = "CLI bootstrap token",
tokenExpirationDays = 365,
dangerouslyOverwriteExisting = false,
} = input;
// Validate password length
@@ -84,15 +87,93 @@ export const executeBootstrap = async (
const normalizedEmail = email.toLowerCase();
// Check if user already exists
const existing = await trx
.selectFrom("users")
.where("email", "=", normalizedEmail)
.select("id")
.executeTakeFirst();
// Handle overwrite mode - delete existing user and org
if (dangerouslyOverwriteExisting) {
// Delete existing user and related records
const existingUser = await trx
.selectFrom("users")
.where("email", "=", normalizedEmail)
.select("id")
.executeTakeFirst();
if (existing) {
throw new Error(`User with email ${email} already exists`);
if (existingUser) {
// Delete all user-related records (FK constraints)
await trx
.deleteFrom("api_tokens")
.where("user_id", "=", existingUser.id)
.execute();
await trx
.deleteFrom("email_verifications")
.where("user_id", "=", existingUser.id)
.execute();
await trx
.deleteFrom("login_requests")
.where("user_id", "=", existingUser.id)
.execute();
await trx
.deleteFrom("passkeys")
.where("user_id", "=", existingUser.id)
.execute();
await trx
.deleteFrom("password_resets")
.where("user_id", "=", existingUser.id)
.execute();
await trx
.deleteFrom("sessions")
.where("user_id", "=", existingUser.id)
.execute();
await trx
.deleteFrom("user_devices")
.where("user_id", "=", existingUser.id)
.execute();
await trx
.deleteFrom("org_members")
.where("user_id", "=", existingUser.id)
.execute();
// Delete invites created by this user
await trx
.deleteFrom("org_invites")
.where("invited_by", "=", existingUser.id)
.execute();
// Delete the user
await trx.deleteFrom("users").where("id", "=", existingUser.id).execute();
}
// Delete existing org and related records
const existingOrg = await trx
.selectFrom("orgs")
.where("slug", "=", orgSlug)
.select("id")
.executeTakeFirst();
if (existingOrg) {
// Delete all org-related records (FK constraints)
await trx
.deleteFrom("org_invites")
.where("org_id", "=", existingOrg.id)
.execute();
await trx
.deleteFrom("org_members")
.where("org_id", "=", existingOrg.id)
.execute();
await trx
.deleteFrom("org_sites")
.where("org_id", "=", existingOrg.id)
.execute();
// Delete the org
await trx.deleteFrom("orgs").where("id", "=", existingOrg.id).execute();
}
} else {
// Check if user already exists (normal mode)
const existing = await trx
.selectFrom("users")
.where("email", "=", normalizedEmail)
.select("id")
.executeTakeFirst();
if (existing) {
throw new Error(`User with email ${email} already exists`);
}
}
// Hash the password

View File

@@ -0,0 +1,105 @@
import { describe, expect, it } from "bun:test";
import {
generateSecureBase58Token,
isBase58,
parseBase58Token,
} from "./generate-base58-token.js";
const BASE58_ALPHABET =
"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
describe("isBase58", () => {
it("should return true for valid base58 strings", () => {
expect(isBase58("123456789")).toBe(true);
expect(isBase58("ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz")).toBe(
true,
);
expect(isBase58("9JKmn")).toBe(true);
});
it("should return false for strings with invalid characters", () => {
// 0, O, I, l are not in base58 alphabet
expect(isBase58("0")).toBe(false);
expect(isBase58("O")).toBe(false);
expect(isBase58("I")).toBe(false);
expect(isBase58("l")).toBe(false);
expect(isBase58("abc0def")).toBe(false);
});
it("should return false for empty strings", () => {
expect(isBase58("")).toBe(false);
});
it("should return false for strings with special characters", () => {
expect(isBase58("abc+def")).toBe(false);
expect(isBase58("abc/def")).toBe(false);
expect(isBase58("abc=def")).toBe(false);
});
});
describe("generateSecureBase58Token", () => {
it("should generate a valid base58 token", () => {
const token = generateSecureBase58Token();
expect(isBase58(token)).toBe(true);
});
it("should generate tokens of expected length (~33 chars for 24 bytes)", () => {
const token = generateSecureBase58Token();
// 24 bytes = 192 bits, base58 encoding gives roughly 33 chars
expect(token.length).toBeGreaterThanOrEqual(30);
expect(token.length).toBeLessThanOrEqual(35);
});
it("should generate unique tokens", () => {
const tokens = new Set<string>();
for (let i = 0; i < 100; i++) {
tokens.add(generateSecureBase58Token());
}
expect(tokens.size).toBe(100);
});
it("should only use base58 alphabet characters", () => {
for (let i = 0; i < 50; i++) {
const token = generateSecureBase58Token();
for (const char of token) {
expect(BASE58_ALPHABET.includes(char)).toBe(true);
}
}
});
it("should prepend prefix when provided", () => {
const token = generateSecureBase58Token("lrt_");
expect(token.startsWith("lrt_")).toBe(true);
// The part after prefix should be valid base58
expect(isBase58(token.slice(4))).toBe(true);
});
});
describe("parseBase58Token", () => {
it("should parse token with correct prefix", () => {
const token = generateSecureBase58Token("lrt_");
const parsed = parseBase58Token(token, "lrt_");
expect(parsed).not.toBeNull();
if (parsed !== null) {
expect(isBase58(parsed)).toBe(true);
}
});
it("should return null for wrong prefix", () => {
const token = generateSecureBase58Token("lrt_");
const parsed = parseBase58Token(token, "wrong_");
expect(parsed).toBeNull();
});
it("should return null for missing prefix", () => {
const token = generateSecureBase58Token();
const parsed = parseBase58Token(token, "lrt_");
expect(parsed).toBeNull();
});
it("should return null for invalid base58 after prefix", () => {
const invalidToken = "lrt_abc0def"; // 0 is not valid base58
const parsed = parseBase58Token(invalidToken, "lrt_");
expect(parsed).toBeNull();
});
});

View File

@@ -0,0 +1,75 @@
/**
* Generate cryptographically secure base58 tokens
* Uses Bitcoin-style base58 alphabet (no 0, O, I, l to avoid confusion)
*/
const BASE58_ALPHABET =
"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
/**
* Check if a string is valid base58
*/
export const isBase58 = (str: string): boolean => {
for (const char of str) {
if (!BASE58_ALPHABET.includes(char)) {
return false;
}
}
return str.length > 0;
};
/**
* Generate a cryptographically secure base58 token
* Uses 24 bytes (192 bits) of entropy, producing ~33 character output
*
* @param prefix - Optional prefix to prepend (e.g., "login_" for login request tokens)
*/
export function generateSecureBase58Token<TPrefix extends string = "">(
prefix?: TPrefix,
): `${TPrefix}${string}` {
const byteLength = 24;
const bytes = new Uint8Array(byteLength);
crypto.getRandomValues(bytes);
// Convert bytes to base58
let result = "";
let num = BigInt(0);
for (const byte of bytes) {
num = num * 256n + BigInt(byte);
}
while (num > 0n) {
const remainder = Number(num % 58n);
result = BASE58_ALPHABET.charAt(remainder) + result;
num /= 58n;
}
// Handle leading zeros
for (const byte of bytes) {
if (byte === 0) {
result = BASE58_ALPHABET.charAt(0) + result;
} else {
break;
}
}
return `${prefix ?? ""}${result}` as `${TPrefix}${string}`;
}
/**
* Parse a token with an expected prefix
* Returns the token without prefix if valid, null if invalid
*/
export const parseBase58Token = (
token: string,
expectedPrefix: string,
): string | null => {
if (!token.startsWith(expectedPrefix)) {
return null;
}
const base58Part = token.slice(expectedPrefix.length);
if (!isBase58(base58Part)) {
return null;
}
return base58Part;
};

View File

@@ -13,8 +13,8 @@ describe("hashPassword", () => {
expect(parts[1]).toBe("pbkdf2-sha256");
expect(parts[2]).toBe("100000");
// Salt and hash should be non-empty base64 strings
expect(parts[3].length).toBeGreaterThan(0);
expect(parts[4].length).toBeGreaterThan(0);
expect(parts[3]?.length).toBeGreaterThan(0);
expect(parts[4]?.length).toBeGreaterThan(0);
});
it("should generate different hashes for the same password", async () => {
@@ -98,4 +98,66 @@ describe("verifyPassword", () => {
const wrongResult = await verifyPassword("password", hash);
expect(wrongResult).toBe(false);
});
it("should verify known hash for 'password'", async () => {
// This hash was generated for the password "password"
const storedHash =
"$pbkdf2-sha256$100000$iUaDbbVm+Mf0HG7RcCCOzw==$IQfBN4chRU3wqCEoC9XOusIVYkyW24dbJd/ksm0VBJk=";
const password = "password";
const result = await verifyPassword(password, storedHash);
expect(result).toBe(true);
});
it("should derive consistent hash for known salt and password", async () => {
// Manually verify the derivation produces the expected hash
const password = "password";
const saltB64 = "iUaDbbVm+Mf0HG7RcCCOzw==";
const expectedHashB64 = "IQfBN4chRU3wqCEoC9XOusIVYkyW24dbJd/ksm0VBJk=";
const iterations = 100000;
// Decode salt
const saltBinary = atob(saltB64);
const salt = new Uint8Array(saltBinary.length);
for (let i = 0; i < saltBinary.length; i++) {
salt[i] = saltBinary.charCodeAt(i);
}
// Derive key
const encoder = new TextEncoder();
const passwordBuffer = encoder.encode(password);
const keyMaterial = await crypto.subtle.importKey(
"raw",
passwordBuffer,
"PBKDF2",
false,
["deriveBits"],
);
const derivedBits = await crypto.subtle.deriveBits(
{
name: "PBKDF2",
salt,
iterations,
hash: "SHA-256",
},
keyMaterial,
32 * 8,
);
// Encode result to base64
const derivedArray = new Uint8Array(derivedBits);
let binary = "";
for (const byte of derivedArray) {
binary += String.fromCharCode(byte);
}
const derivedB64 = btoa(binary);
console.log("Expected hash:", expectedHashB64);
console.log("Derived hash: ", derivedB64);
console.log("Match:", derivedB64 === expectedHashB64);
expect(derivedB64).toBe(expectedHashB64);
});
});

View File

@@ -107,6 +107,7 @@ export const verifyPassword = async (
const derivedBits = await crypto.subtle.deriveBits(
{
name: "PBKDF2",
// @ts-expect-error - salt is a Uint8Array
salt,
iterations,
hash: "SHA-256",

View File

@@ -1 +1,6 @@
export {
generateSecureBase58Token,
isBase58,
parseBase58Token,
} from "./generate-base58-token.js";
export { hashPassword, verifyPassword } from "./hash-password.js";

View File

@@ -1,7 +1,6 @@
{
"extends": "@macalinao/tsconfig/tsconfig.base.json",
"compilerOptions": {
"types": ["@cloudflare/workers-types"]
},
"exclude": ["**/*.test.ts"]
"types": ["bun"]
}
}