From 1372f4e975f6122441490977bb749004274996bb Mon Sep 17 00:00:00 2001 From: Kenneth Obsequio Date: Tue, 30 Jun 2026 19:31:45 +0800 Subject: [PATCH] testing 101 Signed-off-by: Kenneth Obsequio --- .env.example | 19 +- LICENSE | 2 +- config/database.cjs | 2 +- config/garage.toml | 17 + controllers/admin/courses.controller.js | 79 +- controllers/admin/media.controller.js | 23 +- controllers/admin/plan_prices.controller.js | 139 + controllers/admin/tier_policies.controller.js | 112 +- controllers/admin/tiers.controller.js | 45 +- .../client/course_purchases.controller.js | 15 +- controllers/client/courses.controller.js | 32 +- controllers/client/media.controller.js | 5 + controllers/client/profile.controller.js | 17 + .../client/task_download.controller.js | 2 +- controllers/client/task_upload.controller.js | 2 +- controllers/client/tiers.controller.js | 413 +- cron/client.cron.js | 3 + cron/jobs/expire_user_tiers.cron.js | 95 + data/notifications.data.js | 17 +- ...101000066-add-description-to-tier-plans.js | 15 + .../20260101000067-create-payment-policies.js | 47 + ...1000068-add-duration-unit-to-tier-plans.js | 23 + ...1000069-add-preferred-currency-to-users.js | 15 + .../20260101000070-create-plan-prices.js | 34 + ...260101000071-create-course-achievements.js | 25 + docker-compose.yml | 30 + middleware/originGuard.middleware.js | 90 +- middleware/rateLimiter.middleware.js | 3 +- models/courses/course_achievement.mdl.js | 16 + models/courses/courses.associations.js | 6 +- models/courses/courses.mdl.js | 5 +- models/tiers/payment_policies.mdl.js | 36 + models/tiers/plan_prices.mdl.js | 26 + models/tiers/tier.associations.js | 6 + models/tiers/tier_plans.attributes.js | 3 +- models/tiers/tier_plans.mdl.js | 6 +- models/users/users.mdl.js | 3 + package-lock.json | 3837 ++++++++++++++++- package.json | 6 + providers/paypal.provider.js | 72 + providers/registry.js | 14 + routes/admin/admin.routes.js | 4 +- routes/admin/courses.routes.js | 7 + routes/admin/tier_policies.routes.js | 10 +- routes/admin/tiers.routes.js | 15 +- routes/client/client.routes.js | 1 + routes/client/courses.routes.js | 3 + routes/client/tiers.routes.js | 17 +- scripts/garage-init.sh | 31 + services/email.service.js | 13 +- services/payment.service.js | 120 + services/s3.service.js | 8 +- tests/middleware/originGuard.test.js | 258 ++ tests/middleware/rbac.test.js | 203 + tests/services/health.test.js | 235 + tests/utils/accessPolicy.test.js | 143 + tests/utils/datetime.test.js | 74 + tests/utils/otp.test.js | 83 + tests/utils/response.test.js | 92 + tests/utils/token.test.js | 128 + utils/courses/archive.util.js | 4 +- utils/currency.util.js | 171 + 62 files changed, 6696 insertions(+), 281 deletions(-) create mode 100644 config/garage.toml create mode 100644 controllers/admin/plan_prices.controller.js create mode 100644 cron/jobs/expire_user_tiers.cron.js create mode 100644 database/migrations/20260101000066-add-description-to-tier-plans.js create mode 100644 database/migrations/20260101000067-create-payment-policies.js create mode 100644 database/migrations/20260101000068-add-duration-unit-to-tier-plans.js create mode 100644 database/migrations/20260101000069-add-preferred-currency-to-users.js create mode 100644 database/migrations/20260101000070-create-plan-prices.js create mode 100644 database/migrations/20260101000071-create-course-achievements.js create mode 100644 models/courses/course_achievement.mdl.js create mode 100644 models/tiers/payment_policies.mdl.js create mode 100644 models/tiers/plan_prices.mdl.js create mode 100644 providers/paypal.provider.js create mode 100644 providers/registry.js create mode 100755 scripts/garage-init.sh create mode 100644 services/payment.service.js create mode 100644 tests/middleware/originGuard.test.js create mode 100644 tests/middleware/rbac.test.js create mode 100644 tests/services/health.test.js create mode 100644 tests/utils/accessPolicy.test.js create mode 100644 tests/utils/datetime.test.js create mode 100644 tests/utils/otp.test.js create mode 100644 tests/utils/response.test.js create mode 100644 tests/utils/token.test.js create mode 100644 utils/currency.util.js diff --git a/.env.example b/.env.example index 3fd83f3..48b5373 100644 --- a/.env.example +++ b/.env.example @@ -92,19 +92,26 @@ EMAIL_FROM=CHANGE_ME@gmail.com OTP_EXPIRY_MINUTES=10 # ── S3-compatible Storage (Garage self-hosted) ──────────────────────────────── -# S3_ENDPOINT: internal address of your Garage node (e.g. http://127.0.0.1:3900 -# if Garage runs on the same server, or http://:3900) -# S3_PUBLIC_URL: the public-facing base URL served by your reverse proxy -# (e.g. https://cdn.yourdomain.com) -S3_ENDPOINT=http://127.0.0.1:3900 +# Garage is bundled in docker-compose.yml — no separate install needed. +# +# Docker setup: S3_ENDPOINT=http://garage:3900 (use the service name) +# Bare-metal: S3_ENDPOINT=http://127.0.0.1:3900 +# +# S3_PUBLIC_URL: public-facing URL served by your reverse proxy +# (e.g. Caddy/Nginx → https://cdn.yourdomain.com → garage:3900) +# +# GARAGE_RPC_SECRET: shared secret for Garage RPC. +# Generate with: openssl rand -hex 32 +S3_ENDPOINT=http://garage:3900 S3_REGION=garage S3_ACCESS_KEY=CHANGE_ME S3_SECRET_KEY=CHANGE_ME S3_BUCKET=CHANGE_ME S3_PUBLIC_URL=https://cdn.yourdomain.com +GARAGE_RPC_SECRET=CHANGE_ME_32_BYTE_HEX # ── Chibisafe (optional — used alongside S3 for some asset types) ───────────── -# Replace the zrok tunnel URL with a stable domain pointing to your Chibisafe instance. +# Set CHIBISAFE_BASE_URL to the public domain pointing to your Chibisafe instance. CHIBISAFE_BASE_URL=https://files.yourdomain.com CHIBISAFE_API_KEY=CHANGE_ME CHIBISAFE_ALBUM_AVATARS=CHANGE_ME_UUID diff --git a/LICENSE b/LICENSE index 261eeb9..c4662f9 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2025 [Kenneth Obsequio and Russell Obsequio] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/config/database.cjs b/config/database.cjs index f633378..37570d9 100644 --- a/config/database.cjs +++ b/config/database.cjs @@ -8,7 +8,7 @@ const base = { password: process.env.DB_PASSWORD, database: process.env.DB_NAME, host: process.env.DB_HOST, - port: parseInt(process.env.DB_PORT, 10) || 26257, + port: parseInt(process.env.DB_PORT, 10) || 5432, dialect: 'postgres', dialectOptions: { ...(useSSL && { diff --git a/config/garage.toml b/config/garage.toml new file mode 100644 index 0000000..4d9fe8e --- /dev/null +++ b/config/garage.toml @@ -0,0 +1,17 @@ +metadata_dir = "/var/lib/garage/meta" +data_dir = "/var/lib/garage/data" + +# Single-node setup. Increase replication_factor if you add more Garage nodes. +replication_factor = 1 + +# RPC — used for node-to-node and CLI-to-daemon communication. +# rpc_secret is read from GARAGE_RPC_SECRET env var (set in .env). +rpc_bind_addr = "0.0.0.0:3901" +rpc_public_addr = "garage:3901" + +[s3_api] +s3_region = "garage" +api_bind_addr = "0.0.0.0:3900" + +[admin] +api_bind_addr = "0.0.0.0:3903" diff --git a/controllers/admin/courses.controller.js b/controllers/admin/courses.controller.js index 23fb66f..7fabed1 100644 --- a/controllers/admin/courses.controller.js +++ b/controllers/admin/courses.controller.js @@ -23,7 +23,7 @@ const { CoursePrerequisite, CourseAssessment, UnitQuiz, QuizQuestion, QuizOption, QuizAttempt, AssessmentSession, - CourseInstructor, + CourseInstructor, CourseAchievement, } = require("../../models/courses/courses.associations"); const mdl_Users = require("../../models/users/users.mdl"); @@ -100,6 +100,8 @@ exports.createCourse = async (req, res) => { course_code, level, subscription, objectives = [], category_ids = [], + achievement_keys = [], + badge_color, badge_asset_id, badge_image_url, createdBy, } = req.body; @@ -107,18 +109,28 @@ exports.createCourse = async (req, res) => { const course = await Course.create({ title, - description: description ?? null, - order_index: order_index ?? 0, - course_code: course_code ?? null, - level: level ?? null, - subscription: subscription ?? "free", + description: description ?? null, + order_index: order_index ?? 0, + course_code: course_code ?? null, + level: level ?? null, + subscription: subscription ?? "free", duration_seconds: 0, - createdBy: createdBy ?? null, + badge_color: badge_color ?? "purple", + badge_asset_id: badge_asset_id ?? null, + badge_image_url: badge_image_url ?? null, + createdBy: createdBy ?? null, }, { transaction: t }); await syncObjectivesCreate(CourseObjective, "course_id", course.course_id, objectives, t); await syncJunction(CourseProductCat, course.course_id, category_ids, "category_id", t); + if (achievement_keys.length) { + await CourseAchievement.bulkCreate( + achievement_keys.slice(0, 3).map((key, i) => ({ course_id: course.course_id, achievement_key: key, order_index: i })), + { transaction: t }, + ); + } + await t.commit(); logActivity(req.user?.user_id, 'create_course', { entityType: 'course', entityId: course.course_id, details: { title: course.title } }); return R.success(res, "Course created.", { data: course }, 201); @@ -140,6 +152,7 @@ exports.updateCourse = async (req, res) => { title, description, order_index, course_code, level, subscription, objectives, category_ids, + badge_color, badge_asset_id, badge_image_url, updatedBy, } = req.body; @@ -149,6 +162,9 @@ exports.updateCourse = async (req, res) => { if (course_code !== undefined) course.course_code = course_code; if (level !== undefined) course.level = level; if (subscription !== undefined) course.subscription = subscription; + if (badge_color !== undefined) course.badge_color = badge_color; + if (badge_asset_id !== undefined) course.badge_asset_id = badge_asset_id; + if (badge_image_url !== undefined) course.badge_image_url = badge_image_url; course.updatedBy = updatedBy ?? null; await course.save({ transaction: t }); @@ -1760,3 +1776,52 @@ exports.getAssessmentSessions = async (req, res) => { return R.error(res, "Could not retrieve assessment sessions.", 500); } }; + +// ══════════════════════════════════════════════════════════════════════════════ +// COURSE ACHIEVEMENTS +// ══════════════════════════════════════════════════════════════════════════════ + +exports.getCourseAchievements = async (req, res) => { + try { + const { courseId } = req.params; + const rows = await CourseAchievement.findAll({ + where: { course_id: courseId }, + order: [["order_index", "ASC"]], + }); + return R.success(res, "Course achievements retrieved.", { data: rows }); + } catch (err) { + console.error("[COURSE][ACHIEVEMENTS][GET]", err); + return R.error(res, "Could not retrieve course achievements.", 500); + } +}; + +exports.syncCourseAchievements = async (req, res) => { + const t = await sequelize.transaction(); + try { + const { courseId } = req.params; + const { achievement_keys = [] } = req.body; + + if (achievement_keys.length > 3) + return R.error(res, "Maximum 3 achievements allowed per course.", 400); + + await CourseAchievement.destroy({ where: { course_id: courseId }, transaction: t }); + + if (achievement_keys.length) { + await CourseAchievement.bulkCreate( + achievement_keys.map((key, i) => ({ course_id: courseId, achievement_key: key, order_index: i })), + { transaction: t }, + ); + } + + await t.commit(); + const rows = await CourseAchievement.findAll({ + where: { course_id: courseId }, + order: [["order_index", "ASC"]], + }); + return R.success(res, "Course achievements updated.", { data: rows }); + } catch (err) { + await t.rollback(); + console.error("[COURSE][ACHIEVEMENTS][SYNC]", err); + return R.error(res, "Could not update course achievements.", 500); + } +}; diff --git a/controllers/admin/media.controller.js b/controllers/admin/media.controller.js index d9f688e..8c042a5 100644 --- a/controllers/admin/media.controller.js +++ b/controllers/admin/media.controller.js @@ -113,16 +113,31 @@ exports.issueTokensBatch = async (req, res) => { storage_provider: "s3", deletedAt: null, }, - attributes: ["asset_id", "file_type", "storage_key", "mime_type"], + attributes: ["asset_id", "file_type", "storage_key", "mime_type", "thumbnail_storage_key"], }); - const ip = resolveIp(req); - const tokens = {}; + const ip = resolveIp(req); + const tokens = {}; + const thumbnails = {}; + for (const asset of assets) { tokens[String(asset.asset_id)] = signToken(asset, req.user.user_id, ip); + + // For image/video assets with a thumbnail — presign it so the browser can + // load it directly from Garage without going through the stream proxy. + if (asset.thumbnail_storage_key) { + try { + thumbnails[String(asset.asset_id)] = await s3.getSignedDownloadUrl( + asset.thumbnail_storage_key, + TOKEN_TTL_SEC, + ); + } catch { + // Non-fatal — stream token is the fallback + } + } } - return R.success(res, "Tokens issued.", { tokens }); + return R.success(res, "Tokens issued.", { tokens, thumbnails }); } catch (err) { console.error("[ADMIN][MEDIA][TOKENS BATCH]", err); return R.error(res, "Could not issue media tokens.", 500); diff --git a/controllers/admin/plan_prices.controller.js b/controllers/admin/plan_prices.controller.js new file mode 100644 index 0000000..095e693 --- /dev/null +++ b/controllers/admin/plan_prices.controller.js @@ -0,0 +1,139 @@ +/*********************************************************************************************************************************************************************** + * File Name: plan_prices.controller.js (admin) + * Type of Program: Controller + * Description: Admin CRUD for localized price overrides per tier plan. + * Routes: GET/POST/PUT/DELETE /admin/tiers/:id/prices[/:currency] + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jun. 29, 2026 + ***********************************************************************************************************************************************************************/ +'use strict'; + +const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl'); +const mdl_PlanPrices = require('../../models/tiers/plan_prices.mdl'); +const R = require('../../utils/response.util'); +const logActivity = require('../../utils/logActivity.util'); +const { isSupported, SUPPORTED_CURRENCIES, validateLocalizedPrice } = require('../../utils/currency.util'); + +// ─── GET /admin/tiers/:id/prices ───────────────────────────────────────────── + +exports.getPrices = async (req, res) => { + try { + const plan = await mdl_TierPlans.findByPk(req.params.id); + if (!plan) return R.error(res, 'Plan not found.', 404); + + const prices = await mdl_PlanPrices.findAll({ + where: { plan_id: plan.plan_id }, + order: [['currency', 'ASC']], + }); + + return R.success(res, 'Localized prices retrieved.', prices); + } catch (err) { + console.error('[ADMIN][GET PLAN PRICES]', err); + return R.error(res, 'Could not retrieve localized prices.', 500); + } +}; + +// ─── POST /admin/tiers/:id/prices ──────────────────────────────────────────── + +exports.addPrice = async (req, res) => { + try { + const plan = await mdl_TierPlans.findByPk(req.params.id); + if (!plan) return R.error(res, 'Plan not found.', 404); + + const { currency, price } = req.body; + if (!currency || price === undefined) return R.error(res, 'currency and price are required.', 400); + if (!isSupported(currency)) return R.error(res, `Unsupported currency: ${currency}.`, 400); + if (currency === plan.currency) return R.error(res, `${currency} is already the plan's base currency.`, 400); + if (Number(price) < 0) return R.error(res, 'Price must be 0 or greater.', 400); + + const exists = await mdl_PlanPrices.findOne({ where: { plan_id: plan.plan_id, currency } }); + if (exists) return R.error(res, `A localized price for ${currency} already exists. Use PUT to update it.`, 409); + + // ── Rate validation ──────────────────────────────────────────────────────── + const validation = await validateLocalizedPrice(plan.price, plan.currency, price, currency); + if (validation.zone === 'block') return R.error(res, validation.message, 422); + + const entry = await mdl_PlanPrices.create({ + plan_id: plan.plan_id, + currency: currency.toUpperCase(), + price: Number(price), + }); + + logActivity(req.user?.user_id, 'add_plan_price', { + entityType: 'plan_price', + details: { plan_id: plan.plan_id, currency, price }, + }); + + if (validation.zone === 'warn') + return res.status(201).json({ success: true, warning: true, message: validation.message, data: entry }); + + return R.success(res, 'Localized price added.', entry, 201); + } catch (err) { + console.error('[ADMIN][ADD PLAN PRICE]', err); + return R.error(res, 'Could not add localized price.', 500); + } +}; + +// ─── PUT /admin/tiers/:id/prices/:currency ─────────────────────────────────── + +exports.updatePrice = async (req, res) => { + try { + const { id, currency } = req.params; + const { price } = req.body; + + if (price === undefined) return R.error(res, 'price is required.', 400); + if (Number(price) < 0) return R.error(res, 'Price must be 0 or greater.', 400); + + const entry = await mdl_PlanPrices.findOne({ where: { plan_id: id, currency: currency.toUpperCase() } }); + if (!entry) return R.error(res, `No localized price found for ${currency} on this plan.`, 404); + + // ── Rate validation ──────────────────────────────────────────────────────── + const plan = await mdl_TierPlans.findByPk(id); + const validation = await validateLocalizedPrice(plan.price, plan.currency, price, currency); + if (validation.zone === 'block') return R.error(res, validation.message, 422); + + await entry.update({ price: Number(price) }); + + logActivity(req.user?.user_id, 'update_plan_price', { + entityType: 'plan_price', + details: { plan_id: id, currency, price }, + }); + + if (validation.zone === 'warn') + return res.status(200).json({ success: true, warning: true, message: validation.message, data: entry }); + + return R.success(res, 'Localized price updated.', entry); + } catch (err) { + console.error('[ADMIN][UPDATE PLAN PRICE]', err); + return R.error(res, 'Could not update localized price.', 500); + } +}; + +// ─── DELETE /admin/tiers/:id/prices/:currency ──────────────────────────────── + +exports.removePrice = async (req, res) => { + try { + const { id, currency } = req.params; + + const entry = await mdl_PlanPrices.findOne({ where: { plan_id: id, currency: currency.toUpperCase() } }); + if (!entry) return R.error(res, `No localized price found for ${currency} on this plan.`, 404); + + await entry.destroy(); + + logActivity(req.user?.user_id, 'remove_plan_price', { + entityType: 'plan_price', + details: { plan_id: id, currency }, + }); + + return R.success(res, 'Localized price removed.'); + } catch (err) { + console.error('[ADMIN][REMOVE PLAN PRICE]', err); + return R.error(res, 'Could not remove localized price.', 500); + } +}; + +// ─── GET /admin/currencies ──────────────────────────────────────────────────── + +exports.getCurrencies = async (_req, res) => { + return R.success(res, 'Supported currencies retrieved.', SUPPORTED_CURRENCIES); +}; diff --git a/controllers/admin/tier_policies.controller.js b/controllers/admin/tier_policies.controller.js index 9f049d1..2b33f60 100644 --- a/controllers/admin/tier_policies.controller.js +++ b/controllers/admin/tier_policies.controller.js @@ -1,12 +1,13 @@ 'use strict'; -const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl'); -const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl'); -const mdl_PlanPolicies = require('../../models/tiers/plan_policies.mdl'); -const mdl_SystemBadges = require('../../models/system_badges/system_badges.mdl'); -const Asset = require('../../models/assets/assets.mdl'); -const R = require('../../utils/response.util'); -const logActivity = require('../../utils/logActivity.util'); +const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl'); +const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl'); +const mdl_PlanPolicies = require('../../models/tiers/plan_policies.mdl'); +const mdl_PaymentPolicies = require('../../models/tiers/payment_policies.mdl'); +const mdl_SystemBadges = require('../../models/system_badges/system_badges.mdl'); +const Asset = require('../../models/assets/assets.mdl'); +const R = require('../../utils/response.util'); +const logActivity = require('../../utils/logActivity.util'); require('../../models/tiers/tier.associations'); @@ -104,6 +105,103 @@ exports.upsertPlanPolicy = async (req, res) => { } }; +// ─── PAYMENT POLICIES ───────────────────────────────────────────────────────── + +const VALID_PROMO_TYPES = new Set(['flat', 'percent']); +const VALID_WINDOW_UNITS = new Set(['minutes', 'hours', 'days']); + +function validatePromoRules(rules) { + if (!Array.isArray(rules)) return 'promo_rules must be an array.'; + for (const r of rules) { + if (!r.code || typeof r.code !== 'string') return 'Each promo rule must have a code string.'; + if (!VALID_PROMO_TYPES.has(r.type)) return `Invalid promo type "${r.type}". Must be 'flat' or 'percent'.`; + if (!r.value || Number(r.value) <= 0) return 'Promo rule value must be a positive number.'; + if (r.max_uses != null && (!Number.isInteger(r.max_uses) || r.max_uses < 1)) + return 'max_uses must be a positive integer.'; + if (r.expires_at != null && isNaN(new Date(r.expires_at).getTime())) + return 'expires_at must be a valid ISO date string.'; + if (r.min_amount != null && Number(r.min_amount) < 0) + return 'min_amount must be a non-negative number.'; + } + return null; +} + +function validateRefundPolicy(rp) { + if (typeof rp !== 'object' || rp === null || Array.isArray(rp)) + return 'refund_policy must be an object.'; + if (rp.allowed != null && typeof rp.allowed !== 'boolean') + return 'refund_policy.allowed must be a boolean.'; + if (rp.window_unit != null && !VALID_WINDOW_UNITS.has(rp.window_unit)) + return `refund_policy.window_unit must be 'minutes', 'hours', or 'days'.`; + if (rp.window_value != null && (typeof rp.window_value !== 'number' || rp.window_value <= 0)) + return 'refund_policy.window_value must be a positive number.'; + return null; +} + +exports.getPaymentPolicy = async (req, res) => { + try { + const plan = await mdl_TierPlans.findByPk(req.params.planId); + if (!plan) return R.error(res, 'Plan not found.', 404); + + const policy = await mdl_PaymentPolicies.findOne({ where: { plan_id: req.params.planId } }); + return R.success(res, 'Payment policy retrieved.', policy ?? null); + } catch (err) { + console.error('[ADMIN][GET PAYMENT POLICY]', err); + return R.error(res, 'Could not retrieve payment policy.', 500); + } +}; + +exports.upsertPaymentPolicy = async (req, res) => { + try { + const { planId } = req.params; + + const plan = await mdl_TierPlans.findByPk(planId); + if (!plan) return R.error(res, 'Plan not found.', 404); + + const { promo_rules, refund_policy, allowed_providers } = req.body; + + if (promo_rules !== undefined) { + const err = validatePromoRules(promo_rules); + if (err) return R.error(res, err, 400); + } + + if (refund_policy !== undefined) { + const err = validateRefundPolicy(refund_policy); + if (err) return R.error(res, err, 400); + } + + if (allowed_providers !== undefined) { + if (!Array.isArray(allowed_providers) || !allowed_providers.every((p) => typeof p === 'string')) + return R.error(res, 'allowed_providers must be an array of provider name strings.', 400); + } + + let existing = await mdl_PaymentPolicies.findOne({ where: { plan_id: planId } }); + + const DEFAULTS = { allowed: true, window_value: 5, window_unit: 'minutes', reason_required: false }; + + const payload = { + plan_id: planId, + promo_rules: promo_rules !== undefined ? promo_rules : (existing?.promo_rules ?? []), + refund_policy: refund_policy !== undefined ? refund_policy : (existing?.refund_policy ?? DEFAULTS), + allowed_providers: allowed_providers !== undefined ? allowed_providers : (existing?.allowed_providers ?? ['paypal']), + }; + + if (!existing) { + existing = await mdl_PaymentPolicies.create(payload); + logActivity(req.user?.user_id, 'create_payment_policy', { entityType: 'payment_policy', details: { plan_id: planId } }); + } else { + await existing.update(payload); + logActivity(req.user?.user_id, 'update_payment_policy', { entityType: 'payment_policy', details: { plan_id: planId } }); + } + + const result = await mdl_PaymentPolicies.findOne({ where: { plan_id: planId } }); + return R.success(res, 'Payment policy saved.', result); + } catch (err) { + console.error('[ADMIN][UPSERT PAYMENT POLICY]', err); + return R.error(res, 'Could not save payment policy.', 500); + } +}; + // ─── SYSTEM BADGES ──────────────────────────────────────────────────────────── exports.getSystemBadges = async (req, res) => { diff --git a/controllers/admin/tiers.controller.js b/controllers/admin/tiers.controller.js index 3409e91..d983525 100644 --- a/controllers/admin/tiers.controller.js +++ b/controllers/admin/tiers.controller.js @@ -83,11 +83,18 @@ exports.getPlan = async (req, res) => { } }; +const DURATION_UNIT_TO_DAYS = { minute: 1 / 1440, hour: 1 / 24, day: 1, month: 30, year: 365 }; + +function computeDurationDays(value, unit) { + const multiplier = DURATION_UNIT_TO_DAYS[unit] ?? 1; + return parseFloat(value) * multiplier; +} + exports.createPlan = async (req, res) => { try { - const { tier_category_id, label, duration_days, price, currency } = req.body; - if (!tier_category_id || !label || !duration_days || !price) - return R.error(res, 'tier_category_id, label, duration_days, and price are required.', 400); + const { tier_category_id, label, description, duration_value, duration_unit = 'day', price, currency } = req.body; + if (!tier_category_id || !label || !duration_value || !price) + return R.error(res, 'tier_category_id, label, duration_value, and price are required.', 400); const category = await mdl_TierCategories.findByPk(tier_category_id); if (!category || !category.is_active) @@ -96,10 +103,12 @@ exports.createPlan = async (req, res) => { if (category.is_default) return R.error(res, 'Plans cannot be created under the default (Free) tier. Free access is automatic.', 400); + const duration_days = computeDurationDays(duration_value, duration_unit); + const plan = await mdl_TierPlans.create({ tier_category_id: category.tier_category_id, tier: category.slug, - label, duration_days, price, currency, + label, description, duration_days, duration_unit, price, currency, }); const plain = plan.get({ plain: true }); logActivity(req.user?.user_id, 'create_tier_plan', { entityType: 'tier_plan', details: { label: plain.label, tier: plain.tier } }); @@ -115,12 +124,22 @@ exports.updatePlan = async (req, res) => { const plan = await mdl_TierPlans.findByPk(req.params.id); if (!plan) return R.error(res, 'Plan not found.', 404); - const allowed = ['label', 'duration_days', 'price', 'currency', 'is_active', 'tier_category_id']; + const allowed = ['label', 'description', 'price', 'currency', 'is_active', 'tier_category_id']; const updates = {}; for (const k of allowed) { if (req.body[k] !== undefined) updates[k] = req.body[k]; } + // Recompute duration_days when value or unit changes + const { duration_value, duration_unit } = req.body; + if (duration_value !== undefined) { + const unit = duration_unit ?? plan.duration_unit ?? 'day'; + updates.duration_days = computeDurationDays(duration_value, unit); + updates.duration_unit = unit; + } else if (duration_unit !== undefined) { + updates.duration_unit = duration_unit; + } + // If tier_category_id is being changed, sync the tier slug if (updates.tier_category_id) { const category = await mdl_TierCategories.findByPk(updates.tier_category_id); @@ -140,6 +159,22 @@ exports.updatePlan = async (req, res) => { } }; +exports.getPlanImpact = async (req, res) => { + try { + const plan = await mdl_TierPlans.findByPk(req.params.id); + if (!plan) return R.error(res, 'Plan not found.', 404); + + const active_subscriber_count = await mdl_UserTiers.count({ + where: { plan_id: req.params.id, status: 'active' }, + }); + + return R.success(res, 'Plan impact retrieved.', { active_subscriber_count }); + } catch (err) { + console.error('[ADMIN][GET PLAN IMPACT]', err); + return R.error(res, 'Could not retrieve plan impact.', 500); + } +}; + exports.archivePlan = async (req, res) => { try { const plan = await mdl_TierPlans.findByPk(req.params.id); diff --git a/controllers/client/course_purchases.controller.js b/controllers/client/course_purchases.controller.js index 68edf73..e9e6352 100644 --- a/controllers/client/course_purchases.controller.js +++ b/controllers/client/course_purchases.controller.js @@ -1,7 +1,7 @@ 'use strict'; const mdl_CoursePurchase = require('../../models/courses/course_purchases.mdl'); const mdl_Product = require('../../models/courses/products.mdl'); -const paypal = require('../../services/paypal.service'); +const paymentSvc = require('../../services/payment.service'); const R = require('../../utils/response.util'); // ─── CREATE ORDER ───────────────────────────────────────────────────────────── @@ -14,7 +14,6 @@ exports.createCourseOrder = async (req, res) => { const product = await mdl_Product.findOne({ where: { id: product_id, is_active: true } }); if (!product) return R.error(res, 'Product not found or inactive.', 404); - // Block if user already has an active completed purchase for this product const existing = await mdl_CoursePurchase.findOne({ where: { user_id: req.user.user_id, product_id, status: 'completed' }, }); @@ -23,7 +22,7 @@ exports.createCourseOrder = async (req, res) => { if (stillActive) return R.error(res, 'You already have active access to this course.', 409); } - const ppOrder = await paypal.createOrder({ + const ppOrder = await paymentSvc.createOrder('paypal', { amount: Number(product.price).toFixed(2), currency: product.currency, referenceId: `user_${req.user.user_id}_product_${product_id}`, @@ -69,7 +68,7 @@ exports.captureCourseOrder = async (req, res) => { if (!order_id) return R.error(res, 'order_id is required.', 400); const purchase = await mdl_CoursePurchase.findOne({ - where: { user_id: req.user.user_id, status: 'pending', provider: 'paypal' }, + where: { user_id: req.user.user_id, status: 'pending' }, include: [{ model: mdl_Product, as: 'product' }], order: [['createdAt', 'DESC']], }); @@ -79,13 +78,13 @@ exports.captureCourseOrder = async (req, res) => { let captureData; try { - captureData = await paypal.captureOrder(order_id); + captureData = await paymentSvc.captureOrder(purchase.provider, order_id); } catch (ppErr) { await purchase.update({ - status: 'failed', + status: 'failed', provider_payload: { ...purchase.provider_payload, error: ppErr?.response?.data ?? {} }, }); - return R.error(res, 'PayPal capture failed.', 402); + return R.error(res, 'Payment capture failed.', 402); } const capture = captureData.purchase_units?.[0]?.payments?.captures?.[0]; @@ -120,7 +119,7 @@ exports.cancelCourseOrder = async (req, res) => { if (!order_id) return R.error(res, 'order_id is required.', 400); const purchase = await mdl_CoursePurchase.findOne({ - where: { user_id: req.user.user_id, status: 'pending', provider: 'paypal' }, + where: { user_id: req.user.user_id, status: 'pending' }, order: [['createdAt', 'DESC']], }); diff --git a/controllers/client/courses.controller.js b/controllers/client/courses.controller.js index 2804851..ad6b90f 100644 --- a/controllers/client/courses.controller.js +++ b/controllers/client/courses.controller.js @@ -124,6 +124,7 @@ const COURSE_LIST_ATTRS = [ "course_id", "uuid", "title", "description", "course_code", "level", "subscription", "duration_seconds", "order_index", + "badge_color", "badge_asset_id", "badge_image_url", ]; // Strip correct-answer data before sending quiz questions to the client. @@ -149,6 +150,22 @@ async function getActiveTier(user_id) { }); } +// ─── COURSE CATEGORIES (public list for filter chips) ───────────────────────── + +exports.getCategories = async (req, res) => { + try { + const rows = await mdl_Category.findAll({ + where: { is_active: true }, + attributes: ['id', 'name', 'slug'], + order: [['name', 'ASC']], + }); + return R.success(res, 'Categories retrieved.', rows); + } catch (err) { + console.error('[CLIENT][COURSES][CATEGORIES]', err); + return R.error(res, 'Could not retrieve categories.', 500); + } +}; + // ─── COURSES (all visible, is_locked per user tier) ─────────────────────────── exports.getCourses = async (req, res) => { @@ -276,6 +293,11 @@ exports.getCourse = async (req, res) => { "is_required", "passing_score", "time_limit_minutes", "max_questions", ], + include: [{ + model: QuizQuestion, as: "questions", + attributes: ["question_id"], + required: false, + }], }, ], order: [ @@ -313,7 +335,13 @@ exports.getCourse = async (req, res) => { where: { assessment_id: plain.assessment.assessment_id, user_id: req.user.user_id, passed: true }, }); is_completed = !!passedAttempt; - plain.assessment = { ...plain.assessment, has_passed: is_completed }; + const questionCount = plain.assessment.questions?.length ?? 0; + plain.assessment = { + ...plain.assessment, + has_passed: is_completed, + question_count: questionCount, + questions: undefined, + }; } plain.is_completed = is_completed; @@ -957,7 +985,7 @@ exports.getCourseByUuid = async (req, res) => { const { uuid } = req.params; const course = await Course.findOne({ where: { uuid, ...notDeleted }, - attributes: ["course_id", "uuid", "title", "description", "level", "subscription"], + attributes: ["course_id", "uuid", "title", "description", "level", "subscription", "badge_color", "badge_asset_id", "badge_image_url"], }); if (!course) return R.error(res, "Course not found.", 404); diff --git a/controllers/client/media.controller.js b/controllers/client/media.controller.js index 1cd6cb5..c3d4adb 100644 --- a/controllers/client/media.controller.js +++ b/controllers/client/media.controller.js @@ -122,6 +122,11 @@ function pipeRemoteStream(remoteUrl, req, res) { // // S3 assets only — Chibisafe assets use their raw file_url directly. // Returns: { token, provider: "s3", file_type } +// +// TOKEN HITS: If a consumer (e.g. ClientNav badge) re-fetches unexpectedly, +// the fix lives on the frontend — not here. Use a useRef cache key by +// asset_id on the consumer side so this endpoint is called exactly once per +// asset per session. The 4h token TTL makes ref-caching safe within a session. exports.issueToken = async (req, res) => { try { diff --git a/controllers/client/profile.controller.js b/controllers/client/profile.controller.js index 28c6772..27ad8cd 100644 --- a/controllers/client/profile.controller.js +++ b/controllers/client/profile.controller.js @@ -66,6 +66,23 @@ exports.updateProfile = async (req, res) => { } }; +// ─── PATCH preferred currency ────────────────────────────────────────────────── + +exports.updateCurrency = async (req, res) => { + try { + const { currency } = req.body; + if (!currency || typeof currency !== 'string' || currency.length !== 3) + return R.error(res, 'A valid 3-letter ISO 4217 currency code is required.', 400); + + const user = await mdl_Users.findByPk(req.user.user_id); + await user.update({ preferred_currency: currency.toUpperCase() }); + return R.success(res, 'Currency preference updated.', { preferred_currency: user.preferred_currency }); + } catch (err) { + console.error('[CLIENT] updateCurrency error:', err); + return R.error(res, 'Could not update currency preference.', 500); + } +}; + // ─── GET own sessions ────────────────────────────────────────────────────────── exports.getSessions = async (req, res) => { diff --git a/controllers/client/task_download.controller.js b/controllers/client/task_download.controller.js index 6097ed5..d5aa580 100644 --- a/controllers/client/task_download.controller.js +++ b/controllers/client/task_download.controller.js @@ -36,7 +36,7 @@ const isMember = async (userId, groupId) => { // Strips "{S3_PUBLIC_URL}/{S3_BUCKET}/" prefix, leaving e.g. "images/uuid.jpg" const deriveStorageKey = (fileUrl) => { const publicUrl = (process.env.S3_PUBLIC_URL || '').replace(/\/$/, ''); - const bucket = process.env.S3_BUCKET || 'philproperties'; + const bucket = process.env.S3_BUCKET; const prefix = `${publicUrl}/${bucket}/`; if (fileUrl && fileUrl.startsWith(prefix)) { diff --git a/controllers/client/task_upload.controller.js b/controllers/client/task_upload.controller.js index 793636f..3ca443f 100644 --- a/controllers/client/task_upload.controller.js +++ b/controllers/client/task_upload.controller.js @@ -71,7 +71,7 @@ const resolveOwnerType = (mimetype = '') => { // // Returns: // { -// file_url : "https://garage.philproperties.com/philproperties/documents/uuid.pdf", +// file_url : "https://cdn.yourdomain.com/your-bucket/documents/uuid.pdf", // file_name : "social_media_slides.pdf", // file_size : 2400000, // mime_type : "application/pdf", diff --git a/controllers/client/tiers.controller.js b/controllers/client/tiers.controller.js index 4df5d0a..74de059 100644 --- a/controllers/client/tiers.controller.js +++ b/controllers/client/tiers.controller.js @@ -4,47 +4,29 @@ * Description: User-facing tier and payment endpoints. * - View active tier + history * - Browse active plans (with courses per plan) - * - PayPal redirect checkout (create order → capture → cancel) + * - Promo code validation (server-side) + * - PayPal redirect checkout (create order → capture → cancel → refund) * - View own payment history * Author: rgrgogu * Date Created: Jun. 6, 2026 - * Modified: Jun. 9, 2026 + * Modified: Jun. 29, 2026 ***********************************************************************************************************************************************************************/ const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl'); const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl'); +const mdl_PlanPrices = require('../../models/tiers/plan_prices.mdl'); const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl'); const mdl_Payments = require('../../models/tiers/payments.mdl'); const mdl_SystemBadges = require('../../models/system_badges/system_badges.mdl'); const Asset = require('../../models/assets/assets.mdl'); +const UserNotification = require('../../models/notifications/user_notification.mdl'); const { onTierActivated } = require('../../services/achievements.service'); -const { Course } = require('../../models/courses/courses.mdl'); -const paypal = require('../../services/paypal.service'); -const R = require('../../utils/response.util'); +const { Course } = require('../../models/courses/courses.mdl'); +const paymentSvc = require('../../services/payment.service'); +const R = require('../../utils/response.util'); +const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); require('../../models/tiers/tier.associations'); -// ─── Promo codes ────────────────────────────────────────────────────────────── - -const PROMO_CODES = { - PHIL10: 10, // $10 flat discount -}; - -const calculateCheckoutAmount = (price, promoCode) => { - const subtotalCents = Math.round(Number(price) * 100); - const normalizedCode = promoCode?.trim?.().toUpperCase?.() ?? null; - const discountCents = normalizedCode && PROMO_CODES[normalizedCode] - ? Math.min(PROMO_CODES[normalizedCode] * 100, subtotalCents) - : 0; - const totalCents = Math.max(subtotalCents - discountCents, 0); - - return { - promoCode: discountCents > 0 ? normalizedCode : null, - subtotal: (subtotalCents / 100).toFixed(2), - discount: (discountCents / 100).toFixed(2), - total: (totalCents / 100).toFixed(2), - }; -}; - // ─── MY TIER ────────────────────────────────────────────────────────────────── exports.getMyTier = async (req, res) => { @@ -59,27 +41,39 @@ exports.getMyTier = async (req, res) => { model: mdl_TierCategories, as: 'category', required: false, - include: [{ model: Asset, as: 'badgeAsset', attributes: ['asset_id', 'file_url', 'display_name'], required: false }], + include: [{ model: Asset, as: 'badgeAsset', attributes: ['asset_id', 'storage_provider', 'file_url', 'display_name'], required: false }], }], }], order: [['createdAt', 'DESC']], }); + // ── Inline safety net: expire between cron ticks ────────────────────────── + if (tier && tier.expires_at && new Date(tier.expires_at) <= new Date()) { + await tier.update({ status: 'expired' }); + UserNotification.create({ + user_id: req.user.user_id, + ...NOTIFICATION_REGISTRY.tier_expired.build({ + tier: tier.tier, + label: tier.plan?.label ?? null, + }), + }).catch(() => {}); + return R.success(res, 'Active tier retrieved.', { + tier: 'free', status: 'active', category: null, just_expired: true, + }); + } + if (!tier) { - // Free users with no user_tier row: look up free category badge const freeCategory = await mdl_TierCategories.findOne({ where: { slug: 'free' }, - include: [{ model: Asset, as: 'badgeAsset', attributes: ['asset_id', 'file_url', 'display_name'], required: false }], + include: [{ model: Asset, as: 'badgeAsset', attributes: ['asset_id', 'storage_provider', 'file_url', 'display_name'], required: false }], }); return R.success(res, 'Active tier retrieved.', { tier: 'free', status: 'active', category: freeCategory ?? null }); } - // Supplement with the tier category badge even when the user's tier slug doesn't come via a plan - // (e.g., manually granted tiers that only store a slug, not a plan_id) if (!tier.plan?.category) { const category = await mdl_TierCategories.findOne({ where: { slug: tier.tier }, - include: [{ model: Asset, as: 'badgeAsset', attributes: ['asset_id', 'file_url', 'display_name'], required: false }], + include: [{ model: Asset, as: 'badgeAsset', attributes: ['asset_id', 'storage_provider', 'file_url', 'display_name'], required: false }], }); const plain = tier.toJSON(); plain.category = category?.toJSON() ?? null; @@ -106,20 +100,26 @@ exports.getMyTierHistory = async (req, res) => { } }; -// ─── PLANS (with courses) ───────────────────────────────────────────────────── +// ─── PLANS ──────────────────────────────────────────────────────────────────── exports.getPlans = async (req, res) => { try { const plans = await mdl_TierPlans.findAll({ - where: { is_active: true }, - order: [['tier', 'ASC'], ['duration_days', 'ASC']], - attributes: ['plan_id', 'tier', 'label', 'duration_days', 'price', 'currency'], - include: [{ - model: Course, - as: 'courses', - attributes: ['course_id', 'uuid', 'title', 'course_code', 'level', 'duration_seconds'], - through: { attributes: [] }, - }], + order: [['tier', 'ASC'], ['duration_days', 'ASC']], + attributes: ['plan_id', 'tier', 'label', 'description', 'duration_days', 'duration_unit', 'price', 'currency', 'is_active'], + include: [ + { + model: Course, + as: 'courses', + attributes: ['course_id', 'uuid', 'title', 'course_code', 'level', 'duration_seconds'], + through: { attributes: [] }, + }, + { + model: mdl_PlanPrices, + as: 'prices', + attributes: ['currency', 'price'], + }, + ], }); const result = plans.map((p) => { @@ -135,57 +135,109 @@ exports.getPlans = async (req, res) => { } }; -// ─── PAYPAL CHECKOUT ────────────────────────────────────────────────────────── +// ─── PROMO CODE VALIDATION ──────────────────────────────────────────────────── + +exports.validatePromo = async (req, res) => { + try { + const { plan_id, code, currency } = req.body; + if (!plan_id || !code) return R.error(res, 'plan_id and code are required.', 400); + + const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true } }); + if (!plan) return R.error(res, 'Plan not found or inactive.', 404); + + // Resolve localized price if a preferred currency was sent + let effectivePrice = null; + if (currency && currency !== plan.currency) { + const priceEntry = await mdl_PlanPrices.findOne({ where: { plan_id, currency } }); + if (priceEntry) effectivePrice = priceEntry.price; + } + + const policy = await paymentSvc.getPolicyForPlan(plan_id); + const result = await paymentSvc.evaluatePromo(policy, plan, code, effectivePrice); + + return R.success(res, result.valid ? 'Promo code is valid.' : result.reason, result); + } catch (err) { + console.error('[CLIENT][VALIDATE PROMO]', err); + return R.error(res, 'Could not validate promo code.', 500); + } +}; + +// ─── CHECKOUT ───────────────────────────────────────────────────────────────── exports.createOrder = async (req, res) => { try { - const { plan_id, promo_code } = req.body; + const { plan_id, promo_code, currency: requestedCurrency } = req.body; if (!plan_id) return R.error(res, 'plan_id is required.', 400); const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true } }); if (!plan) return R.error(res, 'Plan not found or inactive.', 404); - const checkout = calculateCheckoutAmount(plan.price, promo_code); - if (Number(checkout.total) <= 0) + // Resolve localized price — falls back to plan base price when no override exists + let effectivePrice = Number(plan.price); + let effectiveCurrency = plan.currency; + if (requestedCurrency && requestedCurrency !== plan.currency) { + const priceEntry = await mdl_PlanPrices.findOne({ where: { plan_id, currency: requestedCurrency } }); + if (priceEntry) { + effectivePrice = Number(priceEntry.price); + effectiveCurrency = priceEntry.currency; + } + } + + const policy = await paymentSvc.getPolicyForPlan(plan_id); + + let promoResult = { valid: false, code: null, discount: 0 }; + if (promo_code) { + promoResult = await paymentSvc.evaluatePromo(policy, plan, promo_code, effectivePrice); + if (!promoResult.valid) + return R.error(res, promoResult.reason ?? 'Invalid promo code.', 400); + } + + const subtotal = effectivePrice; + const discount = promoResult.discount ?? 0; + const total = Math.max(subtotal - discount, 0).toFixed(2); + + if (Number(total) <= 0) return R.error(res, 'PayPal checkout requires a payable amount.', 400); - const ppOrder = await paypal.createOrder({ - amount: checkout.total, - currency: plan.currency, + const provider = (policy?.allowed_providers?.[0]) ?? 'paypal'; + const ppOrder = await paymentSvc.createOrder(provider, { + amount: total, + currency: effectiveCurrency, referenceId: `user_${req.user.user_id}_plan_${plan_id}`, }); - // Extract PayPal approval URL from links array const approvalUrl = ppOrder.links?.find((l) => l.rel === 'approve')?.href ?? null; const payment = await mdl_Payments.create({ - user_id: req.user.user_id, + user_id: req.user.user_id, plan_id, - status: 'pending', - amount: checkout.total, - currency: plan.currency, - promo_code: checkout.promoCode, - discount: checkout.discount, - provider: 'paypal', + status: 'pending', + amount: total, + currency: effectiveCurrency, + promo_code: promoResult.code, + discount: discount.toFixed(2), + provider, provider_payload: { - order_id: ppOrder.id, + order_id: ppOrder.id, approval_url: approvalUrl, checkout: { - subtotal: checkout.subtotal, - discount: checkout.discount, - promo_code: checkout.promoCode, + subtotal: subtotal.toFixed(2), + discount: discount.toFixed(2), + promo_code: promoResult.code, + base_price: Number(plan.price).toFixed(2), + base_currency: plan.currency, }, }, }); return R.success(res, 'Order created.', { - payment_id: payment.payment_id, - order_id: ppOrder.id, + payment_id: payment.payment_id, + order_id: ppOrder.id, approval_url: approvalUrl, - amount: checkout.total, - currency: plan.currency, - promo_code: checkout.promoCode, - discount: checkout.discount, + amount: total, + currency: effectiveCurrency, + promo_code: promoResult.code, + discount: discount.toFixed(2), }, 201); } catch (err) { console.error('[CLIENT][CREATE ORDER]', err); @@ -199,70 +251,75 @@ exports.captureOrder = async (req, res) => { if (!order_id) return R.error(res, 'order_id is required.', 400); const payment = await mdl_Payments.findOne({ - where: { - status: 'pending', - provider: 'paypal', - user_id: req.user.user_id, - }, + where: { status: 'pending', user_id: req.user.user_id }, include: [{ model: mdl_TierPlans, as: 'plan' }], - order: [['createdAt', 'DESC']], + order: [['createdAt', 'DESC']], }); - // Match by order_id inside provider_payload if (!payment || payment.provider_payload?.order_id !== order_id) return R.error(res, 'Pending payment not found.', 404); - let captureData; - try { - captureData = await paypal.captureOrder(order_id); - } catch (ppErr) { + // Guard: plan was deactivated while user was on PayPal's approval page + if (!payment.plan?.is_active) { await payment.update({ - status: 'failed', + status: 'cancelled', provider_payload: { ...payment.provider_payload, - error: ppErr?.response?.data ?? {}, + cancelled_at: new Date().toISOString(), + cancelled_by: 'system', + cancel_reason: 'plan_deactivated', }, }); - return R.error(res, 'PayPal capture failed.', 402); + return R.error(res, 'This plan is no longer available. No payment was taken.', 409); + } + + let captureData; + try { + captureData = await paymentSvc.captureOrder(payment.provider, order_id); + } catch (ppErr) { + await payment.update({ + status: 'failed', + provider_payload: { ...payment.provider_payload, error: ppErr?.response?.data ?? {} }, + }); + return R.error(res, 'Payment capture failed.', 402); } const capture = captureData.purchase_units?.[0]?.payments?.captures?.[0]; - // Expire current active tier await mdl_UserTiers.update( { status: 'expired' }, { where: { user_id: req.user.user_id, status: 'active' } } ); - const startsAt = new Date(); + const startsAt = new Date(); const expiresAt = new Date(startsAt.getTime() + payment.plan.duration_days * 86400000); const newTier = await mdl_UserTiers.create({ - user_id: req.user.user_id, - tier: payment.plan.tier, - plan_id: payment.plan_id, - status: 'active', + user_id: req.user.user_id, + tier: payment.plan.tier, + plan_id: payment.plan_id, + status: 'active', starts_at: startsAt, expires_at: expiresAt, granted_by: null, }); await payment.update({ - status: 'completed', - tier_id: newTier.tier_id, - paid_at: new Date(), + status: 'completed', + tier_id: newTier.tier_id, + paid_at: new Date(), provider_payload: { ...payment.provider_payload, capture_id: capture?.id, - payer_id: captureData.payer?.payer_id, - capture: captureData, + payer_id: captureData.payer?.payer_id, + capture: captureData, }, }); - // await grantAchievement(req.user.user_id, payment.plan.tier); + await onTierActivated(req.user.user_id, newTier.tier); return R.success(res, 'Payment successful. Tier activated.', { - tier: newTier.tier, + tier: newTier.tier, expires_at: newTier.expires_at, }); } catch (err) { @@ -277,7 +334,7 @@ exports.cancelOrder = async (req, res) => { if (!order_id) return R.error(res, 'order_id is required.', 400); const payment = await mdl_Payments.findOne({ - where: { user_id: req.user.user_id, status: 'pending', provider: 'paypal' }, + where: { user_id: req.user.user_id, status: 'pending' }, order: [['createdAt', 'DESC']], }); @@ -285,7 +342,7 @@ exports.cancelOrder = async (req, res) => { return R.error(res, 'Pending payment not found.', 404); await payment.update({ - status: 'cancelled', + status: 'cancelled', provider_payload: { ...payment.provider_payload, cancelled_at: new Date().toISOString(), @@ -300,15 +357,87 @@ exports.cancelOrder = async (req, res) => { } }; +exports.refundOrder = async (req, res) => { + try { + const user_id = req.user.user_id; + + const activeTier = await mdl_UserTiers.findOne({ + where: { user_id, status: 'active' }, + order: [['createdAt', 'DESC']], + }); + if (!activeTier) return R.error(res, 'No active tier to refund.', 404); + + const payment = await mdl_Payments.findOne({ + where: { user_id, tier_id: activeTier.tier_id, status: 'completed' }, + order: [['paid_at', 'DESC']], + }); + if (!payment) return R.error(res, 'No completed payment found for this tier.', 404); + + // Load plan's payment policy to get the configured refund window + const policy = await paymentSvc.getPolicyForPlan(payment.plan_id); + + if (!paymentSvc.isRefundAllowed(policy)) + return R.error(res, 'Refunds are not available for this plan.', 403); + + const windowMs = paymentSvc.getRefundWindowMs(policy); + if (!payment.paid_at || Date.now() - new Date(payment.paid_at).getTime() > windowMs) { + const rp = policy?.refund_policy ?? {}; + const label = `${rp.window_value ?? 5} ${rp.window_unit ?? 'minutes'}`; + return R.error(res, `Refund window has expired. Refunds are only available within ${label} of payment.`, 403); + } + + const captureId = payment.provider_payload?.capture_id; + if (!captureId) return R.error(res, 'Capture ID not found. Cannot process refund.', 400); + + let refundData; + try { + refundData = await paymentSvc.refundCapture(payment.provider, captureId, payment.amount, payment.currency); + } catch (ppErr) { + console.error('[CLIENT][REFUND] provider error:', ppErr?.response?.data); + return R.error(res, 'Refund failed. Please try again.', 402); + } + + await payment.update({ + status: 'refunded', + provider_payload: { + ...payment.provider_payload, + refund: refundData, + refunded_at: new Date().toISOString(), + }, + }); + + const now = new Date(); + await activeTier.update({ status: 'revoked', expires_at: now, revoked_at: now }); + + await mdl_UserTiers.create({ + user_id, + tier: 'free', + status: 'active', + starts_at: now, + expires_at: null, + granted_by: null, + notes: 'Auto-downgrade after refund.', + }); + + return R.success(res, 'Refund processed successfully. Your access has been revoked.', { + refund_id: refundData.id, + status: refundData.status, + }); + } catch (err) { + console.error('[CLIENT][REFUND]', err); + return R.error(res, 'Could not process refund.', 500); + } +}; + // ─── MY PAYMENTS ────────────────────────────────────────────────────────────── exports.getMyPayments = async (req, res) => { try { const payments = await mdl_Payments.findAll({ - where: { user_id: req.user.user_id }, - include: [{ model: mdl_TierPlans, as: 'plan', attributes: ['label', 'tier', 'duration_days'] }], + where: { user_id: req.user.user_id }, + include: [{ model: mdl_TierPlans, as: 'plan', attributes: ['label', 'tier', 'duration_days'] }], attributes: { exclude: ['provider_payload'] }, - order: [['createdAt', 'DESC']], + order: [['createdAt', 'DESC']], }); return R.success(res, 'Payment history retrieved.', payments); } catch (err) { @@ -317,14 +446,14 @@ exports.getMyPayments = async (req, res) => { } }; -// ─── SYSTEM BADGES (read-only for client profile) ──────────────────────────── +// ─── TIER CATEGORIES + SYSTEM BADGES ───────────────────────────────────────── exports.getCategories = async (req, res) => { try { const categories = await mdl_TierCategories.findAll({ where: { is_active: true }, - attributes: ['tier_category_id', 'slug', 'name', 'rank', 'color', 'badge_label', 'is_default'], - include: [{ model: Asset, as: 'badgeAsset', attributes: ['file_url', 'display_name'], required: false }], + attributes: ['tier_category_id', 'slug', 'name', 'rank', 'color', 'badge_icon', 'badge_label', 'is_default'], + include: [{ model: Asset, as: 'badgeAsset', attributes: ['asset_id', 'storage_provider', 'file_url', 'display_name'], required: false }], order: [['rank', 'ASC']], }); return R.success(res, 'Tier categories retrieved.', categories); @@ -347,81 +476,3 @@ exports.getSystemBadges = async (req, res) => { return R.error(res, 'Could not retrieve system badges.', 500); } }; - -// — add refundOrder export ──────────────── - -const REFUND_WINDOW_MS = 5 * 60 * 1000; // 5 minutes from paid_at - -exports.refundOrder = async (req, res) => { - try { - const user_id = req.user.user_id; - - // Get the active tier - const activeTier = await mdl_UserTiers.findOne({ - where: { user_id, status: 'active' }, - order: [['createdAt', 'DESC']], - }); - if (!activeTier) return R.error(res, 'No active tier to refund.', 404); - - // Get the completed payment for this tier - const payment = await mdl_Payments.findOne({ - where: { user_id, tier_id: activeTier.tier_id, status: 'completed' }, - order: [['paid_at', 'DESC']], - }); - if (!payment) return R.error(res, 'No completed payment found for this tier.', 404); - - // Enforce 5-minute refund window - if (!payment.paid_at || Date.now() - new Date(payment.paid_at).getTime() > REFUND_WINDOW_MS) - return R.error(res, 'Refund window has expired. Refunds are only available within 5 minutes of payment.', 403); - - // Get capture_id from provider_payload - const captureId = payment.provider_payload?.capture_id; - if (!captureId) return R.error(res, 'Capture ID not found. Cannot process refund.', 400); - - // Call PayPal refund API - let refundData; - try { - refundData = await paypal.refundCapture(captureId, payment.amount, payment.currency); - } catch (ppErr) { - console.error('[CLIENT][REFUND] PayPal error:', ppErr?.response?.data); - return R.error(res, 'PayPal refund failed. Please try again.', 402); - } - - // Update payment status to refunded - await payment.update({ - status: 'refunded', - provider_payload: { - ...payment.provider_payload, - refund: refundData, - refunded_at: new Date().toISOString(), - }, - }); - - // Immediately terminate access — cut expires_at to now and revoke - const now = new Date(); - await activeTier.update({ - status: 'revoked', - expires_at: now, - revoked_at: now, - }); - - // Drop user back to free immediately - await mdl_UserTiers.create({ - user_id, - tier: 'free', - status: 'active', - starts_at: now, - expires_at: null, - granted_by: null, - notes: 'Auto-downgrade after refund.', - }); - - return R.success(res, 'Refund processed successfully. Your access has been revoked.', { - refund_id: refundData.id, - status: refundData.status, - }); - } catch (err) { - console.error('[CLIENT][REFUND]', err); - return R.error(res, 'Could not process refund.', 500); - } -}; diff --git a/cron/client.cron.js b/cron/client.cron.js index 364319a..07dca8b 100644 --- a/cron/client.cron.js +++ b/cron/client.cron.js @@ -8,6 +8,7 @@ * Currently registered: * - userNotifications (cron/jobs/user_notifications.cron.js) * - issueCertificates (cron/jobs/issue_certificates.cron.js) + * - expireUserTiers (cron/jobs/expire_user_tiers.cron.js) * * Author: Kenneth Obsequio (@lash0000) * Date Created: Jun. 17, 2026 @@ -15,11 +16,13 @@ const cron = require('node-cron'); const userNotifications = require('./jobs/user_notifications.cron'); const issueCertificates = require('./jobs/issue_certificates.cron'); +const expireUserTiers = require('./jobs/expire_user_tiers.cron'); // ─── Registry — add future client-side cron jobs here ──────────────────────── const jobs = [ userNotifications, issueCertificates, + expireUserTiers, ]; // ─── Boot all registered client-side jobs ───────────────────────────────────── diff --git a/cron/jobs/expire_user_tiers.cron.js b/cron/jobs/expire_user_tiers.cron.js new file mode 100644 index 0000000..7aaa2cc --- /dev/null +++ b/cron/jobs/expire_user_tiers.cron.js @@ -0,0 +1,95 @@ +/*********************************************************************************************************************************************************************** + * File Name : expire_user_tiers.cron.js + * Type : Cron Job + * Description : Marks active user_tiers rows as 'expired' when their expires_at + * has passed. Runs every minute to support short-duration plans + * (minute- and hour-level plans in addition to day/month/year). + * + * For each expired batch it: + * 1. Bulk-updates matching rows to status = 'expired'. + * 2. Sends an in-app UserNotification to each affected user. + * + * Safety: + * - Only touches rows with expires_at IS NOT NULL so + * manually-granted unlimited tiers (expires_at = NULL) are + * never touched. + * - Bulk update happens before notifications so a restart + * mid-run never re-expires already-expired rows. + * + * Schedule : Every minute ("* * * * *"). Registered by cron/client.cron.js. + * + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jun. 29, 2026 + ***********************************************************************************************************************************************************************/ +'use strict'; + +const { Op } = require('sequelize'); +const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl'); +const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl'); +const UserNotification = require('../../models/notifications/user_notification.mdl'); +const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); + +require('../../models/tiers/tier.associations'); + +async function run() { + // ── 1. Find all active tiers whose expires_at has passed ────────────────── + let expired; + try { + expired = await mdl_UserTiers.findAll({ + where: { + status: 'active', + expires_at: { [Op.ne]: null, [Op.lte]: new Date() }, + }, + include: [{ + model: mdl_TierPlans, + as: 'plan', + attributes: ['label', 'tier'], + required: false, + }], + attributes: ['tier_id', 'user_id', 'tier'], + }); + } catch (err) { + console.error('[CRON][EXPIRE TIERS] Failed to query user_tiers:', err); + return; + } + + if (!expired.length) return; + + const tierIds = expired.map((t) => t.tier_id); + + // ── 2. Bulk-update to expired ────────────────────────────────────────────── + try { + await mdl_UserTiers.update( + { status: 'expired' }, + { where: { tier_id: tierIds } } + ); + } catch (err) { + console.error('[CRON][EXPIRE TIERS] Bulk update failed:', err); + return; + } + + // ── 3. Send in-app notifications (one per affected user) ────────────────── + const notifications = expired.map((t) => + NOTIFICATION_REGISTRY.tier_expired.build({ + tier: t.tier, + label: t.plan?.label ?? null, + }) + ).map((payload, i) => ({ + user_id: expired[i].user_id, + ...payload, + })); + + try { + await UserNotification.bulkCreate(notifications, { ignoreDuplicates: true }); + } catch (err) { + console.error('[CRON][EXPIRE TIERS] Notification bulkCreate failed:', err); + } + + console.log(`[CRON][EXPIRE TIERS] Expired ${expired.length} tier(s) for ${new Set(expired.map((t) => t.user_id)).size} user(s).`); +} + +module.exports = { + name: 'expireUserTiers', + schedule: '* * * * *', + run, +}; diff --git a/data/notifications.data.js b/data/notifications.data.js index 08023c4..d4494ab 100644 --- a/data/notifications.data.js +++ b/data/notifications.data.js @@ -22,7 +22,7 @@ * Admin : task_overdue, user_registration, nogrp_user_registered * User : user_task_overdue, achievement, course_unlocked, * course_completed, certificate_issued, task_reminder, announcement, - * nogrp_welcome + * nogrp_welcome, tier_expired * * Author: Kenneth Obsequio (@lash0000) * Date Created: Jun. 19, 2026 @@ -239,6 +239,21 @@ const NOTIFICATION_REGISTRY = { }, }, + // ── Tier ────────────────────────────────────────────────────────────────── + tier_expired: { + type: 'tier_expired', + scope: 'user', + trigger: 'cron', + build({ tier, label }) { + return { + type: 'tier_expired', + title: 'Subscription Expired', + message: `Your ${label ?? tier} plan has expired. Renew to keep access.`, + data: { tier, label }, + }; + }, + }, + }; module.exports = { NOTIFICATION_REGISTRY }; diff --git a/database/migrations/20260101000066-add-description-to-tier-plans.js b/database/migrations/20260101000066-add-description-to-tier-plans.js new file mode 100644 index 0000000..487f74a --- /dev/null +++ b/database/migrations/20260101000066-add-description-to-tier-plans.js @@ -0,0 +1,15 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('tier_plans', 'description', { + type: Sequelize.TEXT, + allowNull: true, + after: 'label', + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('tier_plans', 'description'); + }, +}; diff --git a/database/migrations/20260101000067-create-payment-policies.js b/database/migrations/20260101000067-create-payment-policies.js new file mode 100644 index 0000000..e6cfa3e --- /dev/null +++ b/database/migrations/20260101000067-create-payment-policies.js @@ -0,0 +1,47 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('payment_policies', { + policy_id: { + type: Sequelize.BIGINT, + primaryKey: true, + autoIncrement: true, + }, + plan_id: { + type: Sequelize.BIGINT, + allowNull: true, + unique: true, + references: { model: 'tier_plans', key: 'plan_id' }, + onDelete: 'CASCADE', + }, + // Array of { code, type:'flat'|'percent', value, currency?, max_discount?, + // max_uses?, expires_at?, min_amount? } + promo_rules: { + type: Sequelize.JSONB, + allowNull: false, + defaultValue: [], + }, + // { allowed, window_value, window_unit:'minutes'|'hours'|'days', reason_required } + refund_policy: { + type: Sequelize.JSONB, + allowNull: false, + defaultValue: { allowed: true, window_value: 5, window_unit: 'minutes', reason_required: false }, + }, + // e.g. ["paypal"] + allowed_providers: { + type: Sequelize.JSONB, + allowNull: false, + defaultValue: ['paypal'], + }, + createdAt: { type: Sequelize.DATE, allowNull: false }, + updatedAt: { type: Sequelize.DATE, allowNull: false }, + }); + + await queryInterface.addIndex('payment_policies', ['plan_id']); + }, + + async down(queryInterface) { + await queryInterface.dropTable('payment_policies'); + }, +}; diff --git a/database/migrations/20260101000068-add-duration-unit-to-tier-plans.js b/database/migrations/20260101000068-add-duration-unit-to-tier-plans.js new file mode 100644 index 0000000..f74a5f7 --- /dev/null +++ b/database/migrations/20260101000068-add-duration-unit-to-tier-plans.js @@ -0,0 +1,23 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('tier_plans', 'duration_unit', { + type: Sequelize.STRING(10), + allowNull: false, + defaultValue: 'day', + }); + await queryInterface.changeColumn('tier_plans', 'duration_days', { + type: Sequelize.FLOAT, + allowNull: false, + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.removeColumn('tier_plans', 'duration_unit'); + await queryInterface.changeColumn('tier_plans', 'duration_days', { + type: Sequelize.INTEGER, + allowNull: false, + }); + }, +}; diff --git a/database/migrations/20260101000069-add-preferred-currency-to-users.js b/database/migrations/20260101000069-add-preferred-currency-to-users.js new file mode 100644 index 0000000..0d288fc --- /dev/null +++ b/database/migrations/20260101000069-add-preferred-currency-to-users.js @@ -0,0 +1,15 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('users', 'preferred_currency', { + type: Sequelize.CHAR(3), + allowNull: false, + defaultValue: 'USD', + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('users', 'preferred_currency'); + }, +}; diff --git a/database/migrations/20260101000070-create-plan-prices.js b/database/migrations/20260101000070-create-plan-prices.js new file mode 100644 index 0000000..5e72e7b --- /dev/null +++ b/database/migrations/20260101000070-create-plan-prices.js @@ -0,0 +1,34 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('plan_prices', { + price_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true }, + plan_id: { + type: Sequelize.BIGINT, + allowNull: false, + references: { model: 'tier_plans', key: 'plan_id' }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }, + currency: { type: Sequelize.CHAR(3), allowNull: false }, + price: { type: Sequelize.DECIMAL(10, 2), allowNull: false }, + createdAt: { type: Sequelize.DATE, allowNull: false }, + updatedAt: { type: Sequelize.DATE, allowNull: false }, + }); + + await queryInterface.addConstraint('plan_prices', { + fields: ['plan_id', 'currency'], + type: 'unique', + name: 'uq_plan_prices_plan_currency', + }); + + await queryInterface.addIndex('plan_prices', ['plan_id'], { + name: 'idx_plan_prices_plan_id', + }); + }, + + async down(queryInterface) { + await queryInterface.dropTable('plan_prices'); + }, +}; diff --git a/database/migrations/20260101000071-create-course-achievements.js b/database/migrations/20260101000071-create-course-achievements.js new file mode 100644 index 0000000..697bf9f --- /dev/null +++ b/database/migrations/20260101000071-create-course-achievements.js @@ -0,0 +1,25 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('course_achievements', { + id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true }, + course_id: { type: Sequelize.BIGINT, allowNull: false }, + achievement_key: { type: Sequelize.STRING(100), allowNull: false }, + order_index: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 }, + createdAt: { type: Sequelize.DATE, allowNull: false }, + updatedAt: { type: Sequelize.DATE, allowNull: false }, + }); + + await queryInterface.addIndex('course_achievements', ['course_id']); + await queryInterface.addConstraint('course_achievements', { + fields: ['course_id', 'achievement_key'], + type: 'unique', + name: 'course_achievements_course_id_key_unique', + }); + }, + + async down(queryInterface) { + await queryInterface.dropTable('course_achievements'); + }, +}; diff --git a/docker-compose.yml b/docker-compose.yml index 0df2997..312b7be 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,6 +39,8 @@ services: condition: service_healthy valkey: condition: service_started + garage: + condition: service_healthy restart: unless-stopped # ── Frontend (React / Nginx) ───────────────────────────────────────────────── @@ -73,6 +75,34 @@ services: - valkey_data:/data restart: unless-stopped + # ── Garage (S3-compatible self-hosted storage) ──────────────────────────────── + # Exposes the S3 API on port 3900. Point your reverse proxy at this port + # to serve S3_PUBLIC_URL (e.g. https://cdn.yourdomain.com → http://garage:3900). + # GARAGE_RPC_SECRET must be set in .env (generate with: openssl rand -hex 32). + # + # garage-init.sh starts the daemon in the background, runs one-time cluster + # initialization (layout, bucket, key), then hands control back to the daemon. + garage: + image: dxflrs/garage + entrypoint: ["/bin/sh", "/garage-init.sh"] + volumes: + - ./config/garage.toml:/etc/garage.toml:ro + - ./scripts/garage-init.sh:/garage-init.sh:ro + - garage_meta:/var/lib/garage/meta + - garage_data:/var/lib/garage/data + ports: + - "3900:3900" + env_file: .env + healthcheck: + test: ["CMD-SHELL", "test -f /var/lib/garage/meta/.ready && garage status > /dev/null 2>&1"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + restart: unless-stopped + volumes: postgres_data: valkey_data: + garage_meta: + garage_data: diff --git a/middleware/originGuard.middleware.js b/middleware/originGuard.middleware.js index 7bb7b5e..a99516f 100644 --- a/middleware/originGuard.middleware.js +++ b/middleware/originGuard.middleware.js @@ -1,37 +1,59 @@ /*********************************************************************************************************************************************************************** * File Name: originGuard.middleware.js * Type of Program: Middleware - * Description: Two-layer server-side guard that blocks non-browser clients from reaching any API route. + * Description: Three-layer server-side guard that blocks non-browser clients from reaching any API route. * - * ── Layer 1 — Sec-Fetch-Site (ALL methods including GET) ──────────────────────────────────────── + * ── Layer 1 — Fetch Metadata family (ALL methods including GET) ───────────────────────────────── * - * Browsers (Chrome 76+, Firefox 90+, Safari 16.4+) automatically attach the Sec-Fetch-Site - * header on every request. It is a forbidden request header — JavaScript cannot set, override, - * or remove it. Its absence is a reliable, low-spoofability signal that the request originated - * from a tool rather than a real browser. + * 1a) Sec-Fetch-Site presence + value + * Browsers (Chrome 76+, Firefox 90+, Safari 16.4+) automatically attach Sec-Fetch-Site on + * every request. It is a forbidden request header — JavaScript cannot set, override, or + * remove it. Its absence reliably signals a non-browser client. The value cross-site is + * also rejected; only same-origin, same-site, and none (direct navigation) are accepted. * - * Tools blocked by this layer (default configurations): - * ✓ Metasploit (Rex HTTP client) — no Sec-Fetch-Site - * ✓ BurpSuite Repeater / Scanner — no Sec-Fetch-Site - * ✓ Postman — no Sec-Fetch-Site - * ✓ curl / wget / httpie / python-requests — no Sec-Fetch-Site - * ✓ Nikto — no Sec-Fetch-Site - * ✓ sqlmap — no Sec-Fetch-Site - * ✓ dirb / gobuster / feroxbuster — no Sec-Fetch-Site - * ✓ nmap HTTP scripts — no Sec-Fetch-Site + * Tools blocked (default configurations): + * ✓ Metasploit (Rex HTTP client) — no Sec-Fetch-Site + * ✓ BurpSuite Repeater / Scanner — no Sec-Fetch-Site + * ✓ Postman — no Sec-Fetch-Site + * ✓ curl / wget / httpie / python-requests — no Sec-Fetch-Site + * ✓ Nikto — no Sec-Fetch-Site + * ✓ sqlmap — no Sec-Fetch-Site + * ✓ dirb / gobuster / feroxbuster — no Sec-Fetch-Site + * ✓ nmap HTTP scripts — no Sec-Fetch-Site * - * ── Layer 2 — Origin allowlist (POST / PUT / PATCH / DELETE only) ─────────────────────────────── + * 1b) Sec-Fetch-Mode + Sec-Fetch-Dest presence + valid combination + * Browsers that send Sec-Fetch-Site always send Mode and Dest too (Chrome 80+, + * Firefox 90+, Safari 16.4+). Missing headers or impossible combinations signal + * manual header injection. Only combinations expected on an API server are allowed: + * cors|empty — standard fetch() call from a cross-origin SPA + * same-origin|empty — same-origin fetch() + * navigate|document — direct browser navigation to an API URL + * + * Additional tools blocked: + * ✓ Scripts that fake only Sec-Fetch-Site — missing Mode or Dest + * ✓ Scripts with wrong Mode+Dest combos — no-cors, cors+document, etc. + * + * ── Layer 2 — Browser presence signals (ALL methods including GET) ───────────────────────────── + * + * At least one browser-native header must be present: + * Sec-CH-UA — Chromium client hint, forbidden in non-browser contexts + * Accept-Language — sent by all browsers (Chrome, Firefox, Safari) + * Absence of both is a strong automation signal that catches tools sophisticated enough to + * replicate the Sec-Fetch-* family but not the full browser header profile. + * + * ── Layer 3 — Origin allowlist (POST / PUT / PATCH / DELETE only) ────────────────────────────── * * State-mutating requests must also carry an Origin header that matches ALLOWED_ORIGINS. * Stops credential-stuffing and cross-origin mutation attempts from unlisted domains, - * even if an attacker managed to set Sec-Fetch-Site manually. + * even if an attacker replicated all browser headers above. * * ── What this does NOT stop ────────────────────────────────────────────────────────────────────── * * ✗ BurpSuite running as MITM proxy through a real browser session. - * The browser supplies all correct headers — requests are indistinguishable from - * legitimate traffic. The only defences here are rate limiting and valid credentials. - * ✗ A determined attacker who manually replicates all browser headers in their tool. + * ✗ Playwright / Puppeteer / Selenium controlling a real browser — they produce all correct + * Sec-Fetch-* headers, Sec-CH-UA, and Accept-Language automatically. + * ✗ A determined attacker who manually replicates all required headers. + * The only defences at that point are rate limiting and valid credentials. * * ── Browser compatibility note ─────────────────────────────────────────────────────────────────── * @@ -41,7 +63,7 @@ * * Author: Kenneth Obsequio (@lash0000) * Date Created: Jun. 20, 2026 - * Date Modified: Jun. 20, 2026 + * Date Modified: Jun. 29, 2026 ***********************************************************************************************************************************************************************/ "use strict"; @@ -54,6 +76,13 @@ const ALLOWED = (process.env.ALLOWED_ORIGINS || process.env.APP_URL || '') const MUTATION_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); +// Valid Sec-Fetch-Mode + Sec-Fetch-Dest combinations expected on this API server. +const VALID_FETCH_COMBOS = new Set([ + 'cors|empty', // standard fetch() from cross-origin SPA + 'same-origin|empty', // same-origin fetch() + 'navigate|document', // direct browser navigation to an API URL +]); + module.exports = function originGuard(req, res, next) { // Dev bypass: set ORIGIN_GUARD_DISABLED=true in .env to allow Postman/curl through. // Hardcoded production lock — never bypassed even if the flag is accidentally set. @@ -63,12 +92,25 @@ module.exports = function originGuard(req, res, next) { if (req.method === 'OPTIONS') return next(); // preflight — handled by cors() - // ── Layer 1: Sec-Fetch-Site must be present (covers GET scanning) ───────── - if (!req.headers['sec-fetch-site']) { + // ── Layer 1a: Sec-Fetch-Site must be present and not cross-site ─────────── + const fetchSite = req.headers['sec-fetch-site']; + if (!fetchSite || fetchSite === 'cross-site') { return R.error(res, 'Forbidden.', 403); } - // ── Layer 2: Origin must be in allowlist for state-changing requests ────── + // ── Layer 1b: Fetch Metadata family must be complete and form a valid combo ─ + const fetchMode = req.headers['sec-fetch-mode']; + const fetchDest = req.headers['sec-fetch-dest']; + if (!fetchMode || !fetchDest || !VALID_FETCH_COMBOS.has(`${fetchMode}|${fetchDest}`)) { + return R.error(res, 'Forbidden.', 403); + } + + // ── Layer 2: At least one browser-native fingerprint header must be present ─ + if (!req.headers['sec-ch-ua'] && !req.headers['accept-language']) { + return R.error(res, 'Forbidden.', 403); + } + + // ── Layer 3: Origin must be in allowlist for state-changing requests ─────── if (MUTATION_METHODS.has(req.method)) { const origin = req.headers['origin']; if (!origin || !ALLOWED.includes(origin)) { diff --git a/middleware/rateLimiter.middleware.js b/middleware/rateLimiter.middleware.js index cb74f70..095c01e 100644 --- a/middleware/rateLimiter.middleware.js +++ b/middleware/rateLimiter.middleware.js @@ -80,10 +80,11 @@ const sensitiveOpsLimiter = rateLimit({ message: { status: 'error', message: 'Too many sensitive operations. Please wait 1 hour.' }, }); -/** Admin routes — tighter than global */ +/** Admin routes — per authenticated user, not per IP */ const adminLimiter = rateLimit({ windowMs: windowMs15, max: 200, + keyGenerator: (req) => req.user?.user_id?.toString() ?? req.ip, standardHeaders: true, legacyHeaders: false, store: makeStore('admin'), diff --git a/models/courses/course_achievement.mdl.js b/models/courses/course_achievement.mdl.js new file mode 100644 index 0000000..fb9689b --- /dev/null +++ b/models/courses/course_achievement.mdl.js @@ -0,0 +1,16 @@ +'use strict'; + +const { DataTypes } = require('sequelize'); +const sequelize = require('../../config/db.config'); + +const CourseAchievement = sequelize.define('CourseAchievement', { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + course_id: { type: DataTypes.BIGINT, allowNull: false }, + achievement_key: { type: DataTypes.STRING(100), allowNull: false }, + order_index: { type: DataTypes.INTEGER, defaultValue: 0 }, +}, { + tableName: 'course_achievements', + timestamps: true, +}); + +module.exports = CourseAchievement; diff --git a/models/courses/courses.associations.js b/models/courses/courses.associations.js index 10d3d45..2e6aeb5 100644 --- a/models/courses/courses.associations.js +++ b/models/courses/courses.associations.js @@ -21,6 +21,7 @@ const CourseInstructor = require("./course_instructor.mdl"); const CourseReadingProgress = require("./course_reading_progress.mdl"); const UnitReadingProgress = require("./unit_reading_progress.mdl"); const LessonReadingProgress = require("./lesson_reading_progress.mdl"); +const CourseAchievement = require("./course_achievement.mdl"); // ── CourseReadingProgress ───────────────────────────────────────────────────── CourseReadingProgress.belongsTo(mdl_Users, { foreignKey: 'user_id', as: 'user' }); @@ -51,9 +52,11 @@ Course.hasMany(CourseObjective, { as: "objectives", foreignKey: "course_id" }); Course.hasMany(CoursePrerequisite, { as: "prerequisites", foreignKey: "course_id" }); Course.hasOne(CourseAssessment, { as: "assessment", foreignKey: "course_id" }); Course.hasMany(Certificate, { as: "certificates", foreignKey: "course_id" }); -Course.hasMany(CourseInstructor, { as: "instructors", foreignKey: "course_id" }); +Course.hasMany(CourseInstructor, { as: "instructors", foreignKey: "course_id" }); CourseInstructor.belongsTo(Course, { as: "course", foreignKey: "course_id" }); CourseInstructor.belongsTo(mdl_Users, { as: "user", foreignKey: "user_id" }); +Course.hasMany(CourseAchievement, { as: "courseAchievements", foreignKey: "course_id" }); +CourseAchievement.belongsTo(Course, { as: "course", foreignKey: "course_id" }); Course.belongsToMany(mdl_Category, { through: CourseProductCategory, foreignKey: 'course_id', otherKey: 'category_id', as: 'categories' }); mdl_Category.belongsToMany(Course, { through: CourseProductCategory, foreignKey: 'category_id', otherKey: 'course_id', as: 'courses' }); @@ -117,4 +120,5 @@ module.exports = { AssessmentSession, QuizSession, mdl_Category, Certificate, CourseInstructor, CourseReadingProgress, UnitReadingProgress, LessonReadingProgress, + CourseAchievement, }; \ No newline at end of file diff --git a/models/courses/courses.mdl.js b/models/courses/courses.mdl.js index 6cc9579..77e5d44 100644 --- a/models/courses/courses.mdl.js +++ b/models/courses/courses.mdl.js @@ -10,7 +10,10 @@ const Course = sequelize.define("Course", { order_index: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, hidden: false, order: 7, filterable: false }, level: { type: DataTypes.ENUM("beginner", "intermediate", "advanced"), allowNull: true, hidden: false, order: 3, filterable: true }, subscription: { type: DataTypes.STRING(50), allowNull: false, defaultValue: "free", hidden: false, order: 4, filterable: true }, - duration_seconds: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0, hidden: false, order: 8, filterable: false }, + duration_seconds: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0, hidden: false, order: 8, filterable: false }, + badge_color: { type: DataTypes.STRING(50), allowNull: true, defaultValue: "purple" }, + badge_asset_id: { type: DataTypes.BIGINT, allowNull: true }, + badge_image_url: { type: DataTypes.TEXT, allowNull: true }, createdBy: { type: DataTypes.BIGINT, allowNull: true }, updatedBy: { type: DataTypes.BIGINT, allowNull: true }, deletedBy: { type: DataTypes.BIGINT, allowNull: true }, diff --git a/models/tiers/payment_policies.mdl.js b/models/tiers/payment_policies.mdl.js new file mode 100644 index 0000000..623af95 --- /dev/null +++ b/models/tiers/payment_policies.mdl.js @@ -0,0 +1,36 @@ +const { DataTypes } = require('sequelize'); +const sequelize = require('../../config/db.config'); + +const mdl_PaymentPolicies = sequelize.define('PaymentPolicy', { + policy_id: { + type: DataTypes.BIGINT, + primaryKey: true, + autoIncrement: true, + }, + plan_id: { + type: DataTypes.BIGINT, + allowNull: true, + unique: true, + }, + promo_rules: { + type: DataTypes.JSONB, + allowNull: false, + defaultValue: [], + }, + refund_policy: { + type: DataTypes.JSONB, + allowNull: false, + defaultValue: { allowed: true, window_value: 5, window_unit: 'minutes', reason_required: false }, + }, + allowed_providers: { + type: DataTypes.JSONB, + allowNull: false, + defaultValue: ['paypal'], + }, +}, { + tableName: 'payment_policies', + timestamps: true, + paranoid: false, +}); + +module.exports = mdl_PaymentPolicies; diff --git a/models/tiers/plan_prices.mdl.js b/models/tiers/plan_prices.mdl.js new file mode 100644 index 0000000..01cdfef --- /dev/null +++ b/models/tiers/plan_prices.mdl.js @@ -0,0 +1,26 @@ +/*********************************************************************************************************************************************************************** + * File Name: plan_prices.mdl.js + * Type of Program: Model + * Description: Admin-managed localized price overrides for tier plans. + * One row per (plan_id, currency) pair. When a user's preferred_currency + * matches a row here, the override price is shown instead of the base price. + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jun. 29, 2026 + ***********************************************************************************************************************************************************************/ +'use strict'; + +const { DataTypes } = require('sequelize'); +const sequelize = require('../../config/db.config'); + +const mdl_PlanPrices = sequelize.define('PlanPrice', { + price_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + plan_id: { type: DataTypes.BIGINT, allowNull: false }, + currency: { type: DataTypes.CHAR(3), allowNull: false, label: 'Currency' }, + price: { type: DataTypes.DECIMAL(10, 2), allowNull: false, label: 'Price' }, +}, { + tableName: 'plan_prices', + timestamps: true, + paranoid: false, +}); + +module.exports = mdl_PlanPrices; diff --git a/models/tiers/tier.associations.js b/models/tiers/tier.associations.js index d5262a5..0d43f2d 100644 --- a/models/tiers/tier.associations.js +++ b/models/tiers/tier.associations.js @@ -5,6 +5,7 @@ const mdl_UserTiers = require('./user_tiers.mdl'); const mdl_Payments = require('./payments.mdl'); const mdl_PlanCourses = require('./plan_courses.mdl'); const mdl_PlanPolicies = require('./plan_policies.mdl'); +const mdl_PlanPrices = require('./plan_prices.mdl'); const mdl_SystemBadges = require('../system_badges/system_badges.mdl'); const Asset = require('../assets/assets.mdl'); const { Course } = require('../courses/courses.mdl'); @@ -53,6 +54,10 @@ mdl_TierPlans.hasMany(mdl_UserTiers, { foreignKey: 'plan_id', as: 'userTiers' mdl_TierPlans.hasOne(mdl_PlanPolicies, { foreignKey: 'plan_id', as: 'policy' }); mdl_PlanPolicies.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' }); +// ─── Plan ↔ Localized Prices ────────────────────────────────────────────────── +mdl_TierPlans.hasMany(mdl_PlanPrices, { foreignKey: 'plan_id', as: 'prices' }); +mdl_PlanPrices.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' }); + // ─── SystemBadge → Asset ───────────────────────────────────────────────────── mdl_SystemBadges.belongsTo(Asset, { foreignKey: 'asset_id', as: 'asset' }); @@ -63,5 +68,6 @@ module.exports = { mdl_Payments, mdl_PlanCourses, mdl_PlanPolicies, + mdl_PlanPrices, mdl_SystemBadges, }; diff --git a/models/tiers/tier_plans.attributes.js b/models/tiers/tier_plans.attributes.js index 69aa399..05c0a0e 100644 --- a/models/tiers/tier_plans.attributes.js +++ b/models/tiers/tier_plans.attributes.js @@ -7,7 +7,8 @@ ***********************************************************************************************************************************************************************/ const excludeAttributes = [ - // nothing hidden by default — all columns are safe to expose to admin + "tier_category_id", + "description", ]; const jsonbSchemas = {}; // no JSONB columns on this model diff --git a/models/tiers/tier_plans.mdl.js b/models/tiers/tier_plans.mdl.js index 935734d..38eff2e 100644 --- a/models/tiers/tier_plans.mdl.js +++ b/models/tiers/tier_plans.mdl.js @@ -13,8 +13,10 @@ const mdl_TierPlans = sequelize.define('TierPlan', { plan_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: 'Plan ID' }, tier_category_id: { type: DataTypes.BIGINT, allowNull: true, label: 'Tier Category' }, tier: { type: DataTypes.STRING(50), allowNull: false, label: 'Tier' }, - label: { type: DataTypes.STRING(100), allowNull: false, label: 'Plan Label' }, - duration_days: { type: DataTypes.INTEGER, allowNull: false, label: 'Duration (Days)' }, + label: { type: DataTypes.STRING(100), allowNull: false, label: 'Plan Label' }, + description: { type: DataTypes.TEXT, allowNull: true, label: 'Description' }, + duration_days: { type: DataTypes.FLOAT, allowNull: false, label: 'Duration (Days)' }, + duration_unit: { type: DataTypes.STRING(10), allowNull: false, defaultValue: 'day', label: 'Duration Unit' }, price: { type: DataTypes.DECIMAL(10, 2), allowNull: false, label: 'Price' }, currency: { type: DataTypes.CHAR(3), allowNull: false, defaultValue: 'USD', label: 'Currency' }, is_active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: 'Active' }, diff --git a/models/users/users.mdl.js b/models/users/users.mdl.js index b048d8c..0615b48 100644 --- a/models/users/users.mdl.js +++ b/models/users/users.mdl.js @@ -43,6 +43,9 @@ const mdl_Users = sequelize.define('User', { */ personal_info: { type: DataTypes.JSONB, defaultValue: null, label: "Personal Info", order: 2 }, + // ── Currency preference ────────────────────────────────────────────────────── + preferred_currency: { type: DataTypes.CHAR(3), allowNull: false, defaultValue: 'USD', label: 'Preferred Currency' }, + // ── Ban state ──────────────────────────────────────────────────────────────── is_banned: { type: DataTypes.BOOLEAN, defaultValue: false, allowNull: false, label: "Banned" }, ban_expires_at: { type: DataTypes.DATE, defaultValue: null, allowNull: true, label: "Ban Expires At" }, diff --git a/package-lock.json b/package-lock.json index f6aa416..fbe6860 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "uuid": "^9.0.0" }, "devDependencies": { + "jest": "^30.4.2", "nodemon": "^3.0.1", "sequelize-cli": "^6.6.5" } @@ -491,6 +492,623 @@ "node": ">=18.0.0" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@babel/core/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@babel/traverse/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -509,6 +1127,443 @@ "node": ">=12" } }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.4.1.tgz", + "integrity": "sha512-v3bhyxUh9Hgmo5p6hAOXe14/R3ZxZDOsvHleh4B07z3m/x4/ngPUXEm9XwK4sF4u+f+P2ORb0Ge+MgpaqRMVDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/core": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.4.2.tgz", + "integrity": "sha512-TZJA6cPJUFxoWhxaLo8t0VX/MZX2wPWr0uIDvLSHIvN4gu9h02vSzqI2kBADG1ExqQlC+cY09xKMSreivvrChQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.4.1", + "@jest/pattern": "30.4.0", + "@jest/reporters": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "exit-x": "^0.2.2", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-changed-files": "30.4.1", + "jest-config": "30.4.2", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-resolve-dependencies": "30.4.2", + "jest-runner": "30.4.2", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "jest-watcher": "30.4.1", + "pretty-format": "30.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/diff-sequences": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz", + "integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.4.1.tgz", + "integrity": "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-mock": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-ginrj6TMgh2GshLUGCjO94Ptx9HhdZA/I6A9iUfyeLKFtdAjnKzHDgzgP9HYQgbxM1lbXScQ2eUBz2lGeVDPWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "30.4.1", + "jest-snapshot": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.4.1.tgz", + "integrity": "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.4.1.tgz", + "integrity": "sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@sinonjs/fake-timers": "^15.4.0", + "@types/node": "*", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.4.1.tgz", + "integrity": "sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/types": "30.4.1", + "jest-mock": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz", + "integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.4.1.tgz", + "integrity": "sha512-/SnkPCzEQpUaBH81kjdEdDdo2WZl5hxw+BmLDGWjRkm8o7XlhjwsU36cqwe5PGBE5WYpBvDzRSdXx9rbGuJtNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@jridgewell/trace-mapping": "^0.3.25", + "@types/node": "*", + "chalk": "^4.1.2", + "collect-v8-coverage": "^1.0.2", + "exit-x": "^0.2.2", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^5.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", + "slash": "^3.0.0", + "string-length": "^4.0.2", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/snapshot-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.4.1.tgz", + "integrity": "sha512-ObY4ljvQ95mt6iwKtVLetR/4yXiAgl3H4nJxhztr0MTjrN97TwDYrnCp/kF60Ec9HdhkWTHSu+Hg05aXfngpOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", + "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "callsites": "^3.1.0", + "graceful-fs": "^4.2.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.4.1.tgz", + "integrity": "sha512-/ZG7pgEiOmmWkN9TplKbOu4id2N5lh7FHwRwlkgBVAzGdRH+OkkQ8wX/kIxg4zmd3ZQvAL1RwL2yWsvNYYECTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.4.1", + "@jest/types": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "collect-v8-coverage": "^1.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.4.1.tgz", + "integrity": "sha512-PeYE+4td5rKjoRPxztObrXU+H8hsjZfxKMXOcmrr34JerSyB/ROOxbbicz8B7A5j9R9VayDnVPvBmedqCsFCdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.4.1", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.4.1.tgz", + "integrity": "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/types": "30.4.1", + "@jridgewell/trace-mapping": "^0.3.25", + "babel-plugin-istanbul": "^7.0.1", + "chalk": "^4.1.2", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", + "pirates": "^4.0.7", + "slash": "^3.0.0", + "write-file-atomic": "^5.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, "node_modules/@nodable/entities": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", @@ -539,6 +1594,19 @@ "node": ">=14" } }, + "node_modules/@pkgr/core": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", + "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, "node_modules/@redis/bloom": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", @@ -553,7 +1621,6 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "license": "MIT", - "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -599,6 +1666,33 @@ "@redis/client": "^1.0.0" } }, + "node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, "node_modules/@smithy/core": { "version": "3.24.7", "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.7.tgz", @@ -719,6 +1813,62 @@ "node": ">=14.0.0" } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -728,6 +1878,33 @@ "@types/ms": "*" } }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -743,12 +1920,386 @@ "undici-types": "~7.19.0" } }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/validator": { "version": "13.15.10", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", "license": "MIT" }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.2.tgz", + "integrity": "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==", + "dev": true, + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/abbrev": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", @@ -781,6 +2332,22 @@ "node": ">= 14" } }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -839,6 +2406,16 @@ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", "license": "MIT" }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -877,6 +2454,105 @@ "proxy-from-env": "^2.1.0" } }, + "node_modules/babel-jest": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.4.1.tgz", + "integrity": "sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "30.4.1", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^7.0.1", + "babel-preset-jest": "30.4.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", + "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", + "dev": true, + "license": "BSD-3-Clause", + "workspaces": [ + "test/babel-8" + ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.4.0.tgz", + "integrity": "sha512-9EdtWM/sSfXLOGLwSn+GS6pIXyBnL07/8gyJlwFXjWy4DxMOyItqyUT29d4lQiS380EZwYlX7/At4PgBS+m2aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/babel__core": "^7.20.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.4.0.tgz", + "integrity": "sha512-lBY4jxsNmCnSiu7kquw8ZC9F4+XLMOKypT3RnNHPvU2Kpd4W0xaPuLr5ZkRyOsvLYAY4yaW1ZwTW4xB7NIiZzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "30.4.0", + "babel-preset-current-node-syntax": "^1.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-beta.1" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -907,6 +2583,19 @@ ], "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.40", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", + "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/bcryptjs": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", @@ -1012,6 +2701,50 @@ "node": ">=8" } }, + "node_modules/browserslist": { + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -1073,6 +2806,113 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chalk/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -1098,6 +2938,29 @@ "fsevents": "~2.3.2" } }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cliui": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", @@ -1198,6 +3061,24 @@ "node": ">=0.10.0" } }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1240,6 +3121,13 @@ "node": ">=14" } }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, "node_modules/concat-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", @@ -1299,6 +3187,13 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -1472,6 +3367,31 @@ "ms": "2.0.0" } }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1520,6 +3440,16 @@ ], "license": "MIT" }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -1627,6 +3557,26 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/electron-to-chromium": { + "version": "1.5.380", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.380.tgz", + "integrity": "sha512-W6d5AbuEoRayO447cqrg6lKJIlscgRnnxOZl/08kfV71BQDoEBC7Wwis68z87LjyK6f4kWyTaubuDbhHKrZkbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -1643,6 +3593,16 @@ "node": ">= 0.8" } }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1704,6 +3664,30 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -1713,12 +3697,70 @@ "node": ">= 0.6" } }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/exit-x": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", + "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.4.1", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/express": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -1765,7 +3807,6 @@ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-6.11.2.tgz", "integrity": "sha512-a7uwwfNTh1U60ssiIkuLFWHt4hAC5yxlLGU2VP0X4YNlyEDZAqF4tK3GD3NSitVBrCQmQ0++0uOyFOgC2y4DDw==", "license": "MIT", - "peer": true, "engines": { "node": ">= 14" }, @@ -1821,6 +3862,13 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-xml-builder": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", @@ -1858,6 +3906,16 @@ "fxparser": "src/cli/cli.js" } }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, "node_modules/ffprobe-static": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/ffprobe-static/-/ffprobe-static-3.1.0.tgz", @@ -1894,6 +3952,20 @@ "node": ">= 0.8" } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/fluent-ffmpeg": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.3.tgz", @@ -1995,6 +4067,13 @@ "node": ">=10" } }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2058,6 +4137,16 @@ "node": ">= 4" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -2092,6 +4181,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -2105,6 +4204,19 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -2280,6 +4392,13 @@ "node": ">= 0.4" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -2381,6 +4500,16 @@ "integrity": "sha512-SMS9V6Sn7VWaS11lYhoAr0ceoaiolTWf4jYdJn0NJhCdKMu9R2H9Fh0LBDWBHQF6HRLI1PmaePYsjanSpE5PEw==", "license": "MIT" }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -2400,6 +4529,36 @@ "dev": true, "license": "ISC" }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/inflection": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.4.tgz", @@ -2409,6 +4568,18 @@ ], "license": "MIT" }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -2431,6 +4602,13 @@ "node": ">= 0.10" } }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -2479,6 +4657,16 @@ "node": ">=8" } }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -2550,6 +4738,125 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -2566,6 +4873,758 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jest": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.4.2.tgz", + "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.4.2", + "@jest/types": "30.4.1", + "import-local": "^3.2.0", + "jest-cli": "30.4.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.4.1.tgz", + "integrity": "sha512-IuctmYrxi21iOSOaIXpJWalHyPAsVv0GeBHKDn8C1CA4W5htHn7INL+wdnL4Bo0+olEndvAFkmb++tIQJG+vvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.1.1", + "jest-util": "30.4.1", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-circus": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.4.2.tgz", + "integrity": "sha512-rvHH7VlY6LgbJXJTQ87GW62g1FntOtbhh0zT+v04kC+pgL6aBKyYINXxWukCpj3dcIBMw5/XUbtDS9dU9JTXeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "co": "^4.6.0", + "dedent": "^1.6.0", + "is-generator-fn": "^2.1.0", + "jest-each": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "p-limit": "^3.1.0", + "pretty-format": "30.4.1", + "pure-rand": "^7.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-cli": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.4.2.tgz", + "integrity": "sha512-jfA2ocvVHMXS2QijrJ0d31ektP+d/W0T5RpcTX2Pq+3sVqHlsXVCM2+FmwpL+bdY8OfHpIg9xMxLF17Zg0U49Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.4.2", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", + "chalk": "^4.1.2", + "exit-x": "^0.2.2", + "import-local": "^3.2.0", + "jest-config": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "yargs": "^17.7.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-cli/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-cli/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-cli/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/jest-cli/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-cli/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-cli/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-cli/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/jest-cli/node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/jest-cli/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/jest-config": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.4.2.tgz", + "integrity": "sha512-rNHAShJQqQwFNoL0hbf3BphSBOWnpOUAKvidLS/AjNVLPfoj5mSf4jQMfW3cYOs6hXeZC7nF7mDHaBnbxELOzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/get-type": "30.1.0", + "@jest/pattern": "30.4.0", + "@jest/test-sequencer": "30.4.1", + "@jest/types": "30.4.1", + "babel-jest": "30.4.1", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "deepmerge": "^4.3.1", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "jest-circus": "30.4.2", + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-runner": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "parse-json": "^5.2.0", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "esbuild-register": ">=3.4.0", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "esbuild-register": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz", + "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.4.0", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.4.0.tgz", + "integrity": "sha512-ZPMabUZCx5MpbZ2eBYSvZ0J8fvo3dR9oM+eeUpb3aKNQFuS2tu3Duw1TNlMoP8k3WQgKGJuhcMFvwcVuq6T7oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-each": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.4.1.tgz", + "integrity": "sha512-/8MJbH6fuj48TstjrMf+u/pd06Qezz5xOXvZA6442heNOWr8bdeoGZX2d9fCn028CoMgYmroH9//zky5GfyYmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.4.1", + "chalk": "^4.1.2", + "jest-util": "30.4.1", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.4.1.tgz", + "integrity": "sha512-4FZYVOk85hz2AyT6BbarKy9u37g6DbrDyCdFhsnDdXqyrueYQvB+0zO4f/kqLCRD0BsPRXPMNJeQwihKZV8naw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-mock": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.4.1.tgz", + "integrity": "sha512-rFrcONd8jeFsyw+Z9CrScJgglRf2+NFmNam8dKu7n+SoHqNYT47mn0DdEcVUZJpvh7Iz6/si7f7yUH7GJHVgnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", + "picomatch": "^4.0.3", + "walker": "^1.0.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.3" + } + }, + "node_modules/jest-haste-map/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/jest-leak-detector": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.4.1.tgz", + "integrity": "sha512-IpmyiioeHxiWDhesHnUFmOxcTzwCwKpgACgWajtAP+nYQXiY7DakTxB6Bx9JFiRMljr0AX1PvnQdaU1KFoz6NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz", + "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.4.1", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.4.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", + "picomatch": "^4.0.3", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/jest-mock": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", + "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", + "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.4.1.tgz", + "integrity": "sha512-Zry8Yq/yJcNAZ7dJ5F2heic8AheXvbFZ7XI5V+h28nrYZ7Qoyy4dItq8OodjnYD270mvX+ZudmrNV9cysqhW5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "jest-pnp-resolver": "^1.2.3", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "slash": "^3.0.0", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.4.2.tgz", + "integrity": "sha512-gDiVh1I+GxYzz9oXlyw+1wv6VOYX1WYxMOfjsA3iGKePV2oxmbHhwxfkALxNxYy1ciw6APWwkW2zZONwP97aEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "30.4.0", + "jest-snapshot": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runner": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.4.2.tgz", + "integrity": "sha512-2dw0PslVYXxffXGpLo+Ejad+KcI1Qkjn7f4X4619gf21oCUmL+SPfjqIa/losUem3yEOvfNZe/F1HWUcNpODcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.4.1", + "@jest/environment": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-haste-map": "30.4.1", + "jest-leak-detector": "30.4.1", + "jest-message-util": "30.4.1", + "jest-resolve": "30.4.1", + "jest-runtime": "30.4.2", + "jest-util": "30.4.1", + "jest-watcher": "30.4.1", + "jest-worker": "30.4.1", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.4.2.tgz", + "integrity": "sha512-3/5e8iPz2k/VLqlr8DgTftYyLUv8Su3FkCAO2/Od81UsUTpSxOrS6O5x5KkoQwyUjmpYyDJKeyAvg2T2nvpNkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/globals": "30.4.1", + "@jest/source-map": "30.0.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "cjs-module-lexer": "^2.1.0", + "collect-v8-coverage": "^1.0.2", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.4.1.tgz", + "integrity": "sha512-tEOkkfOMppUyeiHwjZswOQ3lcnoTnws/q5FnGIaeIh/jmoU0ZlgMYRR8sTlTj+nNGCoJ0RDq6SfxGxCsyMTPmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.4.1", + "@jest/get-type": "30.1.0", + "@jest/snapshot-utils": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "babel-preset-current-node-syntax": "^1.2.0", + "chalk": "^4.1.2", + "expect": "30.4.1", + "graceful-fs": "^4.2.11", + "jest-diff": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "pretty-format": "30.4.1", + "semver": "^7.7.2", + "synckit": "^0.11.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-util/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/jest-validate": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.4.1.tgz", + "integrity": "sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.4.1", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", + "leven": "^3.1.0", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.4.1.tgz", + "integrity": "sha512-/l9UonmvCwjHH7d2h3iAwIloLc1H0S8mJZ/LNK3i86hqwPAz8otUJjP9MfYtz9Tt77Su5FD2xGjZn8d31IZHlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "jest-util": "30.4.1", + "string-length": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.4.1.tgz", + "integrity": "sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.4.1", + "merge-stream": "^2.0.0", + "supports-color": "^8.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/js-beautify": { "version": "1.15.4", "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", @@ -2595,6 +5654,40 @@ "dev": true, "license": "MIT" }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/json-bigint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", @@ -2604,6 +5697,26 @@ "bignumber.js": "^9.0.0" } }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/jsonfile": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", @@ -2666,6 +5779,36 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -2721,6 +5864,32 @@ "dev": true, "license": "ISC" }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2748,6 +5917,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -2803,6 +5979,16 @@ "node": ">= 0.6" } }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -2875,6 +6061,29 @@ "url": "https://opencollective.com/express" } }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -2913,6 +6122,23 @@ } } }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/nodemailer": { "version": "6.10.1", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", @@ -3002,6 +6228,19 @@ "node": ">=0.10.0" } }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -3044,6 +6283,87 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -3051,6 +6371,25 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -3070,6 +6409,16 @@ "util": "^0.10.3" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-expression-matcher": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", @@ -3085,6 +6434,16 @@ "node": ">=14.0.0" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -3130,7 +6489,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -3246,6 +6604,29 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -3285,6 +6666,35 @@ "node": ">=0.10.0" } }, + "node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -3330,6 +6740,23 @@ "dev": true, "license": "MIT" }, + "node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/qs": { "version": "6.14.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", @@ -3390,6 +6817,22 @@ "node": ">= 0.8" } }, + "node_modules/react-is-18": { + "name": "react-is", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-is-19": { + "name": "react-is", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", + "dev": true, + "license": "MIT" + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -3466,6 +6909,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/retry-as-promised": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.1.1.tgz", @@ -3815,6 +7281,37 @@ "node": ">=10" } }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -3824,6 +7321,26 @@ "node": ">= 10.x" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -3850,6 +7367,43 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-length/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-length/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -3954,6 +7508,39 @@ "node": ">=8" } }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strnum": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.0.tgz", @@ -3995,6 +7582,97 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/synckit": { + "version": "0.11.13", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", + "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.3.6" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -4053,6 +7731,29 @@ "node": ">=0.6.x" } }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -4186,6 +7887,75 @@ "node": ">= 0.8" } }, + "node_modules/unrs-resolver": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.4" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/util": { "version": "0.10.4", "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", @@ -4230,6 +8000,21 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, "node_modules/validator": { "version": "13.15.35", "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", @@ -4248,6 +8033,16 @@ "node": ">= 0.8" } }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -4383,6 +8178,27 @@ "node": ">=8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/xml-naming": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", @@ -4496,6 +8312,19 @@ "engines": { "node": ">=8" } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index f3a7042..c85c5a0 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "start": "node server.js", "dev": "nodemon server.js", + "test": "jest", "db:migrate": "sequelize-cli db:migrate", "db:migrate:undo": "sequelize-cli db:migrate:undo", "db:migrate:undo:all": "sequelize-cli db:migrate:undo:all", @@ -46,7 +47,12 @@ "uuid": "^9.0.0" }, "devDependencies": { + "jest": "^30.4.2", "nodemon": "^3.0.1", "sequelize-cli": "^6.6.5" + }, + "jest": { + "testMatch": ["**/tests/**/*.test.js"], + "forceExit": true } } diff --git a/providers/paypal.provider.js b/providers/paypal.provider.js new file mode 100644 index 0000000..79c9de6 --- /dev/null +++ b/providers/paypal.provider.js @@ -0,0 +1,72 @@ +/*********************************************************************************************************************************************************************** + * File Name: paypal.provider.js + * Type of Program: Payment Provider + * Description: PayPal Orders API — create order, capture order, refund capture. + * Canonical provider used by payment.service.js via the provider registry. + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jun. 29, 2026 + ***********************************************************************************************************************************************************************/ +'use strict'; + +const axios = require('axios'); + +const BASE_URL = process.env.PAYPAL_ENV === 'live' + ? 'https://api-m.paypal.com' + : 'https://api-m.sandbox.paypal.com'; + +const getAccessToken = async () => { + const { data } = await axios.post( + `${BASE_URL}/v1/oauth2/token`, + 'grant_type=client_credentials', + { + auth: { + username: process.env.PAYPAL_CLIENT_ID, + password: process.env.PAYPAL_CLIENT_SECRET, + }, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + } + ); + return data.access_token; +}; + +exports.createOrder = async ({ amount, currency = 'USD', referenceId, returnUrl, cancelUrl }) => { + const token = await getAccessToken(); + const { data } = await axios.post( + `${BASE_URL}/v2/checkout/orders`, + { + intent: 'CAPTURE', + purchase_units: [{ + reference_id: referenceId, + amount: { currency_code: currency, value: String(amount) }, + }], + application_context: { + return_url: returnUrl ?? `${process.env.FRONTEND_URL}/plans/checkout`, + cancel_url: cancelUrl ?? `${process.env.FRONTEND_URL}/plans/checkout?cancelled=true`, + brand_name: process.env.PAYPAL_BRAND_NAME ?? 'STARR', + user_action: 'PAY_NOW', + }, + }, + { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } } + ); + return data; +}; + +exports.captureOrder = async (orderId) => { + const token = await getAccessToken(); + const { data } = await axios.post( + `${BASE_URL}/v2/checkout/orders/${orderId}/capture`, + {}, + { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } } + ); + return data; +}; + +exports.refundCapture = async (captureId, amount, currency = 'USD') => { + const token = await getAccessToken(); + const { data } = await axios.post( + `${BASE_URL}/v2/payments/captures/${captureId}/refund`, + { amount: { value: String(amount), currency_code: currency } }, + { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } } + ); + return data; +}; diff --git a/providers/registry.js b/providers/registry.js new file mode 100644 index 0000000..5bf0fe3 --- /dev/null +++ b/providers/registry.js @@ -0,0 +1,14 @@ +'use strict'; + +const paypal = require('./paypal.provider'); + +const PROVIDERS = { paypal }; + +module.exports = { + get(name) { + const p = PROVIDERS[name]; + if (!p) throw new Error(`Unknown payment provider: "${name}". Available: ${Object.keys(PROVIDERS).join(', ')}`); + return p; + }, + list: () => Object.keys(PROVIDERS), +}; diff --git a/routes/admin/admin.routes.js b/routes/admin/admin.routes.js index e472640..49c42d9 100644 --- a/routes/admin/admin.routes.js +++ b/routes/admin/admin.routes.js @@ -31,6 +31,7 @@ const assetsRoutes = require('./assets.routes'); const coursesRoutes = require('./courses.routes'); const taskRoutes = require('./task.routes'); const tiersRoutes = require('./tiers.routes'); +const tierPoliciesRoutes = require('./tier_policies.routes'); const tierCategoriesRoutes = require('./tier_categories.routes'); const categoriesRoutes = require('./categories.routes'); const productsRoutes = require('./products.routes'); @@ -51,7 +52,8 @@ router.use('/assets', assetsRoutes); router.use('/courses', coursesRoutes); router.use('/task-lists', taskRoutes); router.use('/tiers/categories', tierCategoriesRoutes); -router.use('/tiers', tiersRoutes); +router.use('/tiers', tiersRoutes); +router.use('/tier-policies', tierPoliciesRoutes); router.use('/categories', categoriesRoutes); router.use('/products', productsRoutes); router.use('/advertisements', advertisementRoutes); diff --git a/routes/admin/courses.routes.js b/routes/admin/courses.routes.js index 7b45e8d..aef89a3 100644 --- a/routes/admin/courses.routes.js +++ b/routes/admin/courses.routes.js @@ -39,6 +39,13 @@ router.delete("/:courseId", ctrl.archiveCourse); router.get("/:courseId/instructors", ctrl.getInstructors); router.put("/:courseId/instructors", ctrl.syncInstructors); +// ══════════════════════════════════════════════════════════════════════════════ +// COURSE ACHIEVEMENTS +// ══════════════════════════════════════════════════════════════════════════════ + +router.get("/:courseId/achievements", ctrl.getCourseAchievements); +router.put("/:courseId/achievements", ctrl.syncCourseAchievements); + // ══════════════════════════════════════════════════════════════════════════════ // PREREQUISITES // ══════════════════════════════════════════════════════════════════════════════ diff --git a/routes/admin/tier_policies.routes.js b/routes/admin/tier_policies.routes.js index f3936ed..8df2f5e 100644 --- a/routes/admin/tier_policies.routes.js +++ b/routes/admin/tier_policies.routes.js @@ -2,12 +2,18 @@ const express = require('express'); const router = express.Router(); const ctrl = require('../../controllers/admin/tier_policies.controller'); -// ─── Plan Policies ──────────────────────────────────────────────────────────── +// ─── Plan Policies (access rules) ──────────────────────────────────────────── // GET /admin/tiers/plans/:planId/policy -// PUT /admin/tiers/plans/:planId/policy — JSON: { badge_asset_id, badge_label, access_rules, … } +// PUT /admin/tiers/plans/:planId/policy — JSON: { access_rules } router.get('/plans/:planId/policy', ctrl.getPlanPolicy); router.put('/plans/:planId/policy', ctrl.upsertPlanPolicy); +// ─── Payment Policies (promo codes + refund window) ─────────────────────────── +// GET /admin/tiers/plans/:planId/payment-policy +// PUT /admin/tiers/plans/:planId/payment-policy — JSON: { promo_rules, refund_policy, allowed_providers } +router.get('/plans/:planId/payment-policy', ctrl.getPaymentPolicy); +router.put('/plans/:planId/payment-policy', ctrl.upsertPaymentPolicy); + // ─── System Badges ──────────────────────────────────────────────────────────── // GET /admin/tiers/system-badges // GET /admin/tiers/system-badges/:key diff --git a/routes/admin/tiers.routes.js b/routes/admin/tiers.routes.js index 0749df0..a351e25 100644 --- a/routes/admin/tiers.routes.js +++ b/routes/admin/tiers.routes.js @@ -1,6 +1,7 @@ -const express = require('express'); -const router = express.Router(); -const ctrl = require('../../controllers/admin/tiers.controller'); +const express = require('express'); +const router = express.Router(); +const ctrl = require('../../controllers/admin/tiers.controller'); +const priceCtrl = require('../../controllers/admin/plan_prices.controller'); router.get ('/', ctrl.getPlans); router.post ('/', ctrl.createPlan); @@ -16,10 +17,16 @@ router.get ('/users/:id/tiers', ctrl.getUserTiers); router.post ('/users/tiers/grant', ctrl.grantTier); router.patch ('/users/tiers/:tid/revoke', ctrl.revokeTier); -// ← course routes before /:id +// ← course + impact + prices routes before /:id +router.get ('/:id/impact', ctrl.getPlanImpact); router.get ('/:id/courses', ctrl.getPlanCourses); router.post ('/:id/courses', ctrl.syncPlanCourses); +router.get ('/:id/prices', priceCtrl.getPrices); +router.post ('/:id/prices', priceCtrl.addPrice); +router.put ('/:id/prices/:currency', priceCtrl.updatePrice); +router.delete('/:id/prices/:currency', priceCtrl.removePrice); + router.get ('/:id', ctrl.getPlan); router.put ('/:id', ctrl.updatePlan); router.delete('/:id', ctrl.archivePlan); diff --git a/routes/client/client.routes.js b/routes/client/client.routes.js index a6864f2..f1d71f6 100644 --- a/routes/client/client.routes.js +++ b/routes/client/client.routes.js @@ -50,6 +50,7 @@ router.use(authenticate, requireClient()); router.get('/profile', profileCtrl.getProfile); router.put('/profile', ...updateProfileValidator, validate, profileCtrl.updateProfile); router.delete('/profile', profileCtrl.deleteAccount); +router.patch('/profile/currency', profileCtrl.updateCurrency); router.post('/profile/avatar', handleAvatarUpload, profileCtrl.uploadAvatar); router.delete('/profile/avatar', profileCtrl.deleteAvatar); router.get('/sessions', profileCtrl.getSessions); diff --git a/routes/client/courses.routes.js b/routes/client/courses.routes.js index 9951f82..45a32b4 100644 --- a/routes/client/courses.routes.js +++ b/routes/client/courses.routes.js @@ -3,6 +3,9 @@ const router = express.Router(); const ctrl = require('../../controllers/client/courses.controller'); const progressCtrl = require('../../controllers/client/course_reading_progress.controller'); +// Course categories for filter chips — must come before /:courseId +router.get('/categories', ctrl.getCategories); + // Profile learning progress card — must come before /:courseId router.get('/in-progress', progressCtrl.getMyInProgressCourses); diff --git a/routes/client/tiers.routes.js b/routes/client/tiers.routes.js index 3945774..471e453 100644 --- a/routes/client/tiers.routes.js +++ b/routes/client/tiers.routes.js @@ -1,6 +1,7 @@ -const express = require('express'); -const router = express.Router(); -const ctrl = require('../../controllers/client/tiers.controller'); +const express = require('express'); +const router = express.Router(); +const ctrl = require('../../controllers/client/tiers.controller'); +const priceCtrl = require('../../controllers/admin/plan_prices.controller'); // My tier router.get ('/me', ctrl.getMyTier); @@ -9,12 +10,13 @@ router.get ('/me/history', ctrl.getMyTierHistory); // Plans router.get ('/plans', ctrl.getPlans); -// PayPal checkout +// Promo code validation +router.post('/promos/validate', ctrl.validatePromo); + +// Checkout router.post('/checkout/order', ctrl.createOrder); router.post('/checkout/capture', ctrl.captureOrder); router.post('/checkout/cancel', ctrl.cancelOrder); - -// PayPal refund router.post('/checkout/refund', ctrl.refundOrder); // My payments @@ -26,4 +28,7 @@ router.get ('/categories', ctrl.getCategories); // System badges (public read for profile display) router.get ('/system-badges', ctrl.getSystemBadges); +// Supported currencies (public — used by currency picker in settings + checkout) +router.get ('/currencies', priceCtrl.getCurrencies); + module.exports = router; \ No newline at end of file diff --git a/scripts/garage-init.sh b/scripts/garage-init.sh new file mode 100755 index 0000000..40cd1ff --- /dev/null +++ b/scripts/garage-init.sh @@ -0,0 +1,31 @@ +#!/bin/sh +# Wraps the Garage daemon: starts it in the background, runs one-time cluster +# initialization using the local RPC connection, then hands control back to +# the daemon process. Safe to re-run — all operations are idempotent. +set -e + +garage server & +DAEMON_PID=$! + +echo "Waiting for Garage to be ready..." +until garage status > /dev/null 2>&1; do + sleep 2 +done +echo "Garage is up. Running cluster initialization..." + +NODE_ID=$(garage node id 2>/dev/null | head -1 | awk '{print $1}') +garage layout assign "$NODE_ID" -z dc1 -c 100G 2>/dev/null || true +garage layout apply --version 1 2>/dev/null || true + +garage bucket create "$S3_BUCKET" 2>/dev/null || true + +# --yes skips the interactive confirmation prompt. +garage key import "$S3_ACCESS_KEY" "$S3_SECRET_KEY" -n starr-app --yes 2>/dev/null || true + +garage bucket allow "$S3_BUCKET" --read --write --owner --key "$S3_ACCESS_KEY" 2>/dev/null || true + +# Flag file read by the healthcheck — ensures the backend waits for full init. +touch /var/lib/garage/meta/.ready +echo "Garage init complete." + +wait $DAEMON_PID diff --git a/services/email.service.js b/services/email.service.js index 28c3b3e..d95b010 100644 --- a/services/email.service.js +++ b/services/email.service.js @@ -15,13 +15,20 @@ const nodemailer = require('nodemailer'); const { emailTemplates } = require('../data/email_body.data') +const port = Number(process.env.SMTP_PORT); + const transporter = nodemailer.createTransport({ - host: process.env.SMTP_HOST, - port: Number(process.env.SMTP_PORT), + host: process.env.SMTP_HOST, + port, + secure: port === 465, + requireTLS: port === 587, auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS, }, + tls: { + rejectUnauthorized: false, + }, }); const sendEmail = async ({ to, type, data = {} }) => { @@ -39,7 +46,7 @@ const sendEmail = async ({ to, type, data = {} }) => { { from: { name: "STARR System", - address: "do-not-reply@philproperties.com", + address: process.env.EMAIL_FROM, }, to, subject, diff --git a/services/payment.service.js b/services/payment.service.js new file mode 100644 index 0000000..27538ed --- /dev/null +++ b/services/payment.service.js @@ -0,0 +1,120 @@ +/*********************************************************************************************************************************************************************** + * File Name: payment.service.js + * Type of Program: Service + * Description: Provider-agnostic payment orchestration. + * - Loads payment policy per plan (promo rules, refund policy, allowed providers) + * - Evaluates promo codes server-side (type: flat | percent) + * - Calculates refund eligibility window (unit: minutes | hours | days) + * - Delegates create/capture/refund to the provider registry + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jun. 29, 2026 + ***********************************************************************************************************************************************************************/ +'use strict'; + +const registry = require('../providers/registry'); +const mdl_PaymentPolicies = require('../models/tiers/payment_policies.mdl'); +const mdl_Payments = require('../models/tiers/payments.mdl'); + +// ─── Defaults ───────────────────────────────────────────────────────────────── + +const DEFAULT_REFUND_POLICY = { + allowed: true, + window_value: 5, + window_unit: 'minutes', + reason_required: false, +}; + +const UNIT_MS = { minutes: 60_000, hours: 3_600_000, days: 86_400_000 }; + +// ─── Policy ─────────────────────────────────────────────────────────────────── + +async function getPolicyForPlan(plan_id) { + return mdl_PaymentPolicies.findOne({ where: { plan_id } }); +} + +// ─── Refund window ──────────────────────────────────────────────────────────── + +function getRefundWindowMs(policy) { + const rp = policy?.refund_policy ?? DEFAULT_REFUND_POLICY; + const { window_value = 5, window_unit = 'minutes' } = rp; + return Number(window_value) * (UNIT_MS[window_unit] ?? 60_000); +} + +function isRefundAllowed(policy) { + return policy?.refund_policy?.allowed ?? DEFAULT_REFUND_POLICY.allowed; +} + +// ─── Promo evaluation ───────────────────────────────────────────────────────── + +// effectivePrice — pass the localized price when charging in a non-base currency +// so discounts are computed against the actual amount being charged. +async function evaluatePromo(policy, plan, rawCode, effectivePrice = null) { + const code = rawCode?.trim?.().toUpperCase?.() ?? null; + if (!code) return { valid: false, reason: 'No promo code provided.' }; + + const rules = policy?.promo_rules ?? []; + const rule = rules.find((r) => r.code?.toUpperCase() === code); + if (!rule) return { valid: false, reason: 'Invalid promo code.' }; + + if (rule.expires_at && new Date(rule.expires_at) < new Date()) + return { valid: false, reason: 'Promo code has expired.' }; + + // Count how many completed payments used this code for this plan + if (rule.max_uses != null) { + const uses = await mdl_Payments.count({ + where: { promo_code: code, plan_id: plan.plan_id }, + }); + if (uses >= Number(rule.max_uses)) + return { valid: false, reason: 'Promo code has reached its usage limit.' }; + } + + const subtotal = effectivePrice !== null ? Number(effectivePrice) : Number(plan.price); + + if (rule.min_amount != null && subtotal < Number(rule.min_amount)) + return { valid: false, reason: `This promo code requires a minimum purchase of ${rule.min_amount}.` }; + + let discount; + if (rule.type === 'flat') { + discount = Math.min(Number(rule.value), subtotal); + } else if (rule.type === 'percent') { + const pct = Math.min(Number(rule.value), 100); + const raw = (subtotal * pct) / 100; + discount = rule.max_discount != null ? Math.min(raw, Number(rule.max_discount)) : raw; + discount = Math.min(discount, subtotal); + } else { + return { valid: false, reason: 'Unsupported promo type.' }; + } + + return { + valid: true, + code, + type: rule.type, + value: rule.value, + discount: Number(discount.toFixed(2)), + reason: null, + }; +} + +// ─── Provider delegation ────────────────────────────────────────────────────── + +function createOrder(provider, opts) { + return registry.get(provider).createOrder(opts); +} + +function captureOrder(provider, orderId) { + return registry.get(provider).captureOrder(orderId); +} + +function refundCapture(provider, captureId, amount, currency) { + return registry.get(provider).refundCapture(captureId, amount, currency); +} + +module.exports = { + getPolicyForPlan, + getRefundWindowMs, + isRefundAllowed, + evaluatePromo, + createOrder, + captureOrder, + refundCapture, +}; diff --git a/services/s3.service.js b/services/s3.service.js index 2d18a5f..6aebe12 100644 --- a/services/s3.service.js +++ b/services/s3.service.js @@ -10,8 +10,8 @@ // S3_REGION – garage // S3_ACCESS_KEY // S3_SECRET_KEY -// S3_BUCKET – philproperties -// S3_PUBLIC_URL – https://garage.philproperties.com +// S3_BUCKET – your-bucket-name +// S3_PUBLIC_URL – https://cdn.yourdomain.com const { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand, HeadBucketCommand } = require("@aws-sdk/client-s3"); const { getSignedUrl } = require("@aws-sdk/s3-request-presigner"); @@ -44,7 +44,7 @@ const s3Public = new S3Client({ forcePathStyle: true, }); -const DEFAULT_BUCKET = process.env.S3_BUCKET || "philproperties"; +const DEFAULT_BUCKET = process.env.S3_BUCKET; const PUBLIC_URL = (process.env.S3_PUBLIC_URL || "").replace(/\/$/, ""); // ─── Key prefix map ─────────────────────────────────────────────────────────── @@ -84,7 +84,7 @@ function buildKey(originalname, ownerType) { // Builds the public URL for a stored object. // Garage path-style: {S3_PUBLIC_URL}/{bucket}/{key} -// e.g. https://garage.philproperties.com/philproperties/images/uuid.jpg +// e.g. https://cdn.yourdomain.com/your-bucket/images/uuid.jpg function buildPublicUrl(key, bucket = DEFAULT_BUCKET) { return `${PUBLIC_URL}/${bucket}/${key}`; } diff --git a/tests/middleware/originGuard.test.js b/tests/middleware/originGuard.test.js new file mode 100644 index 0000000..98cb660 --- /dev/null +++ b/tests/middleware/originGuard.test.js @@ -0,0 +1,258 @@ +'use strict'; + +// Set env BEFORE requiring the middleware (ALLOWED array is built at load time) +process.env.ORIGIN_GUARD_DISABLED = 'false'; +process.env.NODE_ENV = 'development'; +process.env.ALLOWED_ORIGINS = 'http://localhost:5173,http://localhost:3024'; + +const originGuard = require('../../middleware/originGuard.middleware'); + +// ── Mock helpers ────────────────────────────────────────────────────────────── + +function makeReq({ method = 'GET', headers = {} } = {}) { + return { method, headers }; +} + +function makeRes() { + const res = { + _status: null, + _body: null, + status(code) { this._status = code; return this; }, + json(body) { this._body = body; return this; }, + }; + return res; +} + +// Minimal headers that represent a real browser fetch() call (SPA → API). +// localhost:5173 → localhost:3024 is same-site (same eTLD+1, different port). +const BROWSER_HEADERS = { + 'sec-fetch-site': 'same-site', + 'sec-fetch-mode': 'cors', + 'sec-fetch-dest': 'empty', + 'accept-language': 'en-US,en;q=0.9', +}; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('originGuard middleware', () => { + + // ── Layer 1a: Sec-Fetch-Site presence + value ───────────────────────────── + + test('1. GET with no Sec-Fetch-Site → 403 (Layer 1a: header absent)', () => { + const req = makeReq({ method: 'GET', headers: {} }); + const res = makeRes(); + const next = jest.fn(); + + originGuard(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res._status).toBe(403); + expect(res._body).toMatchObject({ status: 'error', message: 'Forbidden.' }); + }); + + test('2. GET with Sec-Fetch-Site: cross-site → 403 (Layer 1a: cross-site rejected)', () => { + const req = makeReq({ method: 'GET', headers: { 'sec-fetch-site': 'cross-site' } }); + const res = makeRes(); + const next = jest.fn(); + + originGuard(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res._status).toBe(403); + expect(res._body).toMatchObject({ status: 'error', message: 'Forbidden.' }); + }); + + // ── Layer 1b: Fetch Metadata family completeness + valid combo ──────────── + + test('3. GET with Sec-Fetch-Site but missing Mode and Dest → 403 (Layer 1b: incomplete family)', () => { + const req = makeReq({ method: 'GET', headers: { 'sec-fetch-site': 'same-site' } }); + const res = makeRes(); + const next = jest.fn(); + + originGuard(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res._status).toBe(403); + expect(res._body).toMatchObject({ status: 'error', message: 'Forbidden.' }); + }); + + test('4. GET with Sec-Fetch-Site + Mode but missing Dest → 403 (Layer 1b: partial family)', () => { + const req = makeReq({ + method: 'GET', + headers: { 'sec-fetch-site': 'same-site', 'sec-fetch-mode': 'cors' }, + }); + const res = makeRes(); + const next = jest.fn(); + + originGuard(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res._status).toBe(403); + }); + + test('5. GET with impossible combo (cors + document) → 403 (Layer 1b: invalid combination)', () => { + const req = makeReq({ + method: 'GET', + headers: { + 'sec-fetch-site': 'same-site', + 'sec-fetch-mode': 'cors', + 'sec-fetch-dest': 'document', + 'accept-language': 'en-US', + }, + }); + const res = makeRes(); + const next = jest.fn(); + + originGuard(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res._status).toBe(403); + }); + + test('6. GET with no-cors mode → 403 (Layer 1b: no-cors not expected on API server)', () => { + const req = makeReq({ + method: 'GET', + headers: { + 'sec-fetch-site': 'same-site', + 'sec-fetch-mode': 'no-cors', + 'sec-fetch-dest': 'empty', + 'accept-language': 'en-US', + }, + }); + const res = makeRes(); + const next = jest.fn(); + + originGuard(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res._status).toBe(403); + }); + + // ── Layer 2: Browser presence signals ──────────────────────────────────── + + test('7. GET with valid Fetch Metadata but no Sec-CH-UA and no Accept-Language → 403 (Layer 2: no browser fingerprint)', () => { + const req = makeReq({ + method: 'GET', + headers: { + 'sec-fetch-site': 'same-site', + 'sec-fetch-mode': 'cors', + 'sec-fetch-dest': 'empty', + }, + }); + const res = makeRes(); + const next = jest.fn(); + + originGuard(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res._status).toBe(403); + expect(res._body).toMatchObject({ status: 'error', message: 'Forbidden.' }); + }); + + // ── Layer 3: Origin allowlist ───────────────────────────────────────────── + + test('8. POST with full browser headers but foreign Origin → 403 (Layer 3: unlisted origin)', () => { + const req = makeReq({ + method: 'POST', + headers: { ...BROWSER_HEADERS, 'origin': 'http://attacker.com' }, + }); + const res = makeRes(); + const next = jest.fn(); + + originGuard(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res._status).toBe(403); + expect(res._body).toMatchObject({ status: 'error', message: 'Forbidden.' }); + }); + + test('9. POST with full browser headers but missing Origin → 403 (Layer 3: no origin header)', () => { + const req = makeReq({ method: 'POST', headers: { ...BROWSER_HEADERS } }); + const res = makeRes(); + const next = jest.fn(); + + originGuard(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res._status).toBe(403); + }); + + // ── Happy paths ─────────────────────────────────────────────────────────── + + test('10. GET with full browser headers (Accept-Language path) → passes', () => { + const req = makeReq({ method: 'GET', headers: { ...BROWSER_HEADERS } }); + const res = makeRes(); + const next = jest.fn(); + + originGuard(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(res._status).toBeNull(); + }); + + test('11. GET with Sec-CH-UA instead of Accept-Language (Chromium path) → passes', () => { + const req = makeReq({ + method: 'GET', + headers: { + 'sec-fetch-site': 'same-site', + 'sec-fetch-mode': 'cors', + 'sec-fetch-dest': 'empty', + 'sec-ch-ua': '"Chromium";v="137", "Not/A)Brand";v="24"', + }, + }); + const res = makeRes(); + const next = jest.fn(); + + originGuard(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(res._status).toBeNull(); + }); + + test('12. POST with full browser headers and allowed Origin → passes', () => { + const req = makeReq({ + method: 'POST', + headers: { ...BROWSER_HEADERS, 'origin': 'http://localhost:5173' }, + }); + const res = makeRes(); + const next = jest.fn(); + + originGuard(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(res._status).toBeNull(); + }); + + test('13. Direct browser navigation (navigate + document, site: none) → passes', () => { + const req = makeReq({ + method: 'GET', + headers: { + 'sec-fetch-site': 'none', + 'sec-fetch-mode': 'navigate', + 'sec-fetch-dest': 'document', + 'sec-fetch-user': '?1', + 'accept-language': 'en-US,en;q=0.9', + 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + }, + }); + const res = makeRes(); + const next = jest.fn(); + + originGuard(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(res._status).toBeNull(); + }); + + test('14. OPTIONS preflight → passes immediately (handled by cors())', () => { + const req = makeReq({ method: 'OPTIONS', headers: {} }); + const res = makeRes(); + const next = jest.fn(); + + originGuard(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(res._status).toBeNull(); + }); + +}); diff --git a/tests/middleware/rbac.test.js b/tests/middleware/rbac.test.js new file mode 100644 index 0000000..a0d6b63 --- /dev/null +++ b/tests/middleware/rbac.test.js @@ -0,0 +1,203 @@ +'use strict'; + +const { + requireClient, + requireStaff, + requireAdmin, + requireOwnerOrStaff, + requireOwnerOrAdmin, +} = require('../../middleware/rbac.middleware'); + +// ── Mock helpers ────────────────────────────────────────────────────────────── + +function makeRes() { + const res = { + _status: null, + _body: null, + status(code) { this._status = code; return this; }, + json(body) { this._body = body; return this; }, + }; + return res; +} + +function makeReq(accType = null, userId = null, paramId = null) { + return { + user: accType ? { user_id: userId, acc_type: accType } : null, + params: { id: paramId }, + }; +} + +// ── requireClient ───────────────────────────────────────────────────────────── + +describe('requireClient()', () => { + test('user role → passes', () => { + const next = jest.fn(); + requireClient()(makeReq('user'), makeRes(), next); + expect(next).toHaveBeenCalledTimes(1); + }); + + test('staff role → passes', () => { + const next = jest.fn(); + requireClient()(makeReq('staff'), makeRes(), next); + expect(next).toHaveBeenCalledTimes(1); + }); + + test('admin role → passes', () => { + const next = jest.fn(); + requireClient()(makeReq('admin'), makeRes(), next); + expect(next).toHaveBeenCalledTimes(1); + }); + + test('no req.user → 401', () => { + const res = makeRes(); + const next = jest.fn(); + requireClient()(makeReq(null), res, next); + expect(next).not.toHaveBeenCalled(); + expect(res._status).toBe(401); + }); +}); + +// ── requireStaff ────────────────────────────────────────────────────────────── + +describe('requireStaff()', () => { + test('staff role → passes', () => { + const next = jest.fn(); + requireStaff()(makeReq('staff'), makeRes(), next); + expect(next).toHaveBeenCalledTimes(1); + }); + + test('admin role → passes', () => { + const next = jest.fn(); + requireStaff()(makeReq('admin'), makeRes(), next); + expect(next).toHaveBeenCalledTimes(1); + }); + + test('user role → 403', () => { + const res = makeRes(); + const next = jest.fn(); + requireStaff()(makeReq('user'), res, next); + expect(next).not.toHaveBeenCalled(); + expect(res._status).toBe(403); + }); + + test('no req.user → 401', () => { + const res = makeRes(); + const next = jest.fn(); + requireStaff()(makeReq(null), res, next); + expect(next).not.toHaveBeenCalled(); + expect(res._status).toBe(401); + }); +}); + +// ── requireAdmin ────────────────────────────────────────────────────────────── + +describe('requireAdmin()', () => { + test('admin role → passes', () => { + const next = jest.fn(); + requireAdmin()(makeReq('admin'), makeRes(), next); + expect(next).toHaveBeenCalledTimes(1); + }); + + test('staff role → 403', () => { + const res = makeRes(); + const next = jest.fn(); + requireAdmin()(makeReq('staff'), res, next); + expect(next).not.toHaveBeenCalled(); + expect(res._status).toBe(403); + }); + + test('user role → 403', () => { + const res = makeRes(); + const next = jest.fn(); + requireAdmin()(makeReq('user'), res, next); + expect(next).not.toHaveBeenCalled(); + expect(res._status).toBe(403); + }); + + test('no req.user → 401', () => { + const res = makeRes(); + const next = jest.fn(); + requireAdmin()(makeReq(null), res, next); + expect(next).not.toHaveBeenCalled(); + expect(res._status).toBe(401); + }); +}); + +// ── requireOwnerOrStaff ─────────────────────────────────────────────────────── + +describe('requireOwnerOrStaff()', () => { + test('owner (user) accessing own resource → passes', () => { + const next = jest.fn(); + const req = { user: { user_id: 10, acc_type: 'user' }, params: { id: '10' } }; + requireOwnerOrStaff()(req, makeRes(), next); + expect(next).toHaveBeenCalledTimes(1); + }); + + test('staff accessing another user\'s resource → passes', () => { + const next = jest.fn(); + const req = { user: { user_id: 2, acc_type: 'staff' }, params: { id: '99' } }; + requireOwnerOrStaff()(req, makeRes(), next); + expect(next).toHaveBeenCalledTimes(1); + }); + + test('admin accessing another user\'s resource → passes', () => { + const next = jest.fn(); + const req = { user: { user_id: 1, acc_type: 'admin' }, params: { id: '99' } }; + requireOwnerOrStaff()(req, makeRes(), next); + expect(next).toHaveBeenCalledTimes(1); + }); + + test('user accessing another user\'s resource → 403', () => { + const res = makeRes(); + const next = jest.fn(); + const req = { user: { user_id: 10, acc_type: 'user' }, params: { id: '99' } }; + requireOwnerOrStaff()(req, res, next); + expect(next).not.toHaveBeenCalled(); + expect(res._status).toBe(403); + }); + + test('no req.user → 401', () => { + const res = makeRes(); + const next = jest.fn(); + const req = { user: null, params: { id: '10' } }; + requireOwnerOrStaff()(req, res, next); + expect(next).not.toHaveBeenCalled(); + expect(res._status).toBe(401); + }); +}); + +// ── requireOwnerOrAdmin ─────────────────────────────────────────────────────── + +describe('requireOwnerOrAdmin()', () => { + test('owner (user) accessing own resource → passes', () => { + const next = jest.fn(); + const req = { user: { user_id: 10, acc_type: 'user' }, params: { id: '10' } }; + requireOwnerOrAdmin()(req, makeRes(), next); + expect(next).toHaveBeenCalledTimes(1); + }); + + test('admin accessing another user\'s resource → passes', () => { + const next = jest.fn(); + const req = { user: { user_id: 1, acc_type: 'admin' }, params: { id: '99' } }; + requireOwnerOrAdmin()(req, makeRes(), next); + expect(next).toHaveBeenCalledTimes(1); + }); + + test('staff (non-owner) accessing another user\'s resource → 403', () => { + const res = makeRes(); + const next = jest.fn(); + const req = { user: { user_id: 2, acc_type: 'staff' }, params: { id: '99' } }; + requireOwnerOrAdmin()(req, res, next); + expect(next).not.toHaveBeenCalled(); + expect(res._status).toBe(403); + }); + + test('no req.user → 401', () => { + const res = makeRes(); + const next = jest.fn(); + const req = { user: null, params: { id: '10' } }; + requireOwnerOrAdmin()(req, res, next); + expect(next).not.toHaveBeenCalled(); + expect(res._status).toBe(401); + }); +}); diff --git a/tests/services/health.test.js b/tests/services/health.test.js new file mode 100644 index 0000000..850462e --- /dev/null +++ b/tests/services/health.test.js @@ -0,0 +1,235 @@ +'use strict'; + +// ── Env vars must be set BEFORE the service loads (enabled flags read process.env at load time) +process.env.DB_HOST = 'test-db-host'; +process.env.DB_PORT = '5432'; +process.env.S3_ENDPOINT = 'http://test-s3:3900'; +process.env.S3_PUBLIC_URL = 'https://cdn.example.com'; +process.env.S3_BUCKET = 'test-bucket'; +process.env.SMTP_HOST = 'smtp.test.com'; +process.env.SMTP_PORT = '587'; +process.env.REDIS_URL = 'redis://127.0.0.1:6379'; + +// ── Mock all infrastructure before the service module loads ─────────────────── +jest.mock('../../config/db.config', () => ({ authenticate: jest.fn() })); +jest.mock('../../config/redis.config', () => ({ ping: jest.fn() })); +jest.mock('../../services/s3.service', () => ({ ping: jest.fn() })); +jest.mock('../../services/email.service', () => ({ ping: jest.fn() })); + +const db = require('../../config/db.config'); +const cache = require('../../config/redis.config'); +const { ping: s3 } = require('../../services/s3.service'); +const { ping: smtp } = require('../../services/email.service'); +const { runDashboard, runReadiness } = require('../../services/health.service'); + +// ── Reset mocks between tests ───────────────────────────────────────────────── +beforeEach(() => jest.clearAllMocks()); + +// ── Preset helpers ──────────────────────────────────────────────────────────── +const resolve = (fn) => fn.mockResolvedValue(); + +function allHealthy() { + resolve(db.authenticate); + cache.ping.mockResolvedValue('PONG'); + resolve(s3); + resolve(smtp); +} + +// ── runDashboard ────────────────────────────────────────────────────────────── + +describe('runDashboard()', () => { + + test('all healthy → status=healthy, HTTP 200', async () => { + allHealthy(); + const { httpStatus, body } = await runDashboard(); + expect(httpStatus).toBe(200); + expect(body.status).toBe('healthy'); + }); + + test('database down (critical) → status=unhealthy, HTTP 503', async () => { + db.authenticate.mockRejectedValue(new Error('Connection refused')); + cache.ping.mockResolvedValue('PONG'); + resolve(s3); + resolve(smtp); + + const { httpStatus, body } = await runDashboard(); + expect(httpStatus).toBe(503); + expect(body.status).toBe('unhealthy'); + }); + + test('S3 down (non-critical) → status=degraded, HTTP 200', async () => { + resolve(db.authenticate); + cache.ping.mockResolvedValue('PONG'); + s3.mockRejectedValue(new Error('S3 storage unavailable')); + resolve(smtp); + + const { httpStatus, body } = await runDashboard(); + expect(httpStatus).toBe(200); + expect(body.status).toBe('degraded'); + }); + + test('SMTP down (non-critical) → degraded, HTTP 200', async () => { + resolve(db.authenticate); + cache.ping.mockResolvedValue('PONG'); + resolve(s3); + smtp.mockRejectedValue(new Error('SMTP unreachable')); + + const { httpStatus, body } = await runDashboard(); + expect(httpStatus).toBe(200); + expect(body.status).toBe('degraded'); + }); + + test('both DB and S3 down → unhealthy (critical takes precedence), HTTP 503', async () => { + db.authenticate.mockRejectedValue(new Error('refused')); + cache.ping.mockResolvedValue('PONG'); + s3.mockRejectedValue(new Error('s3 down')); + resolve(smtp); + + const { httpStatus, body } = await runDashboard(); + expect(httpStatus).toBe(503); + expect(body.status).toBe('unhealthy'); + }); + + test('body has app, system, uptime, timestamp, services', async () => { + allHealthy(); + const { body } = await runDashboard(); + expect(body).toHaveProperty('app'); + expect(body).toHaveProperty('system'); + expect(body).toHaveProperty('uptime'); + expect(body).toHaveProperty('timestamp'); + expect(body).toHaveProperty('services'); + }); + + test('app info has correct shape', async () => { + allHealthy(); + const { body } = await runDashboard(); + expect(body.app).toMatchObject({ + name: expect.any(String), + version: expect.any(String), + environment: expect.any(String), + node_version: expect.stringMatching(/^v\d+/), + pid: expect.any(Number), + }); + }); + + test('database connection label is "established" when healthy', async () => { + allHealthy(); + const { body } = await runDashboard(); + expect(body.services.database.connection).toBe('established'); + }); + + test('database connection label is "unavailable" when down', async () => { + db.authenticate.mockRejectedValue(new Error('refused')); + cache.ping.mockResolvedValue('PONG'); + resolve(s3); + resolve(smtp); + + const { body } = await runDashboard(); + expect(body.services.database.connection).toBe('unavailable'); + }); + + test('latency_ms is a non-negative number for enabled checks', async () => { + allHealthy(); + const { body } = await runDashboard(); + expect(typeof body.services.database.latency_ms).toBe('number'); + expect(body.services.database.latency_ms).toBeGreaterThanOrEqual(0); + }); + + test('timestamp is a valid ISO 8601 string', async () => { + allHealthy(); + const { body } = await runDashboard(); + expect(new Date(body.timestamp).toISOString()).toBe(body.timestamp); + }); +}); + +// ── runReadiness ────────────────────────────────────────────────────────────── + +describe('runReadiness()', () => { + + test('all healthy → status=healthy, HTTP 200', async () => { + allHealthy(); + const { httpStatus, body } = await runReadiness(); + expect(httpStatus).toBe(200); + expect(body.status).toBe('healthy'); + }); + + test('database down → HTTP 503', async () => { + db.authenticate.mockRejectedValue(new Error('refused')); + cache.ping.mockResolvedValue('PONG'); + resolve(s3); + resolve(smtp); + + const { httpStatus } = await runReadiness(); + expect(httpStatus).toBe(503); + }); + + test('S3 down → HTTP 200, degraded — non-critical stays routable', async () => { + resolve(db.authenticate); + cache.ping.mockResolvedValue('PONG'); + s3.mockRejectedValue(new Error('S3 storage unavailable')); + resolve(smtp); + + const { httpStatus, body } = await runReadiness(); + expect(httpStatus).toBe(200); + expect(body.status).toBe('degraded'); + }); + + test('body has version, uptime, timestamp, checks, memory', async () => { + allHealthy(); + const { body } = await runReadiness(); + expect(body).toHaveProperty('version'); + expect(body).toHaveProperty('uptime'); + expect(body).toHaveProperty('timestamp'); + expect(body).toHaveProperty('checks'); + expect(body).toHaveProperty('memory'); + }); + + test('checks object has database, cache, storage, smtp keys', async () => { + allHealthy(); + const { body } = await runReadiness(); + ['database', 'cache', 'storage', 'smtp'].forEach((key) => { + expect(body.checks).toHaveProperty(key); + }); + }); + + test('memory snapshot has heap_used_mb, heap_total_mb, rss_mb, external_mb', async () => { + allHealthy(); + const { body } = await runReadiness(); + expect(body.memory).toMatchObject({ + heap_used_mb: expect.any(Number), + heap_total_mb: expect.any(Number), + rss_mb: expect.any(Number), + external_mb: expect.any(Number), + }); + }); + + test('database check entry has status=unhealthy and critical=true when DB fails', async () => { + db.authenticate.mockRejectedValue(new Error('refused')); + cache.ping.mockResolvedValue('PONG'); + resolve(s3); + resolve(smtp); + + const { body } = await runReadiness(); + expect(body.checks.database.status).toBe('unhealthy'); + expect(body.checks.database.critical).toBe(true); + }); + + test('storage check entry has status=unhealthy and critical=false when S3 fails', async () => { + resolve(db.authenticate); + cache.ping.mockResolvedValue('PONG'); + s3.mockRejectedValue(new Error('S3 storage unavailable')); + resolve(smtp); + + const { body } = await runReadiness(); + expect(body.checks.storage.status).toBe('unhealthy'); + expect(body.checks.storage.critical).toBe(false); + }); + + test('cache check entry has status=skipped when redis is null (CACHE_DRIVER=memory)', async () => { + allHealthy(); + const { body } = await runReadiness(); + // cache mock returns { ping: fn } so it is enabled; but the skipped test + // is covered when redis module exports null — we verify the shape here instead + expect(['healthy', 'skipped']).toContain(body.checks.cache.status); + }); +}); diff --git a/tests/utils/accessPolicy.test.js b/tests/utils/accessPolicy.test.js new file mode 100644 index 0000000..83f2430 --- /dev/null +++ b/tests/utils/accessPolicy.test.js @@ -0,0 +1,143 @@ +'use strict'; + +const { evaluateCourseAccess, TIER_RANK } = require('../../utils/accessPolicy.util'); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function ctx(tier = 'free', access_rules = [], group_ids = []) { + return { tier, access_rules, group_ids }; +} + +function course(subscription = 'free') { + return { subscription }; +} + +// ── Free course is always accessible ───────────────────────────────────────── + +describe('free course (rank 0)', () => { + test('free user can access free course', () => { + expect(evaluateCourseAccess(ctx('free'), course('free'))).toEqual({ allowed: true, reason: null }); + }); + + test('premium user can access free course', () => { + expect(evaluateCourseAccess(ctx('premium'), course('free'))).toEqual({ allowed: true, reason: null }); + }); + + test('free user with no rules can still access free course', () => { + expect(evaluateCourseAccess(ctx('free', []), course('free'))).toEqual({ allowed: true, reason: null }); + }); +}); + +// ── No access_rules — fallback tier rank comparison ────────────────────────── + +describe('no access_rules (fallback mode)', () => { + test('premium user can access premium course', () => { + const result = evaluateCourseAccess(ctx('premium', []), course('premium'), TIER_RANK); + expect(result).toEqual({ allowed: true, reason: null }); + }); + + test('exclusive user can access exclusive course', () => { + const result = evaluateCourseAccess(ctx('exclusive', []), course('exclusive'), TIER_RANK); + expect(result).toEqual({ allowed: true, reason: null }); + }); + + test('free user cannot access premium course', () => { + const result = evaluateCourseAccess(ctx('free', []), course('premium'), TIER_RANK); + expect(result).toEqual({ allowed: false, reason: 'tier_rank' }); + }); + + test('premium user cannot access exclusive course', () => { + const result = evaluateCourseAccess(ctx('premium', []), course('exclusive'), TIER_RANK); + expect(result).toEqual({ allowed: false, reason: 'tier_rank' }); + }); + + test('unknown course subscription slug → denied (safe default)', () => { + const result = evaluateCourseAccess(ctx('exclusive', []), course('unknown-tier'), TIER_RANK); + expect(result.allowed).toBe(false); + }); +}); + +// ── Rule: course_subscription_access ───────────────────────────────────────── + +describe('rule: course_subscription_access', () => { + const rules = [{ type: 'course_subscription_access', levels: ['free', 'premium'] }]; + + test('plan covers premium → allowed', () => { + const result = evaluateCourseAccess(ctx('premium', rules), course('premium'), TIER_RANK); + expect(result.allowed).toBe(true); + }); + + test('plan does not cover exclusive → denied', () => { + const result = evaluateCourseAccess(ctx('exclusive', rules), course('exclusive'), TIER_RANK); + expect(result).toEqual({ allowed: false, reason: 'subscription_access' }); + }); +}); + +// ── Rule: required_active_tier ──────────────────────────────────────────────── + +describe('rule: required_active_tier', () => { + const rules = [{ type: 'required_active_tier', tier: 'premium' }]; + + test('premium user meets premium requirement → allowed', () => { + const result = evaluateCourseAccess(ctx('premium', rules), course('premium'), TIER_RANK); + expect(result.allowed).toBe(true); + }); + + test('exclusive user exceeds premium requirement → allowed', () => { + const result = evaluateCourseAccess(ctx('exclusive', rules), course('premium'), TIER_RANK); + expect(result.allowed).toBe(true); + }); + + test('free user fails premium requirement → denied', () => { + const result = evaluateCourseAccess(ctx('free', rules), course('premium'), TIER_RANK); + expect(result).toEqual({ allowed: false, reason: 'required_tier' }); + }); + + test('unknown required tier slug → always denied', () => { + const badRules = [{ type: 'required_active_tier', tier: 'ghost-tier' }]; + const result = evaluateCourseAccess(ctx('exclusive', badRules), course('premium'), TIER_RANK); + expect(result.allowed).toBe(false); + }); +}); + +// ── Rule: group_restriction ─────────────────────────────────────────────────── + +describe('rule: group_restriction', () => { + const rules = [{ type: 'group_restriction', group_ids: [5, 10] }]; + + test('user in allowed group → allowed', () => { + const result = evaluateCourseAccess(ctx('premium', rules, [10, 20]), course('premium'), TIER_RANK); + expect(result.allowed).toBe(true); + }); + + test('user not in any allowed group → denied', () => { + const result = evaluateCourseAccess(ctx('premium', rules, [99]), course('premium'), TIER_RANK); + expect(result).toEqual({ allowed: false, reason: 'group_restriction' }); + }); + + test('empty group_ids on rule → no restriction (passes)', () => { + const openRules = [{ type: 'group_restriction', group_ids: [] }]; + const result = evaluateCourseAccess(ctx('premium', openRules, []), course('premium'), TIER_RANK); + expect(result.allowed).toBe(true); + }); +}); + +// ── Multiple rules evaluated together ──────────────────────────────────────── + +describe('multiple rules', () => { + test('fails if any one rule blocks', () => { + const rules = [ + { type: 'course_subscription_access', levels: ['premium', 'exclusive'] }, + { type: 'required_active_tier', tier: 'premium' }, + { type: 'group_restriction', group_ids: [7] }, + ]; + // All pass + expect(evaluateCourseAccess(ctx('premium', rules, [7]), course('premium'), TIER_RANK).allowed).toBe(true); + + // Fails group_restriction + expect(evaluateCourseAccess(ctx('premium', rules, [99]), course('premium'), TIER_RANK).allowed).toBe(false); + + // Fails required_active_tier + expect(evaluateCourseAccess(ctx('free', rules, [7]), course('premium'), TIER_RANK).allowed).toBe(false); + }); +}); diff --git a/tests/utils/datetime.test.js b/tests/utils/datetime.test.js new file mode 100644 index 0000000..7590b32 --- /dev/null +++ b/tests/utils/datetime.test.js @@ -0,0 +1,74 @@ +'use strict'; + +const { fmtDate, fmtDateTime, fmtTime } = require('../../utils/datetime.util'); + +const ISO = '2026-06-20T14:30:00.000Z'; // Saturday, June 20, 2026, 2:30 PM UTC + +// ── fmtDate ─────────────────────────────────────────────────────────────────── + +describe('fmtDate()', () => { + test('null → returns —', () => { + expect(fmtDate(null)).toBe('—'); + }); + + test('empty string → returns —', () => { + expect(fmtDate('')).toBe('—'); + }); + + test('formats year correctly', () => { + expect(fmtDate(ISO)).toContain('2026'); + }); + + test('formats month correctly (June)', () => { + expect(fmtDate(ISO)).toContain('June'); + }); + + test('formats day correctly (20)', () => { + expect(fmtDate(ISO)).toContain('20'); + }); + + test('accepts Date object as well as ISO string', () => { + const d = new Date(ISO); + expect(fmtDate(d)).toContain('2026'); + }); +}); + +// ── fmtDateTime ─────────────────────────────────────────────────────────────── + +describe('fmtDateTime()', () => { + test('null → returns —', () => { + expect(fmtDateTime(null)).toBe('—'); + }); + + test('contains date portion', () => { + expect(fmtDateTime(ISO)).toContain('2026'); + expect(fmtDateTime(ISO)).toContain('June'); + }); + + test('contains time portion (2:30 PM UTC)', () => { + const out = fmtDateTime(ISO); + expect(out).toMatch(/2:30/); + expect(out).toContain('UTC'); + }); +}); + +// ── fmtTime ─────────────────────────────────────────────────────────────────── + +describe('fmtTime()', () => { + test('null → returns —', () => { + expect(fmtTime(null)).toBe('—'); + }); + + test('outputs time with AM/PM', () => { + expect(fmtTime(ISO)).toMatch(/\d+:\d{2}\s*(AM|PM)/); + }); + + test('contains timezone label (UTC)', () => { + expect(fmtTime(ISO)).toContain('UTC'); + }); + + test('locale option changes output', () => { + const en = fmtTime(ISO, { locale: 'en-US' }); + expect(en).toBeTruthy(); + }); +}); diff --git a/tests/utils/otp.test.js b/tests/utils/otp.test.js new file mode 100644 index 0000000..d826dc5 --- /dev/null +++ b/tests/utils/otp.test.js @@ -0,0 +1,83 @@ +'use strict'; + +const { generateOTP, getOTPExpiry, isOTPExpired } = require('../../utils/otp.util'); + +// ── generateOTP ─────────────────────────────────────────────────────────────── + +describe('generateOTP()', () => { + test('returns a 6-character string', () => { + expect(generateOTP()).toHaveLength(6); + }); + + test('contains only digits', () => { + expect(generateOTP()).toMatch(/^\d{6}$/); + }); + + test('value is within 000000-999999', () => { + const n = Number(generateOTP()); + expect(n).toBeGreaterThanOrEqual(0); + expect(n).toBeLessThanOrEqual(999999); + }); + + test('zero-pads values under 100000', () => { + // Run many samples to catch low values; deterministic check via string length + for (let i = 0; i < 50; i++) { + expect(generateOTP()).toHaveLength(6); + } + }); +}); + +// ── getOTPExpiry ────────────────────────────────────────────────────────────── + +describe('getOTPExpiry()', () => { + test('returns a Date instance', () => { + expect(getOTPExpiry()).toBeInstanceOf(Date); + }); + + test('expiry is in the future', () => { + expect(getOTPExpiry().getTime()).toBeGreaterThan(Date.now()); + }); + + test('default offset is approximately 10 minutes', () => { + const before = Date.now(); + const expiry = getOTPExpiry().getTime(); + const after = Date.now(); + const tenMin = 10 * 60 * 1000; + expect(expiry).toBeGreaterThanOrEqual(before + tenMin - 100); + expect(expiry).toBeLessThanOrEqual(after + tenMin + 100); + }); + + test('custom minutes are respected', () => { + const before = Date.now(); + const expiry = getOTPExpiry(5).getTime(); + const fiveMin = 5 * 60 * 1000; + expect(expiry).toBeGreaterThanOrEqual(before + fiveMin - 100); + }); +}); + +// ── isOTPExpired ────────────────────────────────────────────────────────────── + +describe('isOTPExpired()', () => { + test('null → expired (true)', () => { + expect(isOTPExpired(null)).toBe(true); + }); + + test('undefined → expired (true)', () => { + expect(isOTPExpired(undefined)).toBe(true); + }); + + test('past date → expired (true)', () => { + const past = new Date(Date.now() - 60_000); + expect(isOTPExpired(past)).toBe(true); + }); + + test('future date → not expired (false)', () => { + const future = new Date(Date.now() + 60_000); + expect(isOTPExpired(future)).toBe(false); + }); + + test('accepts ISO string as well as Date', () => { + const future = new Date(Date.now() + 60_000).toISOString(); + expect(isOTPExpired(future)).toBe(false); + }); +}); diff --git a/tests/utils/response.test.js b/tests/utils/response.test.js new file mode 100644 index 0000000..c752acf --- /dev/null +++ b/tests/utils/response.test.js @@ -0,0 +1,92 @@ +'use strict'; + +const R = require('../../utils/response.util'); + +function makeRes() { + const res = { + _status: null, + _body: null, + status(code) { this._status = code; return this; }, + json(body) { this._body = body; return this; }, + }; + return res; +} + +// ── R.success ───────────────────────────────────────────────────────────────── + +describe('R.success()', () => { + test('default status is 200', () => { + const res = makeRes(); + R.success(res, 'OK'); + expect(res._status).toBe(200); + }); + + test('envelope shape: status=success, message, data', () => { + const res = makeRes(); + R.success(res, 'Created', { id: 1 }, 201); + expect(res._body).toEqual({ status: 'success', message: 'Created', data: { id: 1 } }); + }); + + test('custom status code is used', () => { + const res = makeRes(); + R.success(res, 'Created', null, 201); + expect(res._status).toBe(201); + }); + + test('data is null when omitted', () => { + const res = makeRes(); + R.success(res, 'OK'); + expect(res._body.data).toBeNull(); + }); +}); + +// ── R.error ─────────────────────────────────────────────────────────────────── + +describe('R.error()', () => { + test('default status is 500', () => { + const res = makeRes(); + R.error(res, 'Something broke'); + expect(res._status).toBe(500); + }); + + test('envelope shape: status=error, message', () => { + const res = makeRes(); + R.error(res, 'Not found', 404); + expect(res._body).toMatchObject({ status: 'error', message: 'Not found' }); + expect(res._status).toBe(404); + }); + + test('errors field is absent when not provided', () => { + const res = makeRes(); + R.error(res, 'Bad request', 400); + expect(res._body).not.toHaveProperty('errors'); + }); + + test('errors field is included when provided', () => { + const res = makeRes(); + const errs = [{ field: 'email', msg: 'Invalid' }]; + R.error(res, 'Validation failed', 422, errs); + expect(res._body.errors).toEqual(errs); + }); +}); + +// ── R.validationError ───────────────────────────────────────────────────────── + +describe('R.validationError()', () => { + test('status is always 422', () => { + const res = makeRes(); + R.validationError(res, []); + expect(res._status).toBe(422); + }); + + test('envelope shape: status=error, message=Validation failed, errors', () => { + const res = makeRes(); + const errs = [{ field: 'name', msg: 'Required' }]; + R.validationError(res, errs); + expect(res._body).toEqual({ + status: 'error', + message: 'Validation failed', + errors: errs, + }); + }); +}); diff --git a/tests/utils/token.test.js b/tests/utils/token.test.js new file mode 100644 index 0000000..7d62f02 --- /dev/null +++ b/tests/utils/token.test.js @@ -0,0 +1,128 @@ +'use strict'; + +// Set secrets BEFORE requiring — jwt.sign/verify reads process.env at call time +// but we want a clean, isolated secret that does not change across test runs. +process.env.JWT_SECRET = 'test-jwt-secret-32chars-padding!!'; +process.env.JWT_REFRESH_SECRET = 'test-refresh-secret-32chars-pad!!'; +process.env.JWT_EXPIRES_IN = '15m'; +process.env.JWT_REFRESH_EXPIRES_IN = '7d'; + +const jwt = require('jsonwebtoken'); +const { + generateTokens, + verifyAccessToken, + verifyRefreshToken, + hashToken, + shouldRotateRefreshToken, +} = require('../../utils/token.util'); + +const MOCK_USER = { user_id: 1, email: 'test@example.com', acc_type: 'user', reg_type: 'system' }; + +// ── generateTokens ──────────────────────────────────────────────────────────── + +describe('generateTokens()', () => { + test('returns accessToken and refreshToken strings', () => { + const { accessToken, refreshToken } = generateTokens(MOCK_USER); + expect(typeof accessToken).toBe('string'); + expect(typeof refreshToken).toBe('string'); + }); + + test('accessToken contains correct payload fields', () => { + const { accessToken } = generateTokens(MOCK_USER); + const decoded = jwt.decode(accessToken); + expect(decoded.user_id).toBe(MOCK_USER.user_id); + expect(decoded.email).toBe(MOCK_USER.email); + expect(decoded.acc_type).toBe(MOCK_USER.acc_type); + expect(decoded.reg_type).toBe(MOCK_USER.reg_type); + }); + + test('refreshToken contains only user_id', () => { + const { refreshToken } = generateTokens(MOCK_USER); + const decoded = jwt.decode(refreshToken); + expect(decoded.user_id).toBe(MOCK_USER.user_id); + expect(decoded.email).toBeUndefined(); + }); +}); + +// ── verifyAccessToken ───────────────────────────────────────────────────────── + +describe('verifyAccessToken()', () => { + test('valid token → returns decoded payload', () => { + const { accessToken } = generateTokens(MOCK_USER); + const decoded = verifyAccessToken(accessToken); + expect(decoded.user_id).toBe(MOCK_USER.user_id); + }); + + test('tampered token → throws JsonWebTokenError', () => { + const { accessToken } = generateTokens(MOCK_USER); + expect(() => verifyAccessToken(accessToken + 'tampered')).toThrow(); + }); + + test('expired token → throws TokenExpiredError', () => { + const expired = jwt.sign( + { user_id: 99, exp: Math.floor(Date.now() / 1000) - 10 }, + process.env.JWT_SECRET + ); + let err; + try { verifyAccessToken(expired); } catch (e) { err = e; } + expect(err.name).toBe('TokenExpiredError'); + }); + + test('token signed with wrong secret → throws', () => { + const bad = jwt.sign({ user_id: 1 }, 'wrong-secret'); + expect(() => verifyAccessToken(bad)).toThrow(); + }); +}); + +// ── verifyRefreshToken ──────────────────────────────────────────────────────── + +describe('verifyRefreshToken()', () => { + test('valid refresh token → returns payload with user_id', () => { + const { refreshToken } = generateTokens(MOCK_USER); + const decoded = verifyRefreshToken(refreshToken); + expect(decoded.user_id).toBe(MOCK_USER.user_id); + }); + + test('access token used as refresh token → throws (different secret)', () => { + const { accessToken } = generateTokens(MOCK_USER); + expect(() => verifyRefreshToken(accessToken)).toThrow(); + }); +}); + +// ── hashToken ───────────────────────────────────────────────────────────────── + +describe('hashToken()', () => { + test('returns a 64-character hex string (SHA-256)', () => { + expect(hashToken('some-token')).toMatch(/^[a-f0-9]{64}$/); + }); + + test('is deterministic — same input produces same hash', () => { + expect(hashToken('abc')).toBe(hashToken('abc')); + }); + + test('different inputs produce different hashes', () => { + expect(hashToken('token-a')).not.toBe(hashToken('token-b')); + }); +}); + +// ── shouldRotateRefreshToken ────────────────────────────────────────────────── + +describe('shouldRotateRefreshToken()', () => { + const now = Math.floor(Date.now() / 1000); + + test('expires in 10 minutes (within 1-day threshold) → should rotate', () => { + const decoded = { exp: now + 600 }; + expect(shouldRotateRefreshToken(decoded, 1)).toBe(true); + }); + + test('expires in 5 days (well beyond 1-day threshold) → should not rotate', () => { + const decoded = { exp: now + 5 * 24 * 60 * 60 }; + expect(shouldRotateRefreshToken(decoded, 1)).toBe(false); + }); + + test('custom threshold is respected', () => { + const decoded = { exp: now + 2 * 24 * 60 * 60 }; // 2 days left + expect(shouldRotateRefreshToken(decoded, 3)).toBe(true); // 3-day threshold → rotate + expect(shouldRotateRefreshToken(decoded, 1)).toBe(false); // 1-day threshold → don't rotate + }); +}); diff --git a/utils/courses/archive.util.js b/utils/courses/archive.util.js index 05c0607..fdec50f 100644 --- a/utils/courses/archive.util.js +++ b/utils/courses/archive.util.js @@ -3,8 +3,8 @@ /** * Single archive — soft delete one record */ -async function archiveOne(Model, options, deletedBy, transaction) { - const record = await Model.findOne(options); +async function archiveOne(Model, where, deletedBy, transaction) { + const record = await Model.findOne({ where, transaction }); if (!record) return null; await record.update({ deletedBy: deletedBy ?? null }, { transaction }); await record.destroy({ transaction }); diff --git a/utils/currency.util.js b/utils/currency.util.js new file mode 100644 index 0000000..5494ad6 --- /dev/null +++ b/utils/currency.util.js @@ -0,0 +1,171 @@ +/*********************************************************************************************************************************************************************** + * File Name: currency.util.js + * Type of Program: Utility + * Description: Currency formatting and resolution helpers for backend use (emails, receipts, notifications). + * + * All format functions accept an optional options object: { locale } + * locale — BCP 47 tag, defaults to 'en-US' + * + * USD is the platform's base/canonical currency. Plans may carry localized price + * overrides (plan_prices table). resolvePrice() applies the COALESCE logic: + * localized override wins → falls back to plan's base price + currency. + ***********************************************************************************************************************************************************************/ +'use strict'; + +// ─── Supported currencies ───────────────────────────────────────────────────── + +const SUPPORTED_CURRENCIES = [ + { code: 'USD', name: 'US Dollar', symbol: '$' }, + { code: 'EUR', name: 'Euro', symbol: '€' }, + { code: 'GBP', name: 'British Pound', symbol: '£' }, + { code: 'CNY', name: 'Chinese Yuan', symbol: '¥' }, + { code: 'JPY', name: 'Japanese Yen', symbol: '¥' }, + { code: 'PHP', name: 'Philippine Peso', symbol: '₱' }, + { code: 'KRW', name: 'South Korean Won', symbol: '₩' }, + { code: 'AUD', name: 'Australian Dollar', symbol: 'A$' }, + { code: 'CAD', name: 'Canadian Dollar', symbol: 'C$' }, + { code: 'SGD', name: 'Singapore Dollar', symbol: 'S$' }, + { code: 'HKD', name: 'Hong Kong Dollar', symbol: 'HK$'}, + { code: 'INR', name: 'Indian Rupee', symbol: '₹' }, + { code: 'MYR', name: 'Malaysian Ringgit', symbol: 'RM' }, + { code: 'THB', name: 'Thai Baht', symbol: '฿' }, + { code: 'IDR', name: 'Indonesian Rupiah', symbol: 'Rp' }, + { code: 'TWD', name: 'Taiwan Dollar', symbol: 'NT$'}, + { code: 'VND', name: 'Vietnamese Dong', symbol: '₫' }, +]; + +const SUPPORTED_CURRENCY_CODES = new Set(SUPPORTED_CURRENCIES.map((c) => c.code)); + +function isSupported(code) { + return SUPPORTED_CURRENCY_CODES.has(code?.toUpperCase()); +} + +// ─── Formatting ─────────────────────────────────────────────────────────────── + +/** "¥1,299.00" / "$9.99" */ +function fmtCurrency(amount, currency = 'USD', { locale = 'en-US' } = {}) { + if (amount === null || amount === undefined) return '—'; + return new Intl.NumberFormat(locale, { + style: 'currency', + currency: currency ?? 'USD', + minimumFractionDigits: 2, + }).format(Number(amount)); +} + +// ─── Price resolution ───────────────────────────────────────────────────────── + +/** + * Returns the effective { price, currency } for a plan given a user's preferred currency. + * plan.prices must be eager-loaded (as: 'prices') for the override to be considered. + * Falls back to plan.price + plan.currency when no override exists. + */ +function resolvePrice(plan, preferredCurrency) { + if (!preferredCurrency || preferredCurrency === plan.currency) + return { price: Number(plan.price), currency: plan.currency }; + + const override = (plan.prices ?? []).find((p) => p.currency === preferredCurrency); + if (override) return { price: Number(override.price), currency: override.currency }; + + return { price: Number(plan.price), currency: plan.currency }; +} + +// ─── Exchange rate fetching ─────────────────────────────────────────────────── +// Uses frankfurter.app (ECB-backed, no API key, free). +// In-process cache with 1-hour TTL avoids hammering the API on every save. + +const _rateCache = new Map(); + +async function fetchExchangeRate(from, to) { + if (from === to) return 1; + const key = `${from}:${to}`; + const now = Date.now(); + const cached = _rateCache.get(key); + if (cached && cached.expiresAt > now) return cached.rate; + + try { + const res = await fetch( + `https://api.frankfurter.app/latest?from=${from}&to=${to}`, + { signal: AbortSignal.timeout(4000) }, + ); + if (!res.ok) return null; + const json = await res.json(); + const rate = json?.rates?.[to]; + if (!rate) return null; + _rateCache.set(key, { rate, expiresAt: now + 60 * 60 * 1000 }); // 1 h TTL + return rate; + } catch { + return null; + } +} + +// ─── Localized price validation ─────────────────────────────────────────────── +// Three zones relative to the market-rate conversion of the base price: +// +// pass → 85 % – 150 % of expected (green, saves normally) +// warn → 70 % – 85 % or 150 % – 300 % (saves with caution message) +// block → < 70 % or > 300 % (rejected — too far from market rate) +// +// If the exchange-rate API is unavailable the check is skipped (returns 'pass'). + +const PRICE_ZONES = { + LOWER_HARD: 0.70, + LOWER_WARN: 0.85, + UPPER_WARN: 1.50, + UPPER_HARD: 3.00, +}; + +async function validateLocalizedPrice(basePrice, baseCurrency, localizedPrice, targetCurrency) { + const rate = await fetchExchangeRate(baseCurrency, targetCurrency); + if (!rate) return { zone: 'pass', skipped: true }; + + const expected = Number(basePrice) * rate; + const entered = Number(localizedPrice); + const { LOWER_HARD, LOWER_WARN, UPPER_WARN, UPPER_HARD } = PRICE_ZONES; + + const hardMin = expected * LOWER_HARD; + const hardMax = expected * UPPER_HARD; + const warnMin = expected * LOWER_WARN; + const warnMax = expected * UPPER_WARN; + + const fmt = (n) => n.toFixed(2); + const rateStr = `1 ${baseCurrency} = ${rate} ${targetCurrency}`; + + if (entered < hardMin || entered > hardMax) { + return { + zone: 'block', + expected: fmt(expected), + hardMin: fmt(hardMin), hardMax: fmt(hardMax), + warnMin: fmt(warnMin), warnMax: fmt(warnMax), + message: `${fmt(entered)} ${targetCurrency} is too far from the current market rate (${rateStr}). ` + + `Acceptable range: ${fmt(hardMin)} – ${fmt(hardMax)} ${targetCurrency}.`, + }; + } + + if (entered < warnMin || entered > warnMax) { + return { + zone: 'warn', + expected: fmt(expected), + hardMin: fmt(hardMin), hardMax: fmt(hardMax), + warnMin: fmt(warnMin), warnMax: fmt(warnMax), + message: `${fmt(entered)} ${targetCurrency} is outside the suggested range (${rateStr}). ` + + `Suggested: ${fmt(warnMin)} – ${fmt(warnMax)} ${targetCurrency}. Saved with caution.`, + }; + } + + return { + zone: 'pass', + expected: fmt(expected), + hardMin: fmt(hardMin), hardMax: fmt(hardMax), + warnMin: fmt(warnMin), warnMax: fmt(warnMax), + }; +} + +module.exports = { + SUPPORTED_CURRENCIES, + isSupported, + fmtCurrency, + resolvePrice, + fetchExchangeRate, + validateLocalizedPrice, + PRICE_ZONES, +};