filemayor 2.1.0 → 4.0.5

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/core/license.js CHANGED
@@ -1,337 +1,403 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * ═══════════════════════════════════════════════════════════════════
5
- * FILEMAYOR CORE — LICENSE
6
- * Offline license key validation, tier management, and feature gating.
7
- * Created by Lehlohonolo Goodwill Nchefu (Chevza)
8
- * ═══════════════════════════════════════════════════════════════════
9
- */
10
-
11
- 'use strict';
12
-
13
- const fs = require('fs');
14
- const path = require('path');
15
- const os = require('os');
16
- const crypto = require('crypto');
17
-
18
- // ─── Constants ────────────────────────────────────────────────────
19
-
20
- const LICENSE_FILE = path.join(os.homedir(), '.filemayor-license.json');
21
- const KEY_PREFIX = 'FM';
22
- const CHECKSUM_SECRET = process.env.FM_CHECKSUM_SECRET || 'filemayor-chevza-2026';
23
-
24
- /**
25
- * License tiers with capabilities
26
- */
27
- const TIERS = {
28
- free: {
29
- name: 'Free',
30
- features: ['scan', 'organize', 'clean', 'undo', 'init', 'info'],
31
- limits: { bulkOrganize: 50 },
32
- },
33
- pro: {
34
- name: 'Pro',
35
- features: ['scan', 'organize', 'clean', 'undo', 'init', 'info',
36
- 'watch', 'sop-ai', 'bulk-organize', 'csv-export'],
37
- limits: { bulkOrganize: Infinity },
38
- },
39
- enterprise: {
40
- name: 'Enterprise',
41
- features: ['scan', 'organize', 'clean', 'undo', 'init', 'info',
42
- 'watch', 'sop-ai', 'bulk-organize', 'csv-export',
43
- 'api-access', 'custom-categories', 'team-sharing'],
44
- limits: { bulkOrganize: Infinity },
45
- },
46
- owner: {
47
- name: 'Owner',
48
- features: ['*'],
49
- limits: { bulkOrganize: Infinity },
50
- },
51
- };
52
-
53
- /**
54
- * Built-in keys (hardcoded, never expire)
55
- */
56
- const BUILTIN_KEYS = {
57
- 'FM-OWN-CHEV-2026-PRMNT': { tier: 'owner', name: 'Owner — Chevza', expires: null },
58
- 'FM-TST-DEV0-TEST-00000': { tier: 'pro', name: 'Test — Development', expires: null },
59
- };
60
-
61
- // ─── Key Generation & Validation ──────────────────────────────────
62
-
63
- /**
64
- * Generate a checksum for a key body
65
- * @param {string} body - Key body without checksum segment
66
- * @returns {string} 5-char checksum
67
- */
68
- function generateChecksum(body) {
69
- const hash = crypto.createHmac('sha256', CHECKSUM_SECRET)
70
- .update(body)
71
- .digest('hex');
72
- return hash.substring(0, 5).toUpperCase();
73
- }
74
-
75
- /**
76
- * Validate key format: FM-XXX-XXXX-XXXX-XXXXX
77
- * Last 5 chars are HMAC checksum of the rest
78
- * @param {string} key - License key
79
- * @returns {{ valid: boolean, tier: string|null, reason: string }}
80
- */
81
- function validateKeyFormat(key) {
82
- if (!key || typeof key !== 'string') {
83
- return { valid: false, tier: null, reason: 'No key provided' };
84
- }
85
-
86
- const k = key.trim().toUpperCase();
87
-
88
- // Check built-in keys first
89
- if (BUILTIN_KEYS[k]) {
90
- return { valid: true, tier: BUILTIN_KEYS[k].tier, reason: 'Built-in key' };
91
- }
92
-
93
- // Format: FM-XXX-XXXX-XXXX-XXXXX
94
- const pattern = /^FM-(PRO|ENT|OWN|TST)-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{5}$/;
95
- if (!pattern.test(k)) {
96
- return { valid: false, tier: null, reason: 'Invalid key format' };
97
- }
98
-
99
- // Extract parts
100
- const parts = k.split('-');
101
- const tierCode = parts[1];
102
- const body = parts.slice(0, 4).join('-'); // FM-XXX-XXXX-XXXX
103
- const checksum = parts[4]; // XXXXX
104
-
105
- // Verify checksum
106
- const expectedChecksum = generateChecksum(body);
107
- if (checksum !== expectedChecksum) {
108
- return { valid: false, tier: null, reason: 'Invalid key (checksum failed)' };
109
- }
110
-
111
- // Map tier code
112
- const tierMap = { PRO: 'pro', ENT: 'enterprise', OWN: 'owner', TST: 'pro' };
113
- const tier = tierMap[tierCode] || 'free';
114
-
115
- return { valid: true, tier, reason: 'Valid' };
116
- }
117
-
118
- /**
119
- * Generate a new license key
120
- * @param {'pro'|'enterprise'|'owner'} tier
121
- * @returns {string} Generated key
122
- */
123
- function generateKey(tier = 'pro') {
124
- const tierMap = { pro: 'PRO', enterprise: 'ENT', owner: 'OWN' };
125
- const code = tierMap[tier] || 'PRO';
126
-
127
- const seg1 = crypto.randomBytes(2).toString('hex').toUpperCase().substring(0, 4);
128
- const seg2 = crypto.randomBytes(2).toString('hex').toUpperCase().substring(0, 4);
129
- const body = `FM-${code}-${seg1}-${seg2}`;
130
- const checksum = generateChecksum(body);
131
-
132
- return `${body}-${checksum}`;
133
- }
134
-
135
- // ─── License Storage ──────────────────────────────────────────────
136
-
137
- /**
138
- * Read stored license from disk
139
- * @returns {{ key: string, tier: string, activatedAt: string, name: string }|null}
140
- */
141
- function readLicense() {
142
- try {
143
- if (!fs.existsSync(LICENSE_FILE)) return null;
144
- const data = JSON.parse(fs.readFileSync(LICENSE_FILE, 'utf8'));
145
- return data;
146
- } catch {
147
- return null;
148
- }
149
- }
150
-
151
- /**
152
- * Write license to disk
153
- * @param {Object} licenseData
154
- */
155
- function writeLicense(licenseData) {
156
- fs.writeFileSync(LICENSE_FILE, JSON.stringify(licenseData, null, 2), 'utf8');
157
- }
158
-
159
- /**
160
- * Remove stored license
161
- */
162
- function removeLicense() {
163
- if (fs.existsSync(LICENSE_FILE)) {
164
- fs.unlinkSync(LICENSE_FILE);
165
- }
166
- }
167
-
168
- // ─── License Management ──────────────────────────────────────────
169
-
170
- /**
171
- * Activate a license key
172
- * @param {string} key - License key to activate
173
- * @returns {{ success: boolean, message: string, tier: string|null }}
174
- */
175
- function activateLicense(key) {
176
- const validation = validateKeyFormat(key);
177
-
178
- if (!validation.valid) {
179
- return { success: false, message: validation.reason, tier: null };
180
- }
181
-
182
- const k = key.trim().toUpperCase();
183
- const builtin = BUILTIN_KEYS[k];
184
-
185
- const licenseData = {
186
- key: k,
187
- tier: validation.tier,
188
- name: builtin ? builtin.name : `${TIERS[validation.tier].name} License`,
189
- activatedAt: new Date().toISOString(),
190
- expires: builtin ? null : null, // LemonSqueezy keys can add expiry later
191
- };
192
-
193
- writeLicense(licenseData);
194
-
195
- return {
196
- success: true,
197
- message: `${TIERS[validation.tier].name} license activated successfully`,
198
- tier: validation.tier,
199
- };
200
- }
201
-
202
- /**
203
- * Deactivate the current license
204
- * @returns {{ success: boolean, message: string }}
205
- */
206
- function deactivateLicense() {
207
- const current = readLicense();
208
- if (!current) {
209
- return { success: false, message: 'No active license found' };
210
- }
211
- removeLicense();
212
- return { success: true, message: 'License deactivated. Reverted to Free tier.' };
213
- }
214
-
215
- /**
216
- * Get current license info
217
- * @returns {{ tier: string, name: string, features: string[], limits: Object, key: string|null, active: boolean }}
218
- */
219
- function getLicenseInfo() {
220
- const stored = readLicense();
221
-
222
- if (!stored) {
223
- return {
224
- tier: 'free',
225
- name: 'Free',
226
- features: TIERS.free.features,
227
- limits: TIERS.free.limits,
228
- key: null,
229
- active: false,
230
- };
231
- }
232
-
233
- // Re-validate stored key
234
- const validation = validateKeyFormat(stored.key);
235
- if (!validation.valid) {
236
- removeLicense();
237
- return {
238
- tier: 'free',
239
- name: 'Free (invalid key removed)',
240
- features: TIERS.free.features,
241
- limits: TIERS.free.limits,
242
- key: null,
243
- active: false,
244
- };
245
- }
246
-
247
- const tier = TIERS[stored.tier] || TIERS.free;
248
-
249
- return {
250
- tier: stored.tier,
251
- name: stored.name,
252
- features: tier.features,
253
- limits: tier.limits,
254
- key: stored.key,
255
- active: true,
256
- activatedAt: stored.activatedAt,
257
- };
258
- }
259
-
260
- // ─── Feature Gating ──────────────────────────────────────────────
261
-
262
- /**
263
- * Check if a feature is available in the current license
264
- * @param {string} feature - Feature identifier
265
- * @returns {boolean}
266
- */
267
- function hasFeature(feature) {
268
- const info = getLicenseInfo();
269
- if (info.features.includes('*')) return true; // Owner has everything
270
- return info.features.includes(feature);
271
- }
272
-
273
- /**
274
- * Check if a Pro feature is gated and return appropriate message
275
- * @param {string} feature - Feature identifier
276
- * @param {string} featureLabel - Human-readable feature name
277
- * @returns {{ allowed: boolean, message: string|null }}
278
- */
279
- function checkProFeature(feature, featureLabel) {
280
- if (hasFeature(feature)) {
281
- return { allowed: true, message: null };
282
- }
283
-
284
- return {
285
- allowed: false,
286
- message: [
287
- c('yellow', `⚡ ${featureLabel} is a Pro Feature`),
288
- '',
289
- ` FileMayor Core is free. ${featureLabel} requires a Pro License.`,
290
- ' Unlocks: Real-time Watch, AI SOP Engine, and Unlimited Bulk processing.',
291
- '',
292
- ' Get Key: https://filemayor.com/pro',
293
- ' Activate: filemayor license activate YOUR-KEY',
294
- '',
295
- ].join('\n'),
296
- };
297
- }
298
-
299
- /**
300
- * Check bulk organize limit
301
- * @param {number} fileCount - Number of files to organize
302
- * @returns {{ allowed: boolean, message: string|null }}
303
- */
304
- function checkBulkLimit(fileCount) {
305
- const info = getLicenseInfo();
306
- if (info.limits.bulkOrganize >= fileCount) {
307
- return { allowed: true, message: null };
308
- }
309
-
310
- return {
311
- allowed: false,
312
- message: [
313
- `⚡ Bulk organize (${fileCount} files) requires a Pro license`,
314
- ` Free tier is limited to ${info.limits.bulkOrganize} files per operation.`,
315
- '',
316
- ' Upgrade: https://filemayor.lemonsqueezy.com/checkout/buy/7fdcc87f-0660-4c1c-b3db-99f94773b71a',
317
- ' Then run: filemayor license activate YOUR-KEY',
318
- ].join('\n'),
319
- };
320
- }
321
-
322
- // ─── Exports ─────────────────────────────────────────────────────
323
-
324
- module.exports = {
325
- TIERS,
326
- BUILTIN_KEYS,
327
- validateKeyFormat,
328
- generateKey,
329
- readLicense,
330
- activateLicense,
331
- deactivateLicense,
332
- getLicenseInfo,
333
- hasFeature,
334
- checkProFeature,
335
- checkBulkLimit,
336
- LICENSE_FILE,
337
- };
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * ═══════════════════════════════════════════════════════════════════
5
+ * FILEMAYOR CORE — LICENSE
6
+ * Offline license key validation, tier management, and feature gating.
7
+ * Created by Lehlohonolo Goodwill Nchefu (Chevza)
8
+ * ═══════════════════════════════════════════════════════════════════
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const os = require('os');
16
+ const crypto = require('crypto');
17
+
18
+ // ─── Constants ────────────────────────────────────────────────────
19
+
20
+ const LICENSE_FILE = path.join(os.homedir(), '.filemayor-license.json');
21
+ const KEY_PREFIX = 'FM';
22
+ const CHECKSUM_SECRET = process.env.FM_CHECKSUM_SECRET || 'filemayor-chevza-2026';
23
+
24
+ /**
25
+ * License tiers with capabilities
26
+ */
27
+ const TIERS = {
28
+ // Free until 100k users: every feature is open. The tier system and
29
+ // Ed25519 license verification stay in place so paid tiers can be
30
+ // reintroduced later without re-architecting.
31
+ free: {
32
+ name: 'Free',
33
+ features: ['scan', 'organize', 'clean', 'undo', 'init', 'info',
34
+ 'watch', 'sop-ai', 'bulk-organize', 'csv-export'],
35
+ limits: { bulkOrganize: Infinity },
36
+ },
37
+ pro: {
38
+ name: 'Pro',
39
+ features: ['scan', 'organize', 'clean', 'undo', 'init', 'info',
40
+ 'watch', 'sop-ai', 'bulk-organize', 'csv-export'],
41
+ limits: { bulkOrganize: Infinity },
42
+ },
43
+ enterprise: {
44
+ name: 'Enterprise',
45
+ features: ['scan', 'organize', 'clean', 'undo', 'init', 'info',
46
+ 'watch', 'sop-ai', 'bulk-organize', 'csv-export',
47
+ 'api-access', 'custom-categories', 'team-sharing'],
48
+ limits: { bulkOrganize: Infinity },
49
+ },
50
+ owner: {
51
+ name: 'Owner',
52
+ features: ['*'],
53
+ limits: { bulkOrganize: Infinity },
54
+ },
55
+ };
56
+
57
+ // ─── Ed25519 JWT Verification ─────────────────────────────────────
58
+
59
+ /**
60
+ * Decode a base64url string to a Buffer
61
+ */
62
+ function b64urlDecode(str) {
63
+ const padded = str.replace(/-/g, '+').replace(/_/g, '/');
64
+ const pad = padded.length % 4;
65
+ return Buffer.from(pad ? padded + '='.repeat(4 - pad) : padded, 'base64');
66
+ }
67
+
68
+ /**
69
+ * Verify an Ed25519 JWT license token.
70
+ * The matching public key is resolved (in priority order) from:
71
+ * 1. process.env.FM_LICENSE_PUBLIC_KEY_<kid>
72
+ * 2. process.env.FM_LICENSE_PUBLIC_KEY
73
+ * where <kid> comes from the JWT header's kid field.
74
+ *
75
+ * Returns the decoded payload object on success, or null on any failure.
76
+ * Fails secure: an unknown/missing public key always returns null.
77
+ *
78
+ * @param {string} token - JWT string (header.payload.signature, all base64url)
79
+ * @returns {object|null} Decoded payload or null
80
+ */
81
+ function verifySignedKey(token) {
82
+ try {
83
+ const parts = token.split('.');
84
+ if (parts.length !== 3) return null;
85
+
86
+ const [headerB64, payloadB64, sigB64] = parts;
87
+ const header = JSON.parse(b64urlDecode(headerB64).toString('utf8'));
88
+ const kid = header.kid || 'v1';
89
+ const signingInput = `${headerB64}.${payloadB64}`;
90
+ const signature = b64urlDecode(sigB64);
91
+
92
+ // Resolve public key PEM from environment
93
+ const pubKeyPem = process.env[`FM_LICENSE_PUBLIC_KEY_${kid}`]
94
+ || process.env.FM_LICENSE_PUBLIC_KEY;
95
+
96
+ if (!pubKeyPem) return null; // Fail secure: no public key configured
97
+
98
+ const pubKey = crypto.createPublicKey({ key: pubKeyPem.trim(), format: 'pem' });
99
+ const valid = crypto.verify(null, Buffer.from(signingInput), pubKey, signature);
100
+ if (!valid) return null;
101
+
102
+ const payload = JSON.parse(b64urlDecode(payloadB64).toString('utf8'));
103
+
104
+ // Check expiry if present
105
+ if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) return null;
106
+
107
+ return payload;
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Built-in keys (hardcoded, never expire)
115
+ */
116
+ const BUILTIN_KEYS = {
117
+ 'FM-OWN-CHEV-2026-PRMNT': { tier: 'owner', name: 'Owner — Chevza', expires: null },
118
+ };
119
+
120
+ // ─── Key Generation & Validation ──────────────────────────────────
121
+
122
+ /**
123
+ * Generate a checksum for a key body
124
+ * @param {string} body - Key body without checksum segment
125
+ * @returns {string} 5-char checksum
126
+ */
127
+ function generateChecksum(body) {
128
+ // Ported from Admin Dashboard simpleHash for cross-platform compatibility
129
+ let hash = 0;
130
+ for (let i = 0; i < body.length; i++) {
131
+ hash = ((hash << 5) - hash + body.charCodeAt(i)) | 0;
132
+ }
133
+ return Math.abs(hash).toString(36).toUpperCase().padEnd(5, '0').substring(0, 5);
134
+ }
135
+
136
+ /**
137
+ * Validate key format: FM-XXX-XXXX-XXXX-XXXXX
138
+ * Last 5 chars are HMAC checksum of the rest
139
+ * @param {string} key - License key
140
+ * @returns {{ valid: boolean, tier: string|null, reason: string }}
141
+ */
142
+ function validateKeyFormat(key) {
143
+ if (!key || typeof key !== 'string') {
144
+ return { valid: false, tier: null, reason: 'No key provided' };
145
+ }
146
+
147
+ const trimmed = key.trim();
148
+
149
+ // ─── Path A: signed Ed25519 JWT (LemonSqueezy webhook output) ───
150
+ if (trimmed.includes('.') && trimmed.split('.').length === 3) {
151
+ const payload = verifySignedKey(trimmed);
152
+ if (payload && payload.tier && TIERS[payload.tier]) {
153
+ return { valid: true, tier: payload.tier, reason: 'Signed license', payload };
154
+ }
155
+ }
156
+
157
+ const k = trimmed.toUpperCase();
158
+
159
+ // ─── Path B: built-in keys (Owner, Test) — never expire ─────────
160
+ if (BUILTIN_KEYS[k]) {
161
+ return { valid: true, tier: BUILTIN_KEYS[k].tier, reason: 'Built-in key' };
162
+ }
163
+
164
+ // ─── Path C: legacy FM-XXX-XXXX-XXXX-XXXXX checksum format ─────
165
+ // INTENTIONALLY DISABLED for external keys. The format's checksum
166
+ // is publicly derivable from source — anyone could mint Pro keys
167
+ // offline. We keep the format only for BUILTIN_KEYS (above) where
168
+ // the keys themselves are hard-coded constants. External callers
169
+ // sending an FM-XXX-style key get the same "invalid" response as
170
+ // a malformed key. Paid licenses are Ed25519 JWTs (Path A) only.
171
+ return { valid: false, tier: null, reason: 'Invalid license' };
172
+ }
173
+
174
+ /**
175
+ * @deprecated FM-XXX format is no longer accepted for external (paid) licenses.
176
+ * The checksum is derivable from public source code. Use the LemonSqueezy
177
+ * webhook (filemayor-landing/app/api/lemonsqueezy/webhook/route.ts) to mint
178
+ * Ed25519 JWT licenses instead.
179
+ *
180
+ * This function is gated behind FM_ALLOW_LEGACY_GENERATE=true for backward
181
+ * compatibility with existing tooling (e.g. admin dashboard) that hasn't
182
+ * been migrated yet. Even when enabled, the resulting keys are NOT accepted
183
+ * by validateKeyFormat() unless they are also added to BUILTIN_KEYS.
184
+ *
185
+ * @param {'pro'|'enterprise'|'owner'} tier
186
+ * @returns {string} Generated key
187
+ */
188
+ function generateKey(tier = 'pro') {
189
+ if (process.env.FM_ALLOW_LEGACY_GENERATE !== 'true') {
190
+ throw new Error(
191
+ 'FM-XXX key generation is disabled. ' +
192
+ 'Use the LemonSqueezy webhook to issue Ed25519 JWT licenses, ' +
193
+ 'or set FM_ALLOW_LEGACY_GENERATE=true if you understand the risk.',
194
+ );
195
+ }
196
+ const tierMap = { pro: 'PRO', enterprise: 'ENT', owner: 'OWN' };
197
+ const code = tierMap[tier] || 'PRO';
198
+ const seg1 = crypto.randomBytes(2).toString('hex').toUpperCase().substring(0, 4);
199
+ const seg2 = crypto.randomBytes(2).toString('hex').toUpperCase().substring(0, 4);
200
+ const body = `FM-${code}-${seg1}-${seg2}`;
201
+ const checksum = generateChecksum(body);
202
+ return `${body}-${checksum}`;
203
+ }
204
+
205
+ // ─── License Storage ──────────────────────────────────────────────
206
+
207
+ /**
208
+ * Read stored license from disk
209
+ * @returns {{ key: string, tier: string, activatedAt: string, name: string }|null}
210
+ */
211
+ function readLicense() {
212
+ try {
213
+ if (!fs.existsSync(LICENSE_FILE)) return null;
214
+ const data = JSON.parse(fs.readFileSync(LICENSE_FILE, 'utf8'));
215
+ return data;
216
+ } catch {
217
+ return null;
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Write license to disk
223
+ * @param {Object} licenseData
224
+ */
225
+ function writeLicense(licenseData) {
226
+ fs.writeFileSync(LICENSE_FILE, JSON.stringify(licenseData, null, 2), 'utf8');
227
+ }
228
+
229
+ /**
230
+ * Remove stored license
231
+ */
232
+ function removeLicense() {
233
+ if (fs.existsSync(LICENSE_FILE)) {
234
+ fs.unlinkSync(LICENSE_FILE);
235
+ }
236
+ }
237
+
238
+ // ─── License Management ──────────────────────────────────────────
239
+
240
+ /**
241
+ * Activate a license key
242
+ * @param {string} key - License key to activate
243
+ * @returns {{ success: boolean, message: string, tier: string|null }}
244
+ */
245
+ function activateLicense(key) {
246
+ const validation = validateKeyFormat(key);
247
+
248
+ if (!validation.valid) {
249
+ return { success: false, message: validation.reason, tier: null };
250
+ }
251
+
252
+ const k = key.trim().toUpperCase();
253
+ const builtin = BUILTIN_KEYS[k];
254
+
255
+ const licenseData = {
256
+ key: k,
257
+ tier: validation.tier,
258
+ name: builtin ? builtin.name : `${TIERS[validation.tier].name} License`,
259
+ activatedAt: new Date().toISOString(),
260
+ expires: builtin ? null : null, // LemonSqueezy keys can add expiry later
261
+ };
262
+
263
+ writeLicense(licenseData);
264
+
265
+ return {
266
+ success: true,
267
+ message: `${TIERS[validation.tier].name} license activated successfully`,
268
+ tier: validation.tier,
269
+ };
270
+ }
271
+
272
+ /**
273
+ * Deactivate the current license
274
+ * @returns {{ success: boolean, message: string }}
275
+ */
276
+ function deactivateLicense() {
277
+ const current = readLicense();
278
+ if (!current) {
279
+ return { success: false, message: 'No active license found' };
280
+ }
281
+ removeLicense();
282
+ return { success: true, message: 'License deactivated. Reverted to Free tier.' };
283
+ }
284
+
285
+ /**
286
+ * Get current license info
287
+ * @returns {{ tier: string, name: string, features: string[], limits: Object, key: string|null, active: boolean }}
288
+ */
289
+ function getLicenseInfo() {
290
+ const stored = readLicense();
291
+
292
+ if (!stored) {
293
+ return {
294
+ tier: 'free',
295
+ name: 'Free',
296
+ features: TIERS.free.features,
297
+ limits: TIERS.free.limits,
298
+ key: null,
299
+ active: false,
300
+ };
301
+ }
302
+
303
+ // Re-validate stored key
304
+ const validation = validateKeyFormat(stored.key);
305
+ if (!validation.valid) {
306
+ removeLicense();
307
+ return {
308
+ tier: 'free',
309
+ name: 'Free (invalid key removed)',
310
+ features: TIERS.free.features,
311
+ limits: TIERS.free.limits,
312
+ key: null,
313
+ active: false,
314
+ };
315
+ }
316
+
317
+ const tier = TIERS[stored.tier] || TIERS.free;
318
+
319
+ return {
320
+ tier: stored.tier,
321
+ name: stored.name,
322
+ features: tier.features,
323
+ limits: tier.limits,
324
+ key: stored.key,
325
+ active: true,
326
+ activatedAt: stored.activatedAt,
327
+ };
328
+ }
329
+
330
+ // ─── Feature Gating ──────────────────────────────────────────────
331
+
332
+ /**
333
+ * Check if a feature is available in the current license
334
+ * @param {string} feature - Feature identifier
335
+ * @returns {boolean}
336
+ */
337
+ function hasFeature(feature) {
338
+ const info = getLicenseInfo();
339
+ if (info.features.includes('*')) return true; // Owner has everything
340
+ return info.features.includes(feature);
341
+ }
342
+
343
+ /**
344
+ * Check if a Pro feature is gated and return appropriate message
345
+ * @param {string} feature - Feature identifier
346
+ * @param {string} featureLabel - Human-readable feature name
347
+ * @returns {{ allowed: boolean, message: string|null }}
348
+ */
349
+ function checkProFeature(feature, featureLabel) {
350
+ if (hasFeature(feature)) {
351
+ return { allowed: true, message: null };
352
+ }
353
+
354
+ return {
355
+ allowed: false,
356
+ message: [
357
+ `⚡ ${featureLabel} requires a license`,
358
+ '',
359
+ ' Activate: filemayor license activate YOUR-KEY',
360
+ ' More info: https://filemayor.com',
361
+ '',
362
+ ].join('\n'),
363
+ };
364
+ }
365
+
366
+ /**
367
+ * Check bulk organize limit
368
+ * @param {number} fileCount - Number of files to organize
369
+ * @returns {{ allowed: boolean, message: string|null }}
370
+ */
371
+ function checkBulkLimit(fileCount) {
372
+ const info = getLicenseInfo();
373
+ if (info.limits.bulkOrganize >= fileCount) {
374
+ return { allowed: true, message: null };
375
+ }
376
+
377
+ return {
378
+ allowed: false,
379
+ message: [
380
+ `⚡ Bulk organize (${fileCount} files) exceeds your current limit`,
381
+ ` This license tier is limited to ${info.limits.bulkOrganize} files per operation.`,
382
+ '',
383
+ ' More info: https://filemayor.com',
384
+ ].join('\n'),
385
+ };
386
+ }
387
+
388
+ // ─── Exports ─────────────────────────────────────────────────────
389
+
390
+ module.exports = {
391
+ TIERS,
392
+ BUILTIN_KEYS,
393
+ validateKeyFormat,
394
+ generateKey,
395
+ readLicense,
396
+ activateLicense,
397
+ deactivateLicense,
398
+ getLicenseInfo,
399
+ hasFeature,
400
+ checkProFeature,
401
+ checkBulkLimit,
402
+ LICENSE_FILE,
403
+ };