filemayor 3.6.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,339 +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
- // Ported from Admin Dashboard simpleHash for cross-platform compatibility
70
- let hash = 0;
71
- for (let i = 0; i < body.length; i++) {
72
- hash = ((hash << 5) - hash + body.charCodeAt(i)) | 0;
73
- }
74
- return Math.abs(hash).toString(36).toUpperCase().padEnd(5, '0').substring(0, 5);
75
- }
76
-
77
- /**
78
- * Validate key format: FM-XXX-XXXX-XXXX-XXXXX
79
- * Last 5 chars are HMAC checksum of the rest
80
- * @param {string} key - License key
81
- * @returns {{ valid: boolean, tier: string|null, reason: string }}
82
- */
83
- function validateKeyFormat(key) {
84
- if (!key || typeof key !== 'string') {
85
- return { valid: false, tier: null, reason: 'No key provided' };
86
- }
87
-
88
- const k = key.trim().toUpperCase();
89
-
90
- // Check built-in keys first
91
- if (BUILTIN_KEYS[k]) {
92
- return { valid: true, tier: BUILTIN_KEYS[k].tier, reason: 'Built-in key' };
93
- }
94
-
95
- // Format: FM-XXX-XXXX-XXXX-XXXXX
96
- const pattern = /^FM-(PRO|ENT|OWN|TST)-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{5}$/;
97
- if (!pattern.test(k)) {
98
- return { valid: false, tier: null, reason: 'Invalid key format' };
99
- }
100
-
101
- // Extract parts
102
- const parts = k.split('-');
103
- const tierCode = parts[1];
104
- const body = parts.slice(0, 4).join('-'); // FM-XXX-XXXX-XXXX
105
- const checksum = parts[4]; // XXXXX
106
-
107
- // Verify checksum
108
- const expectedChecksum = generateChecksum(body);
109
- if (checksum !== expectedChecksum) {
110
- return { valid: false, tier: null, reason: 'Invalid key (checksum failed)' };
111
- }
112
-
113
- // Map tier code
114
- const tierMap = { PRO: 'pro', ENT: 'enterprise', OWN: 'owner', TST: 'pro' };
115
- const tier = tierMap[tierCode] || 'free';
116
-
117
- return { valid: true, tier, reason: 'Valid' };
118
- }
119
-
120
- /**
121
- * Generate a new license key
122
- * @param {'pro'|'enterprise'|'owner'} tier
123
- * @returns {string} Generated key
124
- */
125
- function generateKey(tier = 'pro') {
126
- const tierMap = { pro: 'PRO', enterprise: 'ENT', owner: 'OWN' };
127
- const code = tierMap[tier] || 'PRO';
128
-
129
- const seg1 = crypto.randomBytes(2).toString('hex').toUpperCase().substring(0, 4);
130
- const seg2 = crypto.randomBytes(2).toString('hex').toUpperCase().substring(0, 4);
131
- const body = `FM-${code}-${seg1}-${seg2}`;
132
- const checksum = generateChecksum(body);
133
-
134
- return `${body}-${checksum}`;
135
- }
136
-
137
- // ─── License Storage ──────────────────────────────────────────────
138
-
139
- /**
140
- * Read stored license from disk
141
- * @returns {{ key: string, tier: string, activatedAt: string, name: string }|null}
142
- */
143
- function readLicense() {
144
- try {
145
- if (!fs.existsSync(LICENSE_FILE)) return null;
146
- const data = JSON.parse(fs.readFileSync(LICENSE_FILE, 'utf8'));
147
- return data;
148
- } catch {
149
- return null;
150
- }
151
- }
152
-
153
- /**
154
- * Write license to disk
155
- * @param {Object} licenseData
156
- */
157
- function writeLicense(licenseData) {
158
- fs.writeFileSync(LICENSE_FILE, JSON.stringify(licenseData, null, 2), 'utf8');
159
- }
160
-
161
- /**
162
- * Remove stored license
163
- */
164
- function removeLicense() {
165
- if (fs.existsSync(LICENSE_FILE)) {
166
- fs.unlinkSync(LICENSE_FILE);
167
- }
168
- }
169
-
170
- // ─── License Management ──────────────────────────────────────────
171
-
172
- /**
173
- * Activate a license key
174
- * @param {string} key - License key to activate
175
- * @returns {{ success: boolean, message: string, tier: string|null }}
176
- */
177
- function activateLicense(key) {
178
- const validation = validateKeyFormat(key);
179
-
180
- if (!validation.valid) {
181
- return { success: false, message: validation.reason, tier: null };
182
- }
183
-
184
- const k = key.trim().toUpperCase();
185
- const builtin = BUILTIN_KEYS[k];
186
-
187
- const licenseData = {
188
- key: k,
189
- tier: validation.tier,
190
- name: builtin ? builtin.name : `${TIERS[validation.tier].name} License`,
191
- activatedAt: new Date().toISOString(),
192
- expires: builtin ? null : null, // LemonSqueezy keys can add expiry later
193
- };
194
-
195
- writeLicense(licenseData);
196
-
197
- return {
198
- success: true,
199
- message: `${TIERS[validation.tier].name} license activated successfully`,
200
- tier: validation.tier,
201
- };
202
- }
203
-
204
- /**
205
- * Deactivate the current license
206
- * @returns {{ success: boolean, message: string }}
207
- */
208
- function deactivateLicense() {
209
- const current = readLicense();
210
- if (!current) {
211
- return { success: false, message: 'No active license found' };
212
- }
213
- removeLicense();
214
- return { success: true, message: 'License deactivated. Reverted to Free tier.' };
215
- }
216
-
217
- /**
218
- * Get current license info
219
- * @returns {{ tier: string, name: string, features: string[], limits: Object, key: string|null, active: boolean }}
220
- */
221
- function getLicenseInfo() {
222
- const stored = readLicense();
223
-
224
- if (!stored) {
225
- return {
226
- tier: 'free',
227
- name: 'Free',
228
- features: TIERS.free.features,
229
- limits: TIERS.free.limits,
230
- key: null,
231
- active: false,
232
- };
233
- }
234
-
235
- // Re-validate stored key
236
- const validation = validateKeyFormat(stored.key);
237
- if (!validation.valid) {
238
- removeLicense();
239
- return {
240
- tier: 'free',
241
- name: 'Free (invalid key removed)',
242
- features: TIERS.free.features,
243
- limits: TIERS.free.limits,
244
- key: null,
245
- active: false,
246
- };
247
- }
248
-
249
- const tier = TIERS[stored.tier] || TIERS.free;
250
-
251
- return {
252
- tier: stored.tier,
253
- name: stored.name,
254
- features: tier.features,
255
- limits: tier.limits,
256
- key: stored.key,
257
- active: true,
258
- activatedAt: stored.activatedAt,
259
- };
260
- }
261
-
262
- // ─── Feature Gating ──────────────────────────────────────────────
263
-
264
- /**
265
- * Check if a feature is available in the current license
266
- * @param {string} feature - Feature identifier
267
- * @returns {boolean}
268
- */
269
- function hasFeature(feature) {
270
- const info = getLicenseInfo();
271
- if (info.features.includes('*')) return true; // Owner has everything
272
- return info.features.includes(feature);
273
- }
274
-
275
- /**
276
- * Check if a Pro feature is gated and return appropriate message
277
- * @param {string} feature - Feature identifier
278
- * @param {string} featureLabel - Human-readable feature name
279
- * @returns {{ allowed: boolean, message: string|null }}
280
- */
281
- function checkProFeature(feature, featureLabel) {
282
- if (hasFeature(feature)) {
283
- return { allowed: true, message: null };
284
- }
285
-
286
- return {
287
- allowed: false,
288
- message: [
289
- c('yellow', `⚡ ${featureLabel} is a Pro Feature`),
290
- '',
291
- ` FileMayor Core is free. ${featureLabel} requires a Pro License.`,
292
- ' Unlocks: Real-time Watch, AI SOP Engine, and Unlimited Bulk processing.',
293
- '',
294
- ' Get Key: https://filemayor.com/pro',
295
- ' Activate: filemayor license activate YOUR-KEY',
296
- '',
297
- ].join('\n'),
298
- };
299
- }
300
-
301
- /**
302
- * Check bulk organize limit
303
- * @param {number} fileCount - Number of files to organize
304
- * @returns {{ allowed: boolean, message: string|null }}
305
- */
306
- function checkBulkLimit(fileCount) {
307
- const info = getLicenseInfo();
308
- if (info.limits.bulkOrganize >= fileCount) {
309
- return { allowed: true, message: null };
310
- }
311
-
312
- return {
313
- allowed: false,
314
- message: [
315
- `⚡ Bulk organize (${fileCount} files) requires a Pro license`,
316
- ` Free tier is limited to ${info.limits.bulkOrganize} files per operation.`,
317
- '',
318
- ' Upgrade: https://filemayor.lemonsqueezy.com/checkout/buy/7fdcc87f-0660-4c1c-b3db-99f94773b71a',
319
- ' Then run: filemayor license activate YOUR-KEY',
320
- ].join('\n'),
321
- };
322
- }
323
-
324
- // ─── Exports ─────────────────────────────────────────────────────
325
-
326
- module.exports = {
327
- TIERS,
328
- BUILTIN_KEYS,
329
- validateKeyFormat,
330
- generateKey,
331
- readLicense,
332
- activateLicense,
333
- deactivateLicense,
334
- getLicenseInfo,
335
- hasFeature,
336
- checkProFeature,
337
- checkBulkLimit,
338
- LICENSE_FILE,
339
- };
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
+ };