Add utils package with Web Crypto password hashing
- Create @reviq/utils package with PBKDF2-SHA256 password hashing compatible with Cloudflare Workers (uses crypto.subtle) - Update api-server and CLI to use new utils package for consistent password hashing format across the codebase - Add pino logging to api-server for better request debugging - Make login request tokens cryptographically secure base58 strings instead of database IDs - Add migration to make login_requests.token non-nullable with unique constraint - Fix RPCLink URL construction for client-side API calls - Add db:codegen script to root package.json Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -24,7 +24,11 @@
|
||||
"Bash(bun lint:*)",
|
||||
"Bash(bun lint:fix:*)",
|
||||
"Bash(sg scan:*)",
|
||||
"Bash(bun remove:*)"
|
||||
"Bash(bun remove:*)",
|
||||
"Bash(bun remove:*)",
|
||||
"Bash(biome check:*)",
|
||||
"Skill(igm:check-and-fix)",
|
||||
"Bash(lsof:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,13 +15,16 @@
|
||||
"dependencies": {
|
||||
"@formatjs/intl-durationformat": "^0.9.2",
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@orpc/experimental-pino": "^1.13.2",
|
||||
"@orpc/server": "^1.13.2",
|
||||
"@reviq/api-contract": "workspace:*",
|
||||
"@reviq/db": "workspace:*",
|
||||
"@reviq/db-schema": "workspace:*",
|
||||
"@reviq/utils": "workspace:*",
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@simplewebauthn/types": "^12.0.0",
|
||||
"kysely": "^0.28.2",
|
||||
"pino": "^10.1.0",
|
||||
"postmark": "^4.0.5",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
@@ -34,6 +37,7 @@
|
||||
"@types/zxcvbn": "^4.4.5",
|
||||
"eslint": "catalog:",
|
||||
"pg": "^8.16.3",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { APIContext } from "./context.js";
|
||||
import { LoggingHandlerPlugin } from "@orpc/experimental-pino";
|
||||
import { RPCHandler } from "@orpc/server/fetch";
|
||||
import { createDb } from "@reviq/db";
|
||||
import pino from "pino";
|
||||
import {
|
||||
DEFAULT_PORT,
|
||||
DEFAULT_RP_NAME,
|
||||
@@ -8,8 +10,24 @@ import {
|
||||
} from "./constants.js";
|
||||
import { router } from "./router.js";
|
||||
|
||||
const logger = pino({
|
||||
transport: {
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
colorize: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const db = createDb();
|
||||
const handler = new RPCHandler(router);
|
||||
const handler = new RPCHandler(router, {
|
||||
plugins: [
|
||||
new LoggingHandlerPlugin({
|
||||
logger,
|
||||
logRequestResponse: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const port = Bun.env.PORT ?? DEFAULT_PORT;
|
||||
const allowedOrigins = getAllowedOrigins();
|
||||
@@ -56,4 +74,4 @@ Bun.serve({
|
||||
},
|
||||
});
|
||||
|
||||
console.log("API server running on port", port);
|
||||
logger.info({ port }, "API server running");
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
setCookie,
|
||||
} from "../../utils/cookies.js";
|
||||
import {
|
||||
generateBase58Token,
|
||||
generateDeviceFingerprint,
|
||||
generateExpiry,
|
||||
} from "../../utils/crypto.js";
|
||||
@@ -59,9 +60,9 @@ export const createLoginRequest = os.auth.createLoginRequest.handler(
|
||||
|
||||
// User doesn't exist - return fake response for anti-enumeration
|
||||
if (!user) {
|
||||
// Generate placeholder token (UUID) for anti-enumeration
|
||||
// Generate placeholder token (base58) for anti-enumeration
|
||||
// This prevents attackers from knowing if an email exists based on response
|
||||
const placeholderToken = crypto.randomUUID();
|
||||
const placeholderToken = generateBase58Token();
|
||||
|
||||
// Set placeholder login request token cookie
|
||||
setCookie(
|
||||
@@ -104,14 +105,16 @@ export const createLoginRequest = os.auth.createLoginRequest.handler(
|
||||
const geo = getGeoInfo(context.reqHeaders);
|
||||
const userAgent = getUserAgent(context.reqHeaders);
|
||||
|
||||
// Create login request
|
||||
// Create login request with secure token
|
||||
const expiresAt = generateExpiry(COOKIE_DURATIONS.LOGIN_REQUEST);
|
||||
const token = generateBase58Token();
|
||||
|
||||
const loginRequest = await context.db
|
||||
await context.db
|
||||
.insertInto("login_requests")
|
||||
.values({
|
||||
user_id: userId,
|
||||
email,
|
||||
token,
|
||||
device_fingerprint: deviceFingerprint,
|
||||
ip_address: geo.ip,
|
||||
city: geo.city,
|
||||
@@ -120,16 +123,13 @@ export const createLoginRequest = os.auth.createLoginRequest.handler(
|
||||
user_agent: userAgent,
|
||||
expires_at: expiresAt,
|
||||
})
|
||||
.returning(["id"])
|
||||
.executeTakeFirstOrThrow();
|
||||
.execute();
|
||||
|
||||
const loginRequestId = loginRequest.id;
|
||||
|
||||
// Set login request token cookie with the real login request ID
|
||||
// Set login request token cookie with the secure token
|
||||
setCookie(
|
||||
context.resHeaders,
|
||||
COOKIE_NAMES.LOGIN_REQUEST_TOKEN,
|
||||
loginRequestId,
|
||||
token,
|
||||
COOKIE_OPTIONS.loginRequest,
|
||||
);
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
* Public procedure - no authentication required
|
||||
*
|
||||
* Flow:
|
||||
* 1. Read rev.login_request_token cookie (could be real ID or fake UUID)
|
||||
* 2. If fake token (not found in DB): return { status: 'pending' }
|
||||
* 3. If valid login request ID:
|
||||
* 1. Read rev.login_request_token cookie
|
||||
* 2. If token not found in DB (fake or expired): return { status: 'pending' }
|
||||
* 3. If valid login request:
|
||||
* - Check if expired: return { status: 'expired' }
|
||||
* - Check if not completed: return { status: 'pending' }
|
||||
* - If completed:
|
||||
@@ -31,15 +31,6 @@ import {
|
||||
} from "../../utils/session.js";
|
||||
import { os } from "../base.js";
|
||||
|
||||
/**
|
||||
* Check if a string looks like a UUID (fake token)
|
||||
*/
|
||||
const isUUID = (str: string): boolean => {
|
||||
const uuidRegex =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
return uuidRegex.test(str);
|
||||
};
|
||||
|
||||
/**
|
||||
* Login if request is completed handler
|
||||
* Polls for login completion and creates session when ready
|
||||
@@ -57,21 +48,7 @@ export const loginIfRequestIsCompleted =
|
||||
return { status: "pending" as const };
|
||||
}
|
||||
|
||||
// Check if it's a fake token (UUID)
|
||||
if (isUUID(loginRequestToken)) {
|
||||
// Fake token - user doesn't exist
|
||||
// The cookie will expire naturally after 15 minutes
|
||||
return { status: "pending" as const };
|
||||
}
|
||||
|
||||
// Try to parse as login request ID
|
||||
const loginRequestId = Number.parseInt(loginRequestToken, 10);
|
||||
if (Number.isNaN(loginRequestId)) {
|
||||
// Invalid format - treat as pending
|
||||
return { status: "pending" as const };
|
||||
}
|
||||
|
||||
// Fetch login request from database
|
||||
// Fetch login request from database by token
|
||||
const loginRequest = await context.db
|
||||
.selectFrom("login_requests")
|
||||
.select([
|
||||
@@ -81,7 +58,7 @@ export const loginIfRequestIsCompleted =
|
||||
"completed_at",
|
||||
"expires_at",
|
||||
])
|
||||
.where("id", "=", String(loginRequestId))
|
||||
.where("token", "=", loginRequestToken)
|
||||
.executeTakeFirst();
|
||||
|
||||
// Login request not found - might have been deleted or invalid ID
|
||||
@@ -140,7 +117,7 @@ export const loginIfRequestIsCompleted =
|
||||
// Delete the login request (it's been consumed)
|
||||
await context.db
|
||||
.deleteFrom("login_requests")
|
||||
.where("id", "=", String(loginRequestId))
|
||||
.where("id", "=", loginRequest.id)
|
||||
.execute();
|
||||
|
||||
// Set session cookie
|
||||
|
||||
@@ -11,23 +11,13 @@ import { verifyPassword } from "../../utils/password.js";
|
||||
import { isDeviceTrusted } from "../../utils/session.js";
|
||||
import { os } from "../base.js";
|
||||
|
||||
/**
|
||||
* Check if a string is a valid login request ID (numeric)
|
||||
*/
|
||||
const isValidLoginRequestId = (value: string): boolean => {
|
||||
const num = Number(value);
|
||||
return !Number.isNaN(num) && Number.isInteger(num) && num > 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Login with password handler
|
||||
* - Reads login request token from cookie
|
||||
* - If fake token (UUID): returns generic error for anti-enumeration
|
||||
* - If valid login request ID:
|
||||
* - Validates login request exists and not expired
|
||||
* - Verifies password against stored hash
|
||||
* - If device is trusted: marks login request as completed
|
||||
* - If device is untrusted: generates confirmation token and sends email
|
||||
* - Validates login request exists and not expired
|
||||
* - Verifies password against stored hash
|
||||
* - If device is trusted: marks login request as completed
|
||||
* - If device is untrusted: generates confirmation token and sends email
|
||||
*/
|
||||
export const loginPassword = os.auth.loginPassword.handler(
|
||||
async ({ input, context }) => {
|
||||
@@ -42,25 +32,14 @@ export const loginPassword = os.auth.loginPassword.handler(
|
||||
// Generic error message for anti-enumeration
|
||||
const INVALID_CREDENTIALS_ERROR = "Invalid email or password";
|
||||
|
||||
// No login request token or invalid format
|
||||
// No login request token
|
||||
if (!loginRequestToken) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: INVALID_CREDENTIALS_ERROR,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if token is a fake token (UUID) or valid login request ID
|
||||
if (!isValidLoginRequestId(loginRequestToken)) {
|
||||
// Fake token - return generic error for anti-enumeration
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: INVALID_CREDENTIALS_ERROR,
|
||||
});
|
||||
}
|
||||
|
||||
// Valid login request ID - keep as string (Int8 type)
|
||||
const loginRequestId = loginRequestToken;
|
||||
|
||||
// Fetch login request with user data in single query (optimized JOIN)
|
||||
// Fetch login request with user data by token
|
||||
const result = await context.db
|
||||
.selectFrom("login_requests")
|
||||
.innerJoin("users", "users.id", "login_requests.user_id")
|
||||
@@ -73,7 +52,7 @@ export const loginPassword = os.auth.loginPassword.handler(
|
||||
"login_requests.completed_at",
|
||||
"users.password_hash",
|
||||
])
|
||||
.where("login_requests.id", "=", loginRequestId)
|
||||
.where("login_requests.token", "=", loginRequestToken)
|
||||
.executeTakeFirst();
|
||||
|
||||
// Login request not found
|
||||
@@ -124,7 +103,7 @@ export const loginPassword = os.auth.loginPassword.handler(
|
||||
.set({
|
||||
completed_at: new Date(),
|
||||
})
|
||||
.where("id", "=", loginRequestId)
|
||||
.where("id", "=", result.id)
|
||||
.execute();
|
||||
} else {
|
||||
// Device is untrusted - generate confirmation token and send email
|
||||
@@ -135,10 +114,10 @@ export const loginPassword = os.auth.loginPassword.handler(
|
||||
.set({
|
||||
token: confirmationToken,
|
||||
})
|
||||
.where("id", "=", loginRequestId)
|
||||
.where("id", "=", result.id)
|
||||
.execute();
|
||||
|
||||
// Send login confirmation email (stubbed for now)
|
||||
// Send login confirmation email
|
||||
await sendLoginConfirmationEmail(result.email, confirmationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -149,17 +149,7 @@ export const loginRequestMiddleware = os.middleware(
|
||||
});
|
||||
}
|
||||
|
||||
// Check if token is a valid login request ID (numeric)
|
||||
const num = Number(loginRequestToken);
|
||||
if (Number.isNaN(num) || !Number.isInteger(num) || num <= 0) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Invalid login request",
|
||||
});
|
||||
}
|
||||
|
||||
const loginRequestId = loginRequestToken;
|
||||
|
||||
// Fetch login request with user data
|
||||
// Fetch login request with user data by token
|
||||
const result = await db
|
||||
.selectFrom("login_requests")
|
||||
.innerJoin("users", "users.id", "login_requests.user_id")
|
||||
@@ -172,7 +162,7 @@ export const loginRequestMiddleware = os.middleware(
|
||||
"users.email_verified_at",
|
||||
"users.is_superuser",
|
||||
])
|
||||
.where("login_requests.id", "=", loginRequestId)
|
||||
.where("login_requests.token", "=", loginRequestToken)
|
||||
.where("login_requests.expires_at", ">", new Date())
|
||||
.executeTakeFirst();
|
||||
|
||||
|
||||
@@ -40,6 +40,45 @@ export const generateSecureToken = (): string => {
|
||||
.join("");
|
||||
};
|
||||
|
||||
/**
|
||||
* Base58 alphabet (Bitcoin-style, no 0, O, I, l)
|
||||
*/
|
||||
const BASE58_ALPHABET =
|
||||
"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||
|
||||
/**
|
||||
* Generate a cryptographically secure base58 token
|
||||
* Uses 24 bytes (192 bits) of entropy, producing ~33 character output
|
||||
*/
|
||||
export const generateBase58Token = (byteLength = 24): string => {
|
||||
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 result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate expiration date
|
||||
*/
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
hashPassword as hashPasswordUtil,
|
||||
verifyPassword as verifyPasswordUtil,
|
||||
} from "@reviq/utils";
|
||||
import zxcvbn from "zxcvbn";
|
||||
|
||||
export interface PasswordValidationResult {
|
||||
@@ -41,27 +45,11 @@ export const validatePassword = (
|
||||
};
|
||||
|
||||
/**
|
||||
* Hash a password using Bun's built-in argon2id
|
||||
* @param password - The plaintext password to hash
|
||||
* @returns The hashed password
|
||||
* Hash a password using PBKDF2-SHA256 (Cloudflare Workers compatible)
|
||||
*/
|
||||
export const hashPassword = async (password: string): Promise<string> => {
|
||||
return Bun.password.hash(password, {
|
||||
algorithm: "argon2id",
|
||||
memoryCost: 65536, // 64 MiB
|
||||
timeCost: 3,
|
||||
});
|
||||
};
|
||||
export const hashPassword = hashPasswordUtil;
|
||||
|
||||
/**
|
||||
* Verify a password against a stored hash
|
||||
* @param password - The plaintext password to verify
|
||||
* @param hash - The stored password hash
|
||||
* @returns True if the password matches the hash
|
||||
*/
|
||||
export const verifyPassword = async (
|
||||
password: string,
|
||||
hash: string,
|
||||
): Promise<boolean> => {
|
||||
return Bun.password.verify(password, hash);
|
||||
};
|
||||
export const verifyPassword = verifyPasswordUtil;
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"@stricli/core": "^1.2.5",
|
||||
"@stricli/auto-complete": "^1.0.0",
|
||||
"@reviq/db": "workspace:*",
|
||||
"@reviq/utils": "workspace:*",
|
||||
"@noble/hashes": "^2.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { LocalContext } from "../context.js";
|
||||
import { createDb } from "@reviq/db";
|
||||
import { hashPassword } from "@reviq/utils";
|
||||
import { buildCommand } from "@stricli/core";
|
||||
import { writeConfig } from "../utils/config.js";
|
||||
import { hashPassword } from "../utils/password.js";
|
||||
import { generateToken, hashToken } from "../utils/token.js";
|
||||
|
||||
interface BootstrapFlags {
|
||||
@@ -40,7 +40,7 @@ async function bootstrap(
|
||||
}
|
||||
|
||||
// Hash the password
|
||||
const passwordHash = hashPassword(flags.password);
|
||||
const passwordHash = await hashPassword(flags.password);
|
||||
|
||||
// Create superuser
|
||||
const [user] = await db
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* Password hashing utilities using scrypt from @noble/hashes
|
||||
*/
|
||||
|
||||
import { scrypt as nobleScrypt } from "@noble/hashes/scrypt.js";
|
||||
import { randomBytes } from "@noble/hashes/utils.js";
|
||||
|
||||
// scrypt parameters: N=2^17, r=8, p=1, dkLen=32
|
||||
const N = 131072;
|
||||
const r = 8;
|
||||
const p = 1;
|
||||
const dkLen = 32;
|
||||
|
||||
/**
|
||||
* Hash a password using scrypt
|
||||
* Format: scrypt$17$8$1$<salt-base64>$<hash-base64>
|
||||
*/
|
||||
export const hashPassword = (password: string): string => {
|
||||
const salt = randomBytes(16);
|
||||
const hash = nobleScrypt(password, salt, { N, r, p, dkLen });
|
||||
return `scrypt$17$8$1$${Buffer.from(salt).toString("base64")}$${Buffer.from(hash).toString("base64")}`;
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import { RPCLink } from "@orpc/client/fetch";
|
||||
import { contract } from "@reviq/api-contract";
|
||||
|
||||
const link = new RPCLink({
|
||||
url: "/api/v1/rpc",
|
||||
url: () => `${window.location.origin}/api/v1/rpc`,
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
77
bun.lock
77
bun.lock
@@ -17,13 +17,16 @@
|
||||
"dependencies": {
|
||||
"@formatjs/intl-durationformat": "^0.9.2",
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@orpc/experimental-pino": "^1.13.2",
|
||||
"@orpc/server": "^1.13.2",
|
||||
"@reviq/api-contract": "workspace:*",
|
||||
"@reviq/db": "workspace:*",
|
||||
"@reviq/db-schema": "workspace:*",
|
||||
"@reviq/utils": "workspace:*",
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@simplewebauthn/types": "^12.0.0",
|
||||
"kysely": "^0.28.2",
|
||||
"pino": "^10.1.0",
|
||||
"postmark": "^4.0.5",
|
||||
"zxcvbn": "^4.4.2",
|
||||
},
|
||||
@@ -36,6 +39,7 @@
|
||||
"@types/zxcvbn": "^4.4.5",
|
||||
"eslint": "catalog:",
|
||||
"pg": "^8.16.3",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
},
|
||||
@@ -43,12 +47,13 @@
|
||||
"name": "@reviq/cli",
|
||||
"version": "0.0.0",
|
||||
"bin": {
|
||||
"reviq": "./dist/index.js",
|
||||
"__reviq_bash_complete": "./dist/bash-complete.js",
|
||||
"reviq": "./dist/reviq",
|
||||
"__reviq_bash_complete": "./dist/bash-complete",
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@reviq/db": "workspace:*",
|
||||
"@reviq/utils": "workspace:*",
|
||||
"@stricli/auto-complete": "^1.0.0",
|
||||
"@stricli/core": "^1.2.5",
|
||||
},
|
||||
@@ -166,6 +171,18 @@
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/utils": {
|
||||
"name": "@reviq/utils",
|
||||
"version": "0.0.1",
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20250529.0",
|
||||
"@macalinao/eslint-config": "catalog:",
|
||||
"@macalinao/tsconfig": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
},
|
||||
},
|
||||
"catalog": {
|
||||
"@macalinao/eslint-config": "^7.0.3",
|
||||
@@ -200,6 +217,8 @@
|
||||
|
||||
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.11", "", { "os": "win32", "cpu": "x64" }, "sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg=="],
|
||||
|
||||
"@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260109.0", "", {}, "sha512-90vx2lVm+fhQyE8FKqNhT8JBI8GuY0biAwxTzvzeRIdWVo2ArCpUfYMYq4kzaGTfA6NwCmXmBFSgnqfG6OFxLw=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="],
|
||||
@@ -322,6 +341,8 @@
|
||||
|
||||
"@orpc/contract": ["@orpc/contract@1.13.2", "", { "dependencies": { "@orpc/client": "1.13.2", "@orpc/shared": "1.13.2", "@standard-schema/spec": "^1.1.0", "openapi-types": "^12.1.3" } }, "sha512-td5ExzLPQ2y2zE9itgqod5gmC3WvCFIlkyJSNFRCRhff04PzokE9/hjqGhW5zsNgKuv4rcPYOrkh0e/YiyFndQ=="],
|
||||
|
||||
"@orpc/experimental-pino": ["@orpc/experimental-pino@1.13.2", "", { "dependencies": { "@orpc/client": "1.13.2", "@orpc/server": "1.13.2", "@orpc/shared": "1.13.2" }, "peerDependencies": { "pino": ">=10.1.0" } }, "sha512-BVwiUk6VD5Odi1DIvP2TMx/ay6K0uf+GH2mx6DAMbQ2j146Gr65He5wV+pqRf8Lns5/hke/jkngSICP/WTX8AA=="],
|
||||
|
||||
"@orpc/interop": ["@orpc/interop@1.13.2", "", {}, "sha512-N6AHeREWsXBm0KPShIwwwuPEwGR+slWgyLchXfQYE0HLEQmf2wSZYhoawgaaETl5EoPc6Z7/LUaTqPCE380CzQ=="],
|
||||
|
||||
"@orpc/server": ["@orpc/server@1.13.2", "", { "dependencies": { "@orpc/client": "1.13.2", "@orpc/contract": "1.13.2", "@orpc/interop": "1.13.2", "@orpc/shared": "1.13.2", "@orpc/standard-server": "1.13.2", "@orpc/standard-server-aws-lambda": "1.13.2", "@orpc/standard-server-fastify": "1.13.2", "@orpc/standard-server-fetch": "1.13.2", "@orpc/standard-server-node": "1.13.2", "@orpc/standard-server-peer": "1.13.2", "cookie": "^1.1.1" }, "peerDependencies": { "crossws": ">=0.3.4", "ws": ">=8.18.1" }, "optionalPeers": ["crossws", "ws"] }, "sha512-sHGMT5eQ9eFSfXeRr6F3wkVRm76fUJzDurtCIZe5koStS20e5YZOkg7FH5kZb57c0d/EqvqPAbdHD8WRVl4qLQ=="],
|
||||
@@ -364,6 +385,8 @@
|
||||
|
||||
"@peculiar/x509": ["@peculiar/x509@1.14.2", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-csr": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.0", "@peculiar/asn1-pkcs9": "^2.6.0", "@peculiar/asn1-rsa": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "pvtsutils": "^1.3.6", "reflect-metadata": "^0.2.2", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-r2w1Hg6pODDs0zfAKHkSS5HLkOLSeburtcgwvlLLWWCixw+MmW3U6kD5ddyvc2Y2YdbGuVwCF2S2ASoU1cFAag=="],
|
||||
|
||||
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
|
||||
|
||||
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
|
||||
|
||||
"@reviq/api-contract": ["@reviq/api-contract@workspace:packages/api-contract"],
|
||||
@@ -376,6 +399,8 @@
|
||||
|
||||
"@reviq/db-schema": ["@reviq/db-schema@workspace:packages/db-schema"],
|
||||
|
||||
"@reviq/utils": ["@reviq/utils@workspace:packages/utils"],
|
||||
|
||||
"@reviq/virtual-authenticator": ["@reviq/virtual-authenticator@workspace:packages/testing/virtual-authenticator"],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.55.1", "", { "os": "android", "cpu": "arm" }, "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg=="],
|
||||
@@ -542,6 +567,8 @@
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
|
||||
|
||||
"axios": ["axios@1.13.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA=="],
|
||||
|
||||
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
||||
@@ -570,6 +597,8 @@
|
||||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
|
||||
|
||||
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||
|
||||
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||
@@ -580,6 +609,8 @@
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
|
||||
@@ -606,6 +637,8 @@
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="],
|
||||
|
||||
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||
@@ -648,12 +681,16 @@
|
||||
|
||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"fast-copy": ["fast-copy@4.0.2", "", {}, "sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||
|
||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||
|
||||
"fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
||||
@@ -700,6 +737,8 @@
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="],
|
||||
|
||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||
@@ -732,6 +771,8 @@
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
@@ -818,6 +859,8 @@
|
||||
|
||||
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||
|
||||
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
|
||||
@@ -860,6 +903,14 @@
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"pino": ["pino@10.1.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w=="],
|
||||
|
||||
"pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="],
|
||||
|
||||
"pino-pretty": ["pino-pretty@13.1.3", "", { "dependencies": { "colorette": "^2.0.7", "dateformat": "^4.6.3", "fast-copy": "^4.0.0", "fast-safe-stringify": "^2.1.1", "help-me": "^5.0.0", "joycon": "^3.1.1", "minimist": "^1.2.6", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pump": "^3.0.0", "secure-json-parse": "^4.0.0", "sonic-boom": "^4.0.1", "strip-json-comments": "^5.0.2" }, "bin": { "pino-pretty": "bin.js" } }, "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg=="],
|
||||
|
||||
"pino-std-serializers": ["pino-std-serializers@7.0.0", "", {}, "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA=="],
|
||||
|
||||
"pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
@@ -876,18 +927,26 @@
|
||||
|
||||
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||
|
||||
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
||||
|
||||
"pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="],
|
||||
|
||||
"pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="],
|
||||
|
||||
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
|
||||
|
||||
"radash": ["radash@12.1.1", "", {}, "sha512-h36JMxKRqrAxVD8201FrCpyeNuUY9Y5zZwujr20fFO77tpUtGa6EZzfKw/3WaiBX95fq7+MpsuMLNdSnORAwSA=="],
|
||||
|
||||
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
|
||||
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
|
||||
|
||||
"rechoir": ["rechoir@0.6.2", "", { "dependencies": { "resolve": "^1.1.6" } }, "sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw=="],
|
||||
|
||||
"reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="],
|
||||
@@ -902,6 +961,10 @@
|
||||
|
||||
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
|
||||
|
||||
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
|
||||
|
||||
"secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="],
|
||||
|
||||
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
|
||||
@@ -916,11 +979,13 @@
|
||||
|
||||
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
|
||||
|
||||
"sonic-boom": ["sonic-boom@4.2.0", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
||||
|
||||
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
|
||||
"strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="],
|
||||
|
||||
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
|
||||
|
||||
@@ -948,6 +1013,8 @@
|
||||
|
||||
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||
|
||||
"thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||
@@ -1016,6 +1083,8 @@
|
||||
|
||||
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
|
||||
|
||||
"@eslint/eslintrc/strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
|
||||
|
||||
"@orpc/server/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||
@@ -1042,6 +1111,8 @@
|
||||
|
||||
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"pino/pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="],
|
||||
|
||||
"svelte-sonner/runed": ["runed@0.28.0", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ=="],
|
||||
|
||||
"tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
|
||||
|
||||
17
db/migrations/002_login_request_token_required.sql
Normal file
17
db/migrations/002_login_request_token_required.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- migrate:up
|
||||
-- First, delete any existing login requests (they're temporary auth state anyway)
|
||||
DELETE FROM login_requests;
|
||||
|
||||
-- Make token column required and add unique constraint
|
||||
ALTER TABLE login_requests
|
||||
ALTER COLUMN token SET NOT NULL,
|
||||
ADD CONSTRAINT login_requests_token_unique UNIQUE (token);
|
||||
|
||||
-- Create index for token lookups
|
||||
CREATE INDEX idx_login_requests_token ON login_requests(token);
|
||||
|
||||
-- migrate:down
|
||||
DROP INDEX IF EXISTS idx_login_requests_token;
|
||||
ALTER TABLE login_requests
|
||||
DROP CONSTRAINT IF EXISTS login_requests_token_unique,
|
||||
ALTER COLUMN token DROP NOT NULL;
|
||||
@@ -1,4 +1,4 @@
|
||||
\restrict Trg340CgUaHnQsqUDFepZ6WnV8O2lwkEMfhS9CGxBAJbWOA8qTnig08shTgrMcE
|
||||
\restrict NwR9NcSOK9D25dGgvUNdLvsNphDACAXsvkQ5NSmhpf6sLcFR570yQ96lhgCbCXf
|
||||
|
||||
-- Dumped from database version 17.7
|
||||
-- Dumped by pg_dump version 17.7
|
||||
@@ -114,7 +114,7 @@ CREATE TABLE public.login_requests (
|
||||
id bigint NOT NULL,
|
||||
user_id integer NOT NULL,
|
||||
email text NOT NULL,
|
||||
token text,
|
||||
token text NOT NULL,
|
||||
device_fingerprint text,
|
||||
ip_address text,
|
||||
city text,
|
||||
@@ -652,6 +652,14 @@ ALTER TABLE ONLY public.login_requests
|
||||
ADD CONSTRAINT login_requests_token_key UNIQUE (token);
|
||||
|
||||
|
||||
--
|
||||
-- Name: login_requests login_requests_token_unique; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.login_requests
|
||||
ADD CONSTRAINT login_requests_token_unique UNIQUE (token);
|
||||
|
||||
|
||||
--
|
||||
-- Name: org_invites org_invites_org_id_email_key; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
@@ -856,6 +864,13 @@ CREATE INDEX idx_email_verifications_expires ON public.email_verifications USING
|
||||
CREATE INDEX idx_login_requests_expires ON public.login_requests USING btree (expires_at);
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_login_requests_token; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE INDEX idx_login_requests_token ON public.login_requests USING btree (token);
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_login_requests_user; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
@@ -1069,7 +1084,7 @@ ALTER TABLE ONLY public.user_devices
|
||||
-- PostgreSQL database dump complete
|
||||
--
|
||||
|
||||
\unrestrict Trg340CgUaHnQsqUDFepZ6WnV8O2lwkEMfhS9CGxBAJbWOA8qTnig08shTgrMcE
|
||||
\unrestrict NwR9NcSOK9D25dGgvUNdLvsNphDACAXsvkQ5NSmhpf6sLcFR570yQ96lhgCbCXf
|
||||
|
||||
|
||||
--
|
||||
@@ -1077,4 +1092,5 @@ ALTER TABLE ONLY public.user_devices
|
||||
--
|
||||
|
||||
INSERT INTO public.schema_migrations (version) VALUES
|
||||
('001');
|
||||
('001'),
|
||||
('002');
|
||||
|
||||
@@ -36,8 +36,10 @@
|
||||
"api-server".exec = "bun run --cwd apps/api-server dev";
|
||||
};
|
||||
|
||||
env.DATABASE_URL = "postgres://reviq:reviq@localhost/reviq-dashboard";
|
||||
env.TEST_DATABASE_URL = "postgres://reviq:reviq@localhost/reviq-dashboard_test";
|
||||
env = {
|
||||
DATABASE_URL = "postgres://reviq:reviq@localhost/reviq-dashboard?sslmode=disable";
|
||||
TEST_DATABASE_URL = "postgres://reviq:reviq@localhost/reviq-dashboard_test?sslmode=disable";
|
||||
};
|
||||
|
||||
scripts = {
|
||||
"db-up".exec = "dbmate up";
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
"lint": "biome check && turbo run lint",
|
||||
"lint:fix": "biome check --write --unsafe && turbo run lint -- --fix",
|
||||
"typecheck": "turbo typecheck",
|
||||
"clean": "turbo clean"
|
||||
"clean": "turbo clean",
|
||||
"db:codegen": "bun run --cwd packages/db-schema generate"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.11",
|
||||
|
||||
@@ -10,7 +10,11 @@ export type Generated<T> =
|
||||
? ColumnType<S, I | undefined, U>
|
||||
: ColumnType<T, T | undefined, T>;
|
||||
|
||||
export type Int8 = ColumnType<string, bigint | number | string>;
|
||||
export type Int8 = ColumnType<
|
||||
string,
|
||||
bigint | number | string,
|
||||
bigint | number | string
|
||||
>;
|
||||
|
||||
export type Json = JsonValue;
|
||||
|
||||
@@ -28,7 +32,7 @@ export type OrgRole = "admin" | "member" | "owner";
|
||||
|
||||
export type PasskeyDeviceType = "multiDevice" | "singleDevice";
|
||||
|
||||
export type Timestamp = ColumnType<Date, Date | string>;
|
||||
export type Timestamp = ColumnType<Date, Date | string, Date | string>;
|
||||
|
||||
export interface ApiTokens {
|
||||
created_at: Generated<Timestamp>;
|
||||
@@ -59,7 +63,7 @@ export interface LoginRequests {
|
||||
id: Generated<Int8>;
|
||||
ip_address: string | null;
|
||||
region: string | null;
|
||||
token: string | null;
|
||||
token: string;
|
||||
user_agent: string | null;
|
||||
user_id: number;
|
||||
}
|
||||
|
||||
15
packages/utils/eslint.config.js
Normal file
15
packages/utils/eslint.config.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { configs } from "@macalinao/eslint-config";
|
||||
|
||||
export default [
|
||||
...configs.fast,
|
||||
{
|
||||
ignores: ["**/*.test.ts"],
|
||||
},
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
27
packages/utils/package.json
Normal file
27
packages/utils/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@reviq/utils",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
|
||||
"lint": "eslint . --cache",
|
||||
"test": "bun test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20250529.0",
|
||||
"@macalinao/eslint-config": "catalog:",
|
||||
"@macalinao/tsconfig": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
101
packages/utils/src/hash-password.test.ts
Normal file
101
packages/utils/src/hash-password.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { hashPassword, verifyPassword } from "./hash-password.js";
|
||||
|
||||
describe("hashPassword", () => {
|
||||
it("should generate a hash in the correct format", async () => {
|
||||
const password = "testPassword123!";
|
||||
const hash = await hashPassword(password);
|
||||
|
||||
// Format: $pbkdf2-sha256$iterations$salt$hash
|
||||
const parts = hash.split("$");
|
||||
expect(parts.length).toBe(5);
|
||||
expect(parts[0]).toBe("");
|
||||
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);
|
||||
});
|
||||
|
||||
it("should generate different hashes for the same password", async () => {
|
||||
const password = "testPassword123!";
|
||||
const hash1 = await hashPassword(password);
|
||||
const hash2 = await hashPassword(password);
|
||||
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
|
||||
it("should generate different hashes for different passwords", async () => {
|
||||
const hash1 = await hashPassword("password1");
|
||||
const hash2 = await hashPassword("password2");
|
||||
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyPassword", () => {
|
||||
it("should verify correct password", async () => {
|
||||
const password = "correctPassword!";
|
||||
const hash = await hashPassword(password);
|
||||
|
||||
const result = await verifyPassword(password, hash);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject incorrect password", async () => {
|
||||
const password = "correctPassword!";
|
||||
const hash = await hashPassword(password);
|
||||
|
||||
const result = await verifyPassword("wrongPassword!", hash);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject invalid hash format", async () => {
|
||||
const result = await verifyPassword("password", "invalid-hash");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject hash with wrong algorithm", async () => {
|
||||
const result = await verifyPassword(
|
||||
"password",
|
||||
"$bcrypt$100000$c2FsdA==$aGFzaA==",
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject hash with invalid iterations", async () => {
|
||||
const result = await verifyPassword(
|
||||
"password",
|
||||
"$pbkdf2-sha256$invalid$c2FsdA==$aGFzaA==",
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject hash with zero iterations", async () => {
|
||||
const result = await verifyPassword(
|
||||
"password",
|
||||
"$pbkdf2-sha256$0$c2FsdA==$aGFzaA==",
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle empty password", async () => {
|
||||
const hash = await hashPassword("");
|
||||
const result = await verifyPassword("", hash);
|
||||
expect(result).toBe(true);
|
||||
|
||||
const wrongResult = await verifyPassword("notempty", hash);
|
||||
expect(wrongResult).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle unicode passwords", async () => {
|
||||
const password = "pässwörd🔐";
|
||||
const hash = await hashPassword(password);
|
||||
|
||||
const result = await verifyPassword(password, hash);
|
||||
expect(result).toBe(true);
|
||||
|
||||
const wrongResult = await verifyPassword("password", hash);
|
||||
expect(wrongResult).toBe(false);
|
||||
});
|
||||
});
|
||||
130
packages/utils/src/hash-password.ts
Normal file
130
packages/utils/src/hash-password.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Hash a password using PBKDF2 with SHA-256 (Web Crypto API compatible)
|
||||
*
|
||||
* Format: $pbkdf2-sha256$iterations$salt$hash
|
||||
* - iterations: number of PBKDF2 iterations
|
||||
* - salt: base64-encoded 16-byte random salt
|
||||
* - hash: base64-encoded 32-byte derived key
|
||||
*/
|
||||
|
||||
const ITERATIONS = 100000;
|
||||
const SALT_LENGTH = 16;
|
||||
const KEY_LENGTH = 32;
|
||||
const ALGORITHM = "pbkdf2-sha256";
|
||||
|
||||
const toBase64 = (buffer: ArrayBuffer): string => {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = "";
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return btoa(binary);
|
||||
};
|
||||
|
||||
const fromBase64 = (base64: string): Uint8Array => {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
|
||||
export const hashPassword = async (password: string): Promise<string> => {
|
||||
const encoder = new TextEncoder();
|
||||
const passwordBuffer = encoder.encode(password);
|
||||
|
||||
// Generate random salt
|
||||
const salt = new Uint8Array(SALT_LENGTH);
|
||||
crypto.getRandomValues(salt);
|
||||
|
||||
// Import password as key
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
passwordBuffer,
|
||||
"PBKDF2",
|
||||
false,
|
||||
["deriveBits"],
|
||||
);
|
||||
|
||||
// Derive key using PBKDF2
|
||||
const derivedBits = await crypto.subtle.deriveBits(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
salt,
|
||||
iterations: ITERATIONS,
|
||||
hash: "SHA-256",
|
||||
},
|
||||
keyMaterial,
|
||||
KEY_LENGTH * 8,
|
||||
);
|
||||
|
||||
// Format: $algorithm$iterations$salt$hash
|
||||
const saltB64 = toBase64(salt.buffer);
|
||||
const hashB64 = toBase64(derivedBits);
|
||||
|
||||
return `$${ALGORITHM}$${String(ITERATIONS)}$${saltB64}$${hashB64}`;
|
||||
};
|
||||
|
||||
export const verifyPassword = async (
|
||||
password: string,
|
||||
storedHash: string,
|
||||
): Promise<boolean> => {
|
||||
// Parse stored hash
|
||||
const parts = storedHash.split("$");
|
||||
if (parts.length !== 5 || parts[0] !== "" || parts[1] !== ALGORITHM) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const iterationsStr = parts[2];
|
||||
const saltStr = parts[3];
|
||||
const hashStr = parts[4];
|
||||
if (!(iterationsStr && saltStr && hashStr)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const iterations = Number.parseInt(iterationsStr, 10);
|
||||
const salt = fromBase64(saltStr);
|
||||
const expectedHash = fromBase64(hashStr);
|
||||
|
||||
if (Number.isNaN(iterations) || iterations <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const passwordBuffer = encoder.encode(password);
|
||||
|
||||
// Import password as key
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
passwordBuffer,
|
||||
"PBKDF2",
|
||||
false,
|
||||
["deriveBits"],
|
||||
);
|
||||
|
||||
// Derive key using PBKDF2 with same parameters
|
||||
const derivedBits = await crypto.subtle.deriveBits(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
salt,
|
||||
iterations,
|
||||
hash: "SHA-256",
|
||||
},
|
||||
keyMaterial,
|
||||
expectedHash.length * 8,
|
||||
);
|
||||
|
||||
// Constant-time comparison
|
||||
const derivedArray = new Uint8Array(derivedBits);
|
||||
if (derivedArray.length !== expectedHash.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let result = 0;
|
||||
for (let i = 0; i < derivedArray.length; i++) {
|
||||
result |= (derivedArray[i] ?? 0) ^ (expectedHash[i] ?? 0);
|
||||
}
|
||||
|
||||
return result === 0;
|
||||
};
|
||||
1
packages/utils/src/index.ts
Normal file
1
packages/utils/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { hashPassword, verifyPassword } from "./hash-password.js";
|
||||
7
packages/utils/tsconfig.json
Normal file
7
packages/utils/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "@macalinao/tsconfig/tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["@cloudflare/workers-types"]
|
||||
},
|
||||
"exclude": ["**/*.test.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user