diff --git a/.claude/settings.json b/.claude/settings.json index ec30c88..8ab4694 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -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:*)" ] } } diff --git a/apps/api-server/package.json b/apps/api-server/package.json index 0f258e4..543032a 100644 --- a/apps/api-server/package.json +++ b/apps/api-server/package.json @@ -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:" } } diff --git a/apps/api-server/src/index.ts b/apps/api-server/src/index.ts index a8c456f..ac39ec2 100644 --- a/apps/api-server/src/index.ts +++ b/apps/api-server/src/index.ts @@ -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"); diff --git a/apps/api-server/src/procedures/auth/create-login-request.ts b/apps/api-server/src/procedures/auth/create-login-request.ts index ee79c03..4cc8237 100644 --- a/apps/api-server/src/procedures/auth/create-login-request.ts +++ b/apps/api-server/src/procedures/auth/create-login-request.ts @@ -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, ); diff --git a/apps/api-server/src/procedures/auth/login-if-completed.ts b/apps/api-server/src/procedures/auth/login-if-completed.ts index 499c83d..513429b 100644 --- a/apps/api-server/src/procedures/auth/login-if-completed.ts +++ b/apps/api-server/src/procedures/auth/login-if-completed.ts @@ -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 diff --git a/apps/api-server/src/procedures/auth/login-password.ts b/apps/api-server/src/procedures/auth/login-password.ts index 53dd811..dcd2296 100644 --- a/apps/api-server/src/procedures/auth/login-password.ts +++ b/apps/api-server/src/procedures/auth/login-password.ts @@ -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); } diff --git a/apps/api-server/src/procedures/base.ts b/apps/api-server/src/procedures/base.ts index f8527df..e4948a3 100644 --- a/apps/api-server/src/procedures/base.ts +++ b/apps/api-server/src/procedures/base.ts @@ -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(); diff --git a/apps/api-server/src/utils/crypto.ts b/apps/api-server/src/utils/crypto.ts index 3528a83..cbff2e0 100644 --- a/apps/api-server/src/utils/crypto.ts +++ b/apps/api-server/src/utils/crypto.ts @@ -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 */ diff --git a/apps/api-server/src/utils/password.ts b/apps/api-server/src/utils/password.ts index 015a0e1..0fcc358 100644 --- a/apps/api-server/src/utils/password.ts +++ b/apps/api-server/src/utils/password.ts @@ -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 => { - 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 => { - return Bun.password.verify(password, hash); -}; +export const verifyPassword = verifyPasswordUtil; diff --git a/apps/cli/package.json b/apps/cli/package.json index 927e358..1a1ac51 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -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": { diff --git a/apps/cli/src/routes/bootstrap.ts b/apps/cli/src/routes/bootstrap.ts index b3f6456..83ec0ce 100644 --- a/apps/cli/src/routes/bootstrap.ts +++ b/apps/cli/src/routes/bootstrap.ts @@ -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 diff --git a/apps/cli/src/utils/password.ts b/apps/cli/src/utils/password.ts deleted file mode 100644 index f13c2d7..0000000 --- a/apps/cli/src/utils/password.ts +++ /dev/null @@ -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$$ - */ -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")}`; -}; diff --git a/apps/publisher-dashboard/src/lib/api/client.ts b/apps/publisher-dashboard/src/lib/api/client.ts index 1814244..337c8b5 100644 --- a/apps/publisher-dashboard/src/lib/api/client.ts +++ b/apps/publisher-dashboard/src/lib/api/client.ts @@ -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`, }); /** diff --git a/bun.lock b/bun.lock index 4b784c3..ecd69e5 100644 --- a/bun.lock +++ b/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=="], diff --git a/db/migrations/002_login_request_token_required.sql b/db/migrations/002_login_request_token_required.sql new file mode 100644 index 0000000..11fbb21 --- /dev/null +++ b/db/migrations/002_login_request_token_required.sql @@ -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; diff --git a/db/schema.sql b/db/schema.sql index 385b5c7..90c915d 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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'); diff --git a/devenv.nix b/devenv.nix index 4cf6489..ffbe666 100644 --- a/devenv.nix +++ b/devenv.nix @@ -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"; diff --git a/package.json b/package.json index 38abaac..c2f32e2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/db-schema/src/types.ts b/packages/db-schema/src/types.ts index b74781a..c84b926 100644 --- a/packages/db-schema/src/types.ts +++ b/packages/db-schema/src/types.ts @@ -10,7 +10,11 @@ export type Generated = ? ColumnType : ColumnType; -export type Int8 = ColumnType; +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; +export type Timestamp = ColumnType; export interface ApiTokens { created_at: Generated; @@ -59,7 +63,7 @@ export interface LoginRequests { id: Generated; ip_address: string | null; region: string | null; - token: string | null; + token: string; user_agent: string | null; user_id: number; } diff --git a/packages/utils/eslint.config.js b/packages/utils/eslint.config.js new file mode 100644 index 0000000..6ae3806 --- /dev/null +++ b/packages/utils/eslint.config.js @@ -0,0 +1,15 @@ +import { configs } from "@macalinao/eslint-config"; + +export default [ + ...configs.fast, + { + ignores: ["**/*.test.ts"], + }, + { + languageOptions: { + parserOptions: { + tsconfigRootDir: import.meta.dirname, + }, + }, + }, +]; diff --git a/packages/utils/package.json b/packages/utils/package.json new file mode 100644 index 0000000..00ef177 --- /dev/null +++ b/packages/utils/package.json @@ -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:" + } +} diff --git a/packages/utils/src/hash-password.test.ts b/packages/utils/src/hash-password.test.ts new file mode 100644 index 0000000..3bd87f5 --- /dev/null +++ b/packages/utils/src/hash-password.test.ts @@ -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); + }); +}); diff --git a/packages/utils/src/hash-password.ts b/packages/utils/src/hash-password.ts new file mode 100644 index 0000000..6cc26ba --- /dev/null +++ b/packages/utils/src/hash-password.ts @@ -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 => { + 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 => { + // 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; +}; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts new file mode 100644 index 0000000..e07c313 --- /dev/null +++ b/packages/utils/src/index.ts @@ -0,0 +1 @@ +export { hashPassword, verifyPassword } from "./hash-password.js"; diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json new file mode 100644 index 0000000..fe0b870 --- /dev/null +++ b/packages/utils/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@macalinao/tsconfig/tsconfig.base.json", + "compilerOptions": { + "types": ["@cloudflare/workers-types"] + }, + "exclude": ["**/*.test.ts"] +}