@odavl/guardian 0.2.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +86 -2
- package/README.md +155 -97
- package/bin/guardian.js +1345 -60
- package/config/README.md +59 -0
- package/config/profiles/landing-demo.yaml +16 -0
- package/package.json +21 -11
- package/policies/landing-demo.json +22 -0
- package/src/enterprise/audit-logger.js +166 -0
- package/src/enterprise/pdf-exporter.js +267 -0
- package/src/enterprise/rbac-gate.js +142 -0
- package/src/enterprise/rbac.js +239 -0
- package/src/enterprise/site-manager.js +180 -0
- package/src/founder/feedback-system.js +156 -0
- package/src/founder/founder-tracker.js +213 -0
- package/src/founder/usage-signals.js +141 -0
- package/src/guardian/alert-ledger.js +121 -0
- package/src/guardian/attempt-engine.js +568 -7
- package/src/guardian/attempt-registry.js +42 -1
- package/src/guardian/attempt-relevance.js +106 -0
- package/src/guardian/attempt.js +24 -0
- package/src/guardian/baseline.js +12 -4
- package/src/guardian/breakage-intelligence.js +1 -0
- package/src/guardian/ci-cli.js +121 -0
- package/src/guardian/ci-output.js +4 -3
- package/src/guardian/cli-summary.js +79 -92
- package/src/guardian/config-loader.js +162 -0
- package/src/guardian/drift-detector.js +100 -0
- package/src/guardian/enhanced-html-reporter.js +221 -4
- package/src/guardian/env-guard.js +127 -0
- package/src/guardian/failure-intelligence.js +173 -0
- package/src/guardian/first-run-profile.js +89 -0
- package/src/guardian/first-run.js +6 -1
- package/src/guardian/flag-validator.js +17 -3
- package/src/guardian/html-reporter.js +2 -0
- package/src/guardian/human-reporter.js +431 -0
- package/src/guardian/index.js +22 -19
- package/src/guardian/init-command.js +9 -5
- package/src/guardian/intent-detector.js +146 -0
- package/src/guardian/journey-definitions.js +132 -0
- package/src/guardian/journey-scan-cli.js +145 -0
- package/src/guardian/journey-scanner.js +583 -0
- package/src/guardian/junit-reporter.js +18 -1
- package/src/guardian/live-cli.js +95 -0
- package/src/guardian/live-scheduler-runner.js +137 -0
- package/src/guardian/live-scheduler.js +146 -0
- package/src/guardian/market-reporter.js +341 -81
- package/src/guardian/pattern-analyzer.js +348 -0
- package/src/guardian/policy.js +80 -3
- package/src/guardian/preset-loader.js +9 -6
- package/src/guardian/reality.js +1278 -117
- package/src/guardian/reporter.js +27 -41
- package/src/guardian/run-artifacts.js +212 -0
- package/src/guardian/run-cleanup.js +207 -0
- package/src/guardian/run-latest.js +90 -0
- package/src/guardian/run-list.js +211 -0
- package/src/guardian/scan-presets.js +100 -11
- package/src/guardian/selector-fallbacks.js +394 -0
- package/src/guardian/semantic-contact-finder.js +2 -1
- package/src/guardian/site-introspection.js +257 -0
- package/src/guardian/smoke.js +2 -2
- package/src/guardian/snapshot-schema.js +25 -1
- package/src/guardian/snapshot.js +46 -2
- package/src/guardian/stability-scorer.js +169 -0
- package/src/guardian/template-command.js +184 -0
- package/src/guardian/text-formatters.js +426 -0
- package/src/guardian/verdict.js +320 -0
- package/src/guardian/verdicts.js +74 -0
- package/src/guardian/watch-runner.js +3 -7
- package/src/payments/stripe-checkout.js +169 -0
- package/src/plans/plan-definitions.js +148 -0
- package/src/plans/plan-manager.js +211 -0
- package/src/plans/usage-tracker.js +210 -0
- package/src/recipes/recipe-engine.js +188 -0
- package/src/recipes/recipe-failure-analysis.js +159 -0
- package/src/recipes/recipe-registry.js +134 -0
- package/src/recipes/recipe-runtime.js +507 -0
- package/src/recipes/recipe-store.js +410 -0
- package/guardian-contract-v1.md +0 -149
- /package/{guardian.config.json → config/guardian.config.json} +0 -0
- /package/{guardian.policy.json → config/guardian.policy.json} +0 -0
- /package/{guardian.profile.docs.yaml → config/profiles/docs.yaml} +0 -0
- /package/{guardian.profile.ecommerce.yaml → config/profiles/ecommerce.yaml} +0 -0
- /package/{guardian.profile.marketing.yaml → config/profiles/marketing.yaml} +0 -0
- /package/{guardian.profile.saas.yaml → config/profiles/saas.yaml} +0 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ODAVL Guardian Plan Definitions
|
|
3
|
+
* Real enforced limits for FREE, PRO, and BUSINESS plans
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const PLANS = {
|
|
7
|
+
FREE: {
|
|
8
|
+
id: 'free',
|
|
9
|
+
name: 'Free',
|
|
10
|
+
price: 0,
|
|
11
|
+
priceMonthly: 0,
|
|
12
|
+
maxScansPerMonth: 10,
|
|
13
|
+
maxSites: 1,
|
|
14
|
+
liveGuardianAllowed: false,
|
|
15
|
+
ciModeAllowed: false,
|
|
16
|
+
alertsAllowed: false,
|
|
17
|
+
features: [
|
|
18
|
+
'Scan public pages',
|
|
19
|
+
'Basic issue detection',
|
|
20
|
+
'CLI usage',
|
|
21
|
+
'Community support',
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
PRO: {
|
|
25
|
+
id: 'pro',
|
|
26
|
+
name: 'Pro',
|
|
27
|
+
price: 29,
|
|
28
|
+
priceMonthly: 29,
|
|
29
|
+
stripePriceId: process.env.STRIPE_PRO_PRICE_ID || 'price_pro_placeholder',
|
|
30
|
+
maxScansPerMonth: 200,
|
|
31
|
+
maxSites: 3,
|
|
32
|
+
liveGuardianAllowed: true,
|
|
33
|
+
ciModeAllowed: true,
|
|
34
|
+
alertsAllowed: true,
|
|
35
|
+
features: [
|
|
36
|
+
'Deeper checks & signals',
|
|
37
|
+
'Prioritized findings',
|
|
38
|
+
'Live guardian monitoring',
|
|
39
|
+
'CI/CD integration',
|
|
40
|
+
'Email alerts',
|
|
41
|
+
'Exportable reports',
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
BUSINESS: {
|
|
45
|
+
id: 'business',
|
|
46
|
+
name: 'Business',
|
|
47
|
+
price: 99,
|
|
48
|
+
priceMonthly: 99,
|
|
49
|
+
stripePriceId: process.env.STRIPE_BUSINESS_PRICE_ID || 'price_business_placeholder',
|
|
50
|
+
maxScansPerMonth: -1, // unlimited
|
|
51
|
+
maxSites: -1, // unlimited
|
|
52
|
+
liveGuardianAllowed: true,
|
|
53
|
+
ciModeAllowed: true,
|
|
54
|
+
alertsAllowed: true,
|
|
55
|
+
features: [
|
|
56
|
+
'Everything in Pro',
|
|
57
|
+
'Unlimited scans',
|
|
58
|
+
'Unlimited sites',
|
|
59
|
+
'Priority support',
|
|
60
|
+
'Custom integrations',
|
|
61
|
+
'Team collaboration',
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Get plan by ID
|
|
68
|
+
*/
|
|
69
|
+
function getPlan(planId) {
|
|
70
|
+
const normalizedId = planId.toUpperCase();
|
|
71
|
+
if (!PLANS[normalizedId]) {
|
|
72
|
+
return PLANS.FREE;
|
|
73
|
+
}
|
|
74
|
+
return PLANS[normalizedId];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Check if a plan allows a specific feature
|
|
79
|
+
*/
|
|
80
|
+
function planAllows(planId, feature) {
|
|
81
|
+
const plan = getPlan(planId);
|
|
82
|
+
switch (feature) {
|
|
83
|
+
case 'liveGuardian':
|
|
84
|
+
return plan.liveGuardianAllowed;
|
|
85
|
+
case 'ciMode':
|
|
86
|
+
return plan.ciModeAllowed;
|
|
87
|
+
case 'alerts':
|
|
88
|
+
return plan.alertsAllowed;
|
|
89
|
+
default:
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Check if usage is within plan limits
|
|
96
|
+
*/
|
|
97
|
+
function isWithinLimit(planId, currentUsage, limitType) {
|
|
98
|
+
const plan = getPlan(planId);
|
|
99
|
+
|
|
100
|
+
switch (limitType) {
|
|
101
|
+
case 'scans':
|
|
102
|
+
if (plan.maxScansPerMonth === -1) return true; // unlimited
|
|
103
|
+
return currentUsage < plan.maxScansPerMonth;
|
|
104
|
+
case 'sites':
|
|
105
|
+
if (plan.maxSites === -1) return true; // unlimited
|
|
106
|
+
return currentUsage < plan.maxSites;
|
|
107
|
+
default:
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Get human-readable error message for limit exceeded
|
|
114
|
+
*/
|
|
115
|
+
function getLimitExceededMessage(planId, limitType) {
|
|
116
|
+
const plan = getPlan(planId);
|
|
117
|
+
|
|
118
|
+
switch (limitType) {
|
|
119
|
+
case 'scans':
|
|
120
|
+
return `You've reached your monthly scan limit for the ${plan.name} plan (${plan.maxScansPerMonth} scans/month). Upgrade to continue scanning.`;
|
|
121
|
+
case 'sites':
|
|
122
|
+
return `You've reached your site limit for the ${plan.name} plan (${plan.maxSites} site${plan.maxSites > 1 ? 's' : ''}). Upgrade to add more sites.`;
|
|
123
|
+
case 'liveGuardian':
|
|
124
|
+
return `Live Guardian monitoring is not available on the ${plan.name} plan. Upgrade to Pro to enable continuous monitoring.`;
|
|
125
|
+
case 'ciMode':
|
|
126
|
+
return `CI/CD mode is not available on the ${plan.name} plan. Upgrade to Pro to enable CI integration.`;
|
|
127
|
+
case 'alerts':
|
|
128
|
+
return `Email alerts are not available on the ${plan.name} plan. Upgrade to Pro to enable notifications.`;
|
|
129
|
+
default:
|
|
130
|
+
return `This feature is not available on your current plan. Upgrade to unlock more features.`;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Get all plans for display
|
|
136
|
+
*/
|
|
137
|
+
function getAllPlans() {
|
|
138
|
+
return [PLANS.FREE, PLANS.PRO, PLANS.BUSINESS];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = {
|
|
142
|
+
PLANS,
|
|
143
|
+
getPlan,
|
|
144
|
+
planAllows,
|
|
145
|
+
isWithinLimit,
|
|
146
|
+
getLimitExceededMessage,
|
|
147
|
+
getAllPlans,
|
|
148
|
+
};
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ODAVL Guardian Plan Manager
|
|
3
|
+
* Manages user plans, checks limits, and enforces restrictions
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
const { getPlan, planAllows, isWithinLimit, getLimitExceededMessage } = require('./plan-definitions');
|
|
10
|
+
const { getUsageStats, canPerformScan, canAddSite, recordScan } = require('./usage-tracker');
|
|
11
|
+
|
|
12
|
+
const PLAN_DIR = path.join(os.homedir(), '.odavl-guardian', 'plan');
|
|
13
|
+
const PLAN_FILE = path.join(PLAN_DIR, 'current-plan.json');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Ensure plan directory exists
|
|
17
|
+
*/
|
|
18
|
+
function ensurePlanDir() {
|
|
19
|
+
if (!fs.existsSync(PLAN_DIR)) {
|
|
20
|
+
fs.mkdirSync(PLAN_DIR, { recursive: true });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Get current plan
|
|
26
|
+
*/
|
|
27
|
+
function getCurrentPlan() {
|
|
28
|
+
ensurePlanDir();
|
|
29
|
+
|
|
30
|
+
if (!fs.existsSync(PLAN_FILE)) {
|
|
31
|
+
// Default to FREE plan
|
|
32
|
+
return {
|
|
33
|
+
planId: 'free',
|
|
34
|
+
activated: new Date().toISOString(),
|
|
35
|
+
stripeCustomerId: null,
|
|
36
|
+
stripeSubscriptionId: null,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(fs.readFileSync(PLAN_FILE, 'utf-8'));
|
|
42
|
+
} catch (error) {
|
|
43
|
+
console.error('Error loading plan:', error.message);
|
|
44
|
+
return {
|
|
45
|
+
planId: 'free',
|
|
46
|
+
activated: new Date().toISOString(),
|
|
47
|
+
stripeCustomerId: null,
|
|
48
|
+
stripeSubscriptionId: null,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Set current plan
|
|
55
|
+
*/
|
|
56
|
+
function setCurrentPlan(planId, stripeData = {}) {
|
|
57
|
+
ensurePlanDir();
|
|
58
|
+
|
|
59
|
+
const planInfo = {
|
|
60
|
+
planId: planId.toLowerCase(),
|
|
61
|
+
activated: new Date().toISOString(),
|
|
62
|
+
stripeCustomerId: stripeData.customerId || null,
|
|
63
|
+
stripeSubscriptionId: stripeData.subscriptionId || null,
|
|
64
|
+
upgraded: new Date().toISOString(),
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
fs.writeFileSync(PLAN_FILE, JSON.stringify(planInfo, null, 2), 'utf-8');
|
|
68
|
+
return planInfo;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Check if a feature is allowed
|
|
73
|
+
*/
|
|
74
|
+
function checkFeatureAllowed(feature) {
|
|
75
|
+
const currentPlan = getCurrentPlan();
|
|
76
|
+
const plan = getPlan(currentPlan.planId);
|
|
77
|
+
const allowed = planAllows(currentPlan.planId, feature);
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
allowed,
|
|
81
|
+
plan: plan.name,
|
|
82
|
+
message: allowed ? null : getLimitExceededMessage(currentPlan.planId, feature),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Check if can perform scan
|
|
88
|
+
*/
|
|
89
|
+
function checkCanScan(url) {
|
|
90
|
+
const currentPlan = getCurrentPlan();
|
|
91
|
+
const plan = getPlan(currentPlan.planId);
|
|
92
|
+
const usage = getUsageStats();
|
|
93
|
+
|
|
94
|
+
// Check site limit first
|
|
95
|
+
if (!canAddSite(url, plan.maxSites)) {
|
|
96
|
+
return {
|
|
97
|
+
allowed: false,
|
|
98
|
+
reason: 'site_limit',
|
|
99
|
+
message: getLimitExceededMessage(currentPlan.planId, 'sites'),
|
|
100
|
+
plan: plan.name,
|
|
101
|
+
usage: {
|
|
102
|
+
sites: usage.sites.length,
|
|
103
|
+
maxSites: plan.maxSites,
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Check scan limit
|
|
109
|
+
if (!canPerformScan(plan.maxScansPerMonth)) {
|
|
110
|
+
return {
|
|
111
|
+
allowed: false,
|
|
112
|
+
reason: 'scan_limit',
|
|
113
|
+
message: getLimitExceededMessage(currentPlan.planId, 'scans'),
|
|
114
|
+
plan: plan.name,
|
|
115
|
+
usage: {
|
|
116
|
+
scansThisMonth: usage.scansThisMonth,
|
|
117
|
+
maxScans: plan.maxScansPerMonth,
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
allowed: true,
|
|
124
|
+
plan: plan.name,
|
|
125
|
+
usage: {
|
|
126
|
+
scansThisMonth: usage.scansThisMonth,
|
|
127
|
+
maxScans: plan.maxScansPerMonth,
|
|
128
|
+
scansRemaining: plan.maxScansPerMonth === -1 ? 'Unlimited' : plan.maxScansPerMonth - usage.scansThisMonth,
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Record scan and enforce limits
|
|
135
|
+
*/
|
|
136
|
+
function performScan(url) {
|
|
137
|
+
const check = checkCanScan(url);
|
|
138
|
+
|
|
139
|
+
if (!check.allowed) {
|
|
140
|
+
throw new Error(check.message);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Record the scan
|
|
144
|
+
recordScan(url);
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
success: true,
|
|
148
|
+
plan: check.plan,
|
|
149
|
+
usage: getUsageStats(),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Get plan summary for display
|
|
155
|
+
*/
|
|
156
|
+
function getPlanSummary() {
|
|
157
|
+
const currentPlan = getCurrentPlan();
|
|
158
|
+
const plan = getPlan(currentPlan.planId);
|
|
159
|
+
const usage = getUsageStats();
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
plan: {
|
|
163
|
+
id: plan.id,
|
|
164
|
+
name: plan.name,
|
|
165
|
+
price: plan.price,
|
|
166
|
+
},
|
|
167
|
+
limits: {
|
|
168
|
+
scans: {
|
|
169
|
+
max: plan.maxScansPerMonth,
|
|
170
|
+
used: usage.scansThisMonth,
|
|
171
|
+
remaining: plan.maxScansPerMonth === -1 ? 'Unlimited' : Math.max(0, plan.maxScansPerMonth - usage.scansThisMonth),
|
|
172
|
+
},
|
|
173
|
+
sites: {
|
|
174
|
+
max: plan.maxSites,
|
|
175
|
+
used: usage.sites.length,
|
|
176
|
+
remaining: plan.maxSites === -1 ? 'Unlimited' : Math.max(0, plan.maxSites - usage.sites.length),
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
features: {
|
|
180
|
+
liveGuardian: plan.liveGuardianAllowed,
|
|
181
|
+
ciMode: plan.ciModeAllowed,
|
|
182
|
+
alerts: plan.alertsAllowed,
|
|
183
|
+
},
|
|
184
|
+
activated: currentPlan.activated,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Upgrade message helper
|
|
190
|
+
*/
|
|
191
|
+
function getUpgradeMessage() {
|
|
192
|
+
return '\nTo upgrade your plan, visit: https://guardian.odavl.com/pricing\nOr run: guardian upgrade';
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Get plan file path (for debugging)
|
|
197
|
+
*/
|
|
198
|
+
function getPlanFilePath() {
|
|
199
|
+
return PLAN_FILE;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
module.exports = {
|
|
203
|
+
getCurrentPlan,
|
|
204
|
+
setCurrentPlan,
|
|
205
|
+
checkFeatureAllowed,
|
|
206
|
+
checkCanScan,
|
|
207
|
+
performScan,
|
|
208
|
+
getPlanSummary,
|
|
209
|
+
getUpgradeMessage,
|
|
210
|
+
getPlanFilePath,
|
|
211
|
+
};
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ODAVL Guardian Usage Tracker
|
|
3
|
+
* Tracks scans per month and enforces plan limits
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
|
|
10
|
+
const USAGE_DIR = path.join(os.homedir(), '.odavl-guardian', 'usage');
|
|
11
|
+
const USAGE_FILE = path.join(USAGE_DIR, 'usage.json');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Ensure usage directory exists
|
|
15
|
+
*/
|
|
16
|
+
function ensureUsageDir() {
|
|
17
|
+
if (!fs.existsSync(USAGE_DIR)) {
|
|
18
|
+
fs.mkdirSync(USAGE_DIR, { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Get current month key (YYYY-MM)
|
|
24
|
+
*/
|
|
25
|
+
function getCurrentMonthKey() {
|
|
26
|
+
const now = new Date();
|
|
27
|
+
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Load usage data
|
|
32
|
+
*/
|
|
33
|
+
function loadUsage() {
|
|
34
|
+
ensureUsageDir();
|
|
35
|
+
|
|
36
|
+
if (!fs.existsSync(USAGE_FILE)) {
|
|
37
|
+
return {
|
|
38
|
+
currentMonth: getCurrentMonthKey(),
|
|
39
|
+
scansThisMonth: 0,
|
|
40
|
+
sites: [],
|
|
41
|
+
totalScans: 0,
|
|
42
|
+
history: {},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const data = JSON.parse(fs.readFileSync(USAGE_FILE, 'utf-8'));
|
|
48
|
+
|
|
49
|
+
// Reset if new month
|
|
50
|
+
const currentMonth = getCurrentMonthKey();
|
|
51
|
+
if (data.currentMonth !== currentMonth) {
|
|
52
|
+
// Archive old month
|
|
53
|
+
data.history[data.currentMonth] = {
|
|
54
|
+
scans: data.scansThisMonth,
|
|
55
|
+
sites: data.sites.length,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Reset for new month
|
|
59
|
+
data.currentMonth = currentMonth;
|
|
60
|
+
data.scansThisMonth = 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return data;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
console.error('Error loading usage data:', error.message);
|
|
66
|
+
return {
|
|
67
|
+
currentMonth: getCurrentMonthKey(),
|
|
68
|
+
scansThisMonth: 0,
|
|
69
|
+
sites: [],
|
|
70
|
+
totalScans: 0,
|
|
71
|
+
history: {},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Save usage data
|
|
78
|
+
*/
|
|
79
|
+
function saveUsage(usage) {
|
|
80
|
+
ensureUsageDir();
|
|
81
|
+
fs.writeFileSync(USAGE_FILE, JSON.stringify(usage, null, 2), 'utf-8');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Record a scan
|
|
86
|
+
*/
|
|
87
|
+
function recordScan(url) {
|
|
88
|
+
const usage = loadUsage();
|
|
89
|
+
|
|
90
|
+
// Extract domain from URL
|
|
91
|
+
let domain;
|
|
92
|
+
try {
|
|
93
|
+
const urlObj = new URL(url);
|
|
94
|
+
domain = urlObj.hostname;
|
|
95
|
+
} catch {
|
|
96
|
+
domain = url;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Increment scan count
|
|
100
|
+
usage.scansThisMonth += 1;
|
|
101
|
+
usage.totalScans += 1;
|
|
102
|
+
|
|
103
|
+
// Track unique site
|
|
104
|
+
if (!usage.sites.includes(domain)) {
|
|
105
|
+
usage.sites.push(domain);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Record timestamp
|
|
109
|
+
if (!usage.scanHistory) {
|
|
110
|
+
usage.scanHistory = [];
|
|
111
|
+
}
|
|
112
|
+
usage.scanHistory.push({
|
|
113
|
+
url,
|
|
114
|
+
domain,
|
|
115
|
+
timestamp: new Date().toISOString(),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Keep only last 100 scans in history
|
|
119
|
+
if (usage.scanHistory.length > 100) {
|
|
120
|
+
usage.scanHistory = usage.scanHistory.slice(-100);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
saveUsage(usage);
|
|
124
|
+
return usage;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Get current usage stats
|
|
129
|
+
*/
|
|
130
|
+
function getUsageStats() {
|
|
131
|
+
return loadUsage();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Get scans remaining for current plan
|
|
136
|
+
*/
|
|
137
|
+
function getScansRemaining(maxScans) {
|
|
138
|
+
const usage = loadUsage();
|
|
139
|
+
if (maxScans === -1) return 'Unlimited';
|
|
140
|
+
return Math.max(0, maxScans - usage.scansThisMonth);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Check if can perform scan
|
|
145
|
+
*/
|
|
146
|
+
function canPerformScan(maxScans) {
|
|
147
|
+
if (maxScans === -1) return true; // unlimited
|
|
148
|
+
const usage = loadUsage();
|
|
149
|
+
return usage.scansThisMonth < maxScans;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Check if can add site
|
|
154
|
+
*/
|
|
155
|
+
function canAddSite(url, maxSites) {
|
|
156
|
+
if (maxSites === -1) return true; // unlimited
|
|
157
|
+
|
|
158
|
+
const usage = loadUsage();
|
|
159
|
+
|
|
160
|
+
// Extract domain
|
|
161
|
+
let domain;
|
|
162
|
+
try {
|
|
163
|
+
const urlObj = new URL(url);
|
|
164
|
+
domain = urlObj.hostname;
|
|
165
|
+
} catch {
|
|
166
|
+
domain = url;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// If site already tracked, it's OK
|
|
170
|
+
if (usage.sites.includes(domain)) {
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Check if under limit
|
|
175
|
+
return usage.sites.length < maxSites;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Reset usage (for testing or manual reset)
|
|
180
|
+
*/
|
|
181
|
+
function resetUsage() {
|
|
182
|
+
ensureUsageDir();
|
|
183
|
+
const usage = {
|
|
184
|
+
currentMonth: getCurrentMonthKey(),
|
|
185
|
+
scansThisMonth: 0,
|
|
186
|
+
sites: [],
|
|
187
|
+
totalScans: 0,
|
|
188
|
+
history: {},
|
|
189
|
+
scanHistory: [],
|
|
190
|
+
};
|
|
191
|
+
saveUsage(usage);
|
|
192
|
+
return usage;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Get usage file path (for debugging)
|
|
197
|
+
*/
|
|
198
|
+
function getUsageFilePath() {
|
|
199
|
+
return USAGE_FILE;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
module.exports = {
|
|
203
|
+
recordScan,
|
|
204
|
+
getUsageStats,
|
|
205
|
+
getScansRemaining,
|
|
206
|
+
canPerformScan,
|
|
207
|
+
canAddSite,
|
|
208
|
+
resetUsage,
|
|
209
|
+
getUsageFilePath,
|
|
210
|
+
};
|