securenow 5.10.2 → 5.11.1

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.
@@ -400,8 +400,38 @@ OTEL_LOG_LEVEL=debug # Enable debug output
400
400
 
401
401
  ---
402
402
 
403
+ ---
404
+
405
+ ## Firewall — Automatic IP Blocking
406
+
407
+ If you use the SecureNow blocklist to block malicious IPs, the firewall module can enforce that blocklist directly in your app with zero code changes.
408
+
409
+ ### Enable It
410
+
411
+ Add your API key to `.env`:
412
+
413
+ ```bash
414
+ SECURENOW_API_KEY=snk_live_abc123...
415
+ ```
416
+
417
+ The firewall activates automatically on startup and syncs the blocklist every 60 seconds:
418
+
419
+ ```
420
+ [securenow] Firewall: ENABLED
421
+ [securenow] Firewall: Layer 1 (HTTP 403) active
422
+ [securenow] Firewall: synced 142 blocked IPs
423
+ ```
424
+
425
+ Blocked IPs get a 403 Forbidden response. No code changes needed — works with Express, Next.js, Fastify, and all Node.js frameworks.
426
+
427
+ See the [Firewall Guide](./docs/FIREWALL-GUIDE.md) for advanced layers (TCP blocking, iptables, Cloud WAF).
428
+
429
+ ---
430
+
403
431
  ## Complete Documentation
404
432
 
433
+ - [Firewall Guide](./docs/FIREWALL-GUIDE.md)
434
+ - [API Keys Guide](./docs/API-KEYS-GUIDE.md)
405
435
  - [Logging Quick Start](./docs/LOGGING-QUICKSTART.md)
406
436
  - [Logging Complete Guide](./docs/LOGGING-GUIDE.md)
407
437
  - [All Examples](./examples/)
package/NPM_README.md CHANGED
@@ -8,6 +8,7 @@ OpenTelemetry instrumentation library for Node.js, Next.js, and Nuxt application
8
8
  - 📋 Automatic logging with console instrumentation
9
9
  - 🔐 Built-in sensitive data redaction
10
10
  - 🎯 Request body capture for debugging
11
+ - 🛡️ Multi-layer firewall — auto-blocks IPs from your SecureNow blocklist
11
12
  - 🔧 Fully configurable via environment variables
12
13
  - 🖥️ Single `-r securenow/register` flag — works for both CJS and ESM apps
13
14
  - 🟢 Native Nuxt 3 module (`securenow/nuxt`)
@@ -27,6 +28,7 @@ OpenTelemetry instrumentation library for Node.js, Next.js, and Nuxt application
27
28
  - [NestJS](#nestjs)
28
29
  - [Koa](#koa)
29
30
  - [Hapi](#hapi)
31
+ - [Firewall — Automatic IP Blocking](#firewall--automatic-ip-blocking)
30
32
  - [Environment Variables Reference](#environment-variables-reference)
31
33
  - [Logging Setup](#logging-setup)
32
34
  - [Request Body Capture](#request-body-capture)
@@ -373,6 +375,8 @@ fi
373
375
  | | `forensics library` | Saved queries |
374
376
  | | `api-map` | API endpoints |
375
377
  | | `api-map stats` | API stats |
378
+ | **Firewall** | `firewall status` | Firewall status and API key info |
379
+ | | `firewall test-ip <ip>` | Check if an IP would be blocked |
376
380
  | **Remediate** | `blocklist` | Blocked IPs |
377
381
  | | `blocklist add <ip>` | Block IP |
378
382
  | | `blocklist remove <id>` | Unblock IP |
@@ -862,6 +866,49 @@ export default defineNuxtConfig({
862
866
 
863
867
  ---
864
868
 
869
+ ## Firewall — Automatic IP Blocking
870
+
871
+ SecureNow can automatically block IPs from your blocklist at the application layer. No code changes — just set an API key and the firewall activates.
872
+
873
+ ### Enable the Firewall
874
+
875
+ ```bash
876
+ # Add to your .env
877
+ SECURENOW_API_KEY=snk_live_abc123...
878
+ ```
879
+
880
+ That's it. On startup, you'll see:
881
+
882
+ ```
883
+ [securenow] Firewall: ENABLED
884
+ [securenow] Firewall: Layer 1 (HTTP 403) active
885
+ [securenow] Firewall: synced 142 blocked IPs (138 exact + 4 CIDR ranges)
886
+ ```
887
+
888
+ ### Blocking Layers
889
+
890
+ The firewall supports four layers — Layer 1 is always on, the rest are opt-in:
891
+
892
+ | Layer | Env Var | Description |
893
+ |-------|---------|-------------|
894
+ | **Layer 1: HTTP** | *(always on)* | Returns 403 Forbidden. Works with proxy headers. |
895
+ | **Layer 2: TCP** | `SECURENOW_FIREWALL_TCP=1` | `socket.destroy()` — zero bytes sent back |
896
+ | **Layer 3: iptables** | `SECURENOW_FIREWALL_IPTABLES=1` | Kernel-level DROP (Linux, requires root) |
897
+ | **Layer 4: Cloud WAF** | `SECURENOW_FIREWALL_CLOUD=cloudflare` | Pushes to Cloudflare, AWS WAF, or GCP Cloud Armor |
898
+
899
+ ### Get an API Key
900
+
901
+ ```bash
902
+ npx securenow login
903
+ npx securenow firewall status
904
+ ```
905
+
906
+ Or create one from the dashboard with the `firewall:read` scope.
907
+
908
+ See the [Firewall Guide](./docs/FIREWALL-GUIDE.md) for the full reference.
909
+
910
+ ---
911
+
865
912
  ## Environment Variables Reference
866
913
 
867
914
  ### Required Variables
@@ -915,6 +962,22 @@ export default defineNuxtConfig({
915
962
 
916
963
  **Example:** `SECURENOW_DISABLE_INSTRUMENTATIONS=fs,dns` disables filesystem and DNS instrumentations.
917
964
 
965
+ #### Firewall
966
+
967
+ | Variable | Description | Default |
968
+ |----------|-------------|---------|
969
+ | `SECURENOW_API_KEY` | API key with `firewall:read` scope. Enables the firewall when set. | - |
970
+ | `SECURENOW_API_URL` | SecureNow API base URL. | `https://api.securenow.ai` |
971
+ | `SECURENOW_FIREWALL_ENABLED` | Master kill-switch. Set to `0` to disable. | `1` |
972
+ | `SECURENOW_FIREWALL_SYNC_INTERVAL` | Blocklist refresh interval in seconds. | `60` |
973
+ | `SECURENOW_FIREWALL_FAIL_MODE` | `open` (allow when unavailable) or `closed` (block all). | `open` |
974
+ | `SECURENOW_FIREWALL_TCP` | Enable Layer 2 TCP blocking. | `0` |
975
+ | `SECURENOW_FIREWALL_IPTABLES` | Enable Layer 3 iptables blocking. | `0` |
976
+ | `SECURENOW_FIREWALL_CLOUD` | Cloud WAF provider: `cloudflare`, `aws`, or `gcp`. | - |
977
+ | `SECURENOW_TRUSTED_PROXIES` | Comma-separated trusted proxy IPs. | - |
978
+
979
+ See [Firewall Guide](./docs/FIREWALL-GUIDE.md) for complete details on all layers.
980
+
918
981
  #### Debugging
919
982
 
920
983
  | Variable | Description | Default |
@@ -1574,6 +1637,8 @@ No code changes needed!
1574
1637
  - **Automatic redaction** of sensitive fields (passwords, tokens, keys)
1575
1638
  - **Configurable** sensitive field patterns
1576
1639
  - **No data stored** locally - everything sent to your OTLP backend
1640
+ - **Multi-layer firewall** — blocks IPs at HTTP, TCP, OS, and cloud-edge levels
1641
+ - **API keys** with granular scopes, IP allowlisting, and SHA-256 hashing
1577
1642
  - **Open source** - audit the code yourself
1578
1643
 
1579
1644
  ---
package/README.md CHANGED
@@ -121,6 +121,10 @@ npx securenow ip 1.2.3.4
121
121
  npx securenow forensics "show top attacking IPs in the last hour"
122
122
  npx securenow blocklist add 1.2.3.4 --reason "scanner"
123
123
 
124
+ # Firewall — automatic IP blocking
125
+ npx securenow firewall status
126
+ npx securenow firewall test-ip 1.2.3.4
127
+
124
128
  # Full dashboard overview
125
129
  npx securenow status
126
130
  ```
@@ -223,6 +227,8 @@ SecureNow automatically instruments:
223
227
  - **[Logging Quick Start](./docs/LOGGING-QUICKSTART.md)** - Add logging in 2 minutes
224
228
 
225
229
  ### Complete Guides
230
+ - **[Firewall Guide](./docs/FIREWALL-GUIDE.md)** - Automatic multi-layer IP blocking
231
+ - **[API Keys Guide](./docs/API-KEYS-GUIDE.md)** - API key management and scopes
226
232
  - **[Next.js Complete Guide](./docs/NEXTJS-GUIDE.md)** - Full Next.js integration guide
227
233
  - **[Nuxt 3 Complete Guide](./docs/NUXT-GUIDE.md)** - Full Nuxt 3 integration guide
228
234
  - **[Logging Complete Guide](./docs/LOGGING-GUIDE.md)** - Full logging setup for all frameworks
@@ -303,6 +309,13 @@ Most users won't need this — just add `-r securenow/register` to your existing
303
309
  | `securenow api-map` | View discovered API endpoints |
304
310
  | `securenow api-map stats` | API map statistics |
305
311
 
312
+ ### Firewall
313
+
314
+ | Command | Description |
315
+ |---------|-------------|
316
+ | `securenow firewall status` | Show firewall status, active layers, and API key info |
317
+ | `securenow firewall test-ip <ip>` | Check if an IP would be blocked by the current blocklist |
318
+
306
319
  ### Remediation
307
320
 
308
321
  | Command | Description |
package/cidr.js ADDED
@@ -0,0 +1,83 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Bitmask-based CIDR matching. No regex on user input — prevents ReDoS.
5
+ * All operations use unsigned 32-bit integers for IPv4 addresses.
6
+ */
7
+
8
+ function ipToInt(ip) {
9
+ const parts = ip.split('.');
10
+ if (parts.length !== 4) return null;
11
+ let result = 0;
12
+ for (let i = 0; i < 4; i++) {
13
+ const n = parseInt(parts[i], 10);
14
+ if (isNaN(n) || n < 0 || n > 255) return null;
15
+ result = (result << 8) + n;
16
+ }
17
+ return result >>> 0;
18
+ }
19
+
20
+ function parseCidr(cidr) {
21
+ const slash = cidr.indexOf('/');
22
+ if (slash === -1) return null;
23
+ const ip = cidr.slice(0, slash);
24
+ const bits = parseInt(cidr.slice(slash + 1), 10);
25
+ if (isNaN(bits) || bits < 0 || bits > 32) return null;
26
+ const network = ipToInt(ip);
27
+ if (network === null) return null;
28
+ const mask = bits === 0 ? 0 : (~((1 << (32 - bits)) - 1)) >>> 0;
29
+ return { network: (network & mask) >>> 0, mask };
30
+ }
31
+
32
+ function matchesCidr(ipInt, cidrEntry) {
33
+ return ((ipInt & cidrEntry.mask) >>> 0) === cidrEntry.network;
34
+ }
35
+
36
+ /**
37
+ * Create a matcher from a list of IPs and CIDRs.
38
+ * Returns { isBlocked(ip), stats() }.
39
+ * Exact IPs use a Set for O(1) lookup; CIDRs use array scan (typically < 100 entries).
40
+ */
41
+ function createMatcher(ipList) {
42
+ const exactSet = new Set();
43
+ const cidrRanges = [];
44
+
45
+ for (const entry of ipList) {
46
+ const trimmed = (entry || '').trim();
47
+ if (!trimmed) continue;
48
+
49
+ if (trimmed.includes('/')) {
50
+ const parsed = parseCidr(trimmed);
51
+ if (parsed) cidrRanges.push(parsed);
52
+ } else {
53
+ const normalized = trimmed.replace(/^::ffff:/, '');
54
+ exactSet.add(normalized);
55
+ }
56
+ }
57
+
58
+ function isBlocked(ip) {
59
+ if (!ip) return false;
60
+ const normalized = ip.replace(/^::ffff:/, '');
61
+
62
+ if (exactSet.has(normalized)) return true;
63
+
64
+ if (cidrRanges.length > 0) {
65
+ const ipInt = ipToInt(normalized);
66
+ if (ipInt !== null) {
67
+ for (const cidr of cidrRanges) {
68
+ if (matchesCidr(ipInt, cidr)) return true;
69
+ }
70
+ }
71
+ }
72
+
73
+ return false;
74
+ }
75
+
76
+ function stats() {
77
+ return { exact: exactSet.size, cidr: cidrRanges.length, total: exactSet.size + cidrRanges.length };
78
+ }
79
+
80
+ return { isBlocked, stats };
81
+ }
82
+
83
+ module.exports = { ipToInt, parseCidr, matchesCidr, createMatcher };
package/cli/auth.js CHANGED
@@ -1,208 +1,208 @@
1
- 'use strict';
2
-
3
- const http = require('http');
4
- const { execFileSync } = require('child_process');
5
- const config = require('./config');
6
- const { api, CLIError } = require('./client');
7
- const ui = require('./ui');
8
-
9
- function openBrowser(url) {
10
- try {
11
- const platform = process.platform;
12
- if (platform === 'darwin') execFileSync('open', [url], { stdio: 'ignore' });
13
- else if (platform === 'win32') execFileSync('rundll32', ['url.dll,FileProtocolHandler', url], { stdio: 'ignore' });
14
- else execFileSync('xdg-open', [url], { stdio: 'ignore' });
15
- return true;
16
- } catch {
17
- return false;
18
- }
19
- }
20
-
21
- function decodeJwtPayload(token) {
22
- try {
23
- const parts = token.split('.');
24
- if (parts.length !== 3) return null;
25
- const payload = Buffer.from(parts[1], 'base64url').toString('utf8');
26
- return JSON.parse(payload);
27
- } catch {
28
- try {
29
- const parts = token.split('.');
30
- const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
31
- const padded = base64 + '='.repeat((4 - base64.length % 4) % 4);
32
- const payload = Buffer.from(padded, 'base64').toString('utf8');
33
- return JSON.parse(payload);
34
- } catch {
35
- return null;
36
- }
37
- }
38
- }
39
-
40
- async function loginWithBrowser() {
41
- const appUrl = config.getAppUrl();
42
-
43
- return new Promise((resolve, reject) => {
44
- const server = http.createServer((req, res) => {
45
- const url = new URL(req.url, `http://localhost`);
46
-
47
- if (url.pathname === '/callback') {
48
- const token = url.searchParams.get('token');
49
- const error = url.searchParams.get('error');
50
-
51
- res.writeHead(200, { 'Content-Type': 'text/html' });
52
-
53
- if (error) {
54
- res.end('<html><body style="font-family:system-ui;text-align:center;padding:60px"><h2>Authentication Failed</h2><p>You can close this window.</p></body></html>');
55
- server.close();
56
- reject(new CLIError(`Authentication failed: ${error}`));
57
- return;
58
- }
59
-
60
- if (token) {
61
- res.end('<html><body style="font-family:system-ui;text-align:center;padding:60px"><h2 style="color:#22c55e">✓ Logged in to SecureNow</h2><p>You can close this window and return to the terminal.</p></body></html>');
62
- server.close();
63
- resolve(token);
64
- return;
65
- }
66
-
67
- res.end('<html><body style="font-family:system-ui;text-align:center;padding:60px"><h2>Something went wrong</h2><p>No token received. Please try again.</p></body></html>');
68
- server.close();
69
- reject(new CLIError('No token received in callback'));
70
- return;
71
- }
72
-
73
- res.writeHead(404);
74
- res.end();
75
- });
76
-
77
- server.listen(0, '127.0.0.1', () => {
78
- const port = server.address().port;
79
- const authUrl = `${appUrl}/cli/auth?callback=http://localhost:${port}/callback`;
80
-
81
- console.log('');
82
- ui.info('Opening browser for authentication...');
83
- console.log('');
84
-
85
- const opened = openBrowser(authUrl);
86
- if (!opened) {
87
- console.log(' Open this URL in your browser to log in:\n');
88
- console.log(` ${ui.c.underline(ui.c.cyan(authUrl))}\n`);
89
- } else {
90
- console.log(` If the browser didn't open, visit:`);
91
- console.log(` ${ui.c.underline(ui.c.cyan(authUrl))}\n`);
92
- }
93
-
94
- console.log(ui.c.dim(' Waiting for authentication...'));
95
-
96
- const timeout = setTimeout(() => {
97
- server.close();
98
- reject(new CLIError('Login timed out after 5 minutes. Try `securenow login --token <TOKEN>` instead.'));
99
- }, 5 * 60 * 1000);
100
-
101
- server.on('close', () => clearTimeout(timeout));
102
- });
103
-
104
- server.on('error', (err) => {
105
- reject(new CLIError(`Failed to start local server: ${err.message}`));
106
- });
107
- });
108
- }
109
-
110
- async function loginWithToken(token) {
111
- const s = ui.spinner('Validating token');
112
- try {
113
- await api.get('/applications', { token });
114
- s.stop('Token is valid');
115
- return token;
116
- } catch (err) {
117
- s.fail('Token validation failed');
118
- throw new CLIError(`Invalid token: ${err.message}`);
119
- }
120
- }
121
-
122
- async function login(args, flags) {
123
- if (flags.token) {
124
- const token = flags.token;
125
- await loginWithToken(token);
126
- const payload = decodeJwtPayload(token);
127
- const email = payload?.email || 'unknown';
128
- const exp = payload?.exp ? payload.exp * 1000 : null;
129
-
130
- config.setAuth(token, email, exp);
131
- console.log('');
132
- ui.success(`Logged in as ${ui.c.bold(email)}`);
133
- if (exp) {
134
- const days = Math.ceil((exp - Date.now()) / (1000 * 60 * 60 * 24));
135
- ui.info(`Session expires in ${days} days`);
136
- }
137
- return;
138
- }
139
-
140
- try {
141
- const token = await loginWithBrowser();
142
- const payload = decodeJwtPayload(token);
143
- const email = payload?.email || 'unknown';
144
- const exp = payload?.exp ? payload.exp * 1000 : null;
145
-
146
- config.setAuth(token, email, exp);
147
- console.log('');
148
- ui.success(`Logged in as ${ui.c.bold(email)}`);
149
- if (exp) {
150
- const days = Math.ceil((exp - Date.now()) / (1000 * 60 * 60 * 24));
151
- ui.info(`Session expires in ${days} days`);
152
- }
153
- } catch (err) {
154
- if (err.message.includes('timed out')) {
155
- console.log('');
156
- ui.warn('Browser login timed out. You can also login with a token:');
157
- console.log('');
158
- console.log(` 1. Go to ${ui.c.cyan(config.getAppUrl() + '/dashboard/settings')}`);
159
- console.log(` 2. Copy your CLI token`);
160
- console.log(` 3. Run: ${ui.c.bold('securenow login --token <YOUR_TOKEN>')}`);
161
- console.log('');
162
- } else {
163
- throw err;
164
- }
165
- }
166
- }
167
-
168
- async function logout() {
169
- const creds = config.loadCredentials();
170
- config.clearCredentials();
171
- if (creds.email) {
172
- ui.success(`Logged out from ${ui.c.bold(creds.email)}`);
173
- } else {
174
- ui.success('Logged out');
175
- }
176
- }
177
-
178
- async function whoami() {
179
- const creds = config.loadCredentials();
180
- const token = config.getToken();
181
-
182
- if (!token) {
183
- ui.error('Not logged in. Run `securenow login` to authenticate.');
184
- process.exit(1);
185
- }
186
-
187
- const payload = decodeJwtPayload(token);
188
-
189
- ui.heading('Current Session');
190
- console.log('');
191
- const pairs = [
192
- ['Email', creds.email || payload?.email || 'unknown'],
193
- ['User ID', payload?.sub || 'unknown'],
194
- ['API', config.getApiUrl()],
195
- ];
196
- if (creds.expiresAt) {
197
- const days = Math.ceil((creds.expiresAt - Date.now()) / (1000 * 60 * 60 * 24));
198
- pairs.push(['Expires', days > 0 ? `in ${days} days` : ui.c.red('expired')]);
199
- }
200
- const defaultApp = config.getDefaultApp();
201
- if (defaultApp) {
202
- pairs.push(['Default App', defaultApp]);
203
- }
204
- ui.keyValue(pairs);
205
- console.log('');
206
- }
207
-
208
- module.exports = { login, logout, whoami };
1
+ 'use strict';
2
+
3
+ const http = require('http');
4
+ const { execFileSync } = require('child_process');
5
+ const config = require('./config');
6
+ const { api, CLIError } = require('./client');
7
+ const ui = require('./ui');
8
+
9
+ function openBrowser(url) {
10
+ try {
11
+ const platform = process.platform;
12
+ if (platform === 'darwin') execFileSync('open', [url], { stdio: 'ignore' });
13
+ else if (platform === 'win32') execFileSync('rundll32', ['url.dll,FileProtocolHandler', url], { stdio: 'ignore' });
14
+ else execFileSync('xdg-open', [url], { stdio: 'ignore' });
15
+ return true;
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ function decodeJwtPayload(token) {
22
+ try {
23
+ const parts = token.split('.');
24
+ if (parts.length !== 3) return null;
25
+ const payload = Buffer.from(parts[1], 'base64url').toString('utf8');
26
+ return JSON.parse(payload);
27
+ } catch {
28
+ try {
29
+ const parts = token.split('.');
30
+ const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
31
+ const padded = base64 + '='.repeat((4 - base64.length % 4) % 4);
32
+ const payload = Buffer.from(padded, 'base64').toString('utf8');
33
+ return JSON.parse(payload);
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+ }
39
+
40
+ async function loginWithBrowser() {
41
+ const appUrl = config.getAppUrl();
42
+
43
+ return new Promise((resolve, reject) => {
44
+ const server = http.createServer((req, res) => {
45
+ const url = new URL(req.url, `http://localhost`);
46
+
47
+ if (url.pathname === '/callback') {
48
+ const token = url.searchParams.get('token');
49
+ const error = url.searchParams.get('error');
50
+
51
+ res.writeHead(200, { 'Content-Type': 'text/html' });
52
+
53
+ if (error) {
54
+ res.end('<html><body style="font-family:system-ui;text-align:center;padding:60px"><h2>Authentication Failed</h2><p>You can close this window.</p></body></html>');
55
+ server.close();
56
+ reject(new CLIError(`Authentication failed: ${error}`));
57
+ return;
58
+ }
59
+
60
+ if (token) {
61
+ res.end('<html><body style="font-family:system-ui;text-align:center;padding:60px"><h2 style="color:#22c55e">✓ Logged in to SecureNow</h2><p>You can close this window and return to the terminal.</p></body></html>');
62
+ server.close();
63
+ resolve(token);
64
+ return;
65
+ }
66
+
67
+ res.end('<html><body style="font-family:system-ui;text-align:center;padding:60px"><h2>Something went wrong</h2><p>No token received. Please try again.</p></body></html>');
68
+ server.close();
69
+ reject(new CLIError('No token received in callback'));
70
+ return;
71
+ }
72
+
73
+ res.writeHead(404);
74
+ res.end();
75
+ });
76
+
77
+ server.listen(0, '127.0.0.1', () => {
78
+ const port = server.address().port;
79
+ const authUrl = `${appUrl}/cli/auth?callback=http://localhost:${port}/callback`;
80
+
81
+ console.log('');
82
+ ui.info('Opening browser for authentication...');
83
+ console.log('');
84
+
85
+ const opened = openBrowser(authUrl);
86
+ if (!opened) {
87
+ console.log(' Open this URL in your browser to log in:\n');
88
+ console.log(` ${ui.c.underline(ui.c.cyan(authUrl))}\n`);
89
+ } else {
90
+ console.log(` If the browser didn't open, visit:`);
91
+ console.log(` ${ui.c.underline(ui.c.cyan(authUrl))}\n`);
92
+ }
93
+
94
+ console.log(ui.c.dim(' Waiting for authentication...'));
95
+
96
+ const timeout = setTimeout(() => {
97
+ server.close();
98
+ reject(new CLIError('Login timed out after 5 minutes. Try `securenow login --token <TOKEN>` instead.'));
99
+ }, 5 * 60 * 1000);
100
+
101
+ server.on('close', () => clearTimeout(timeout));
102
+ });
103
+
104
+ server.on('error', (err) => {
105
+ reject(new CLIError(`Failed to start local server: ${err.message}`));
106
+ });
107
+ });
108
+ }
109
+
110
+ async function loginWithToken(token) {
111
+ const s = ui.spinner('Validating token');
112
+ try {
113
+ await api.get('/applications', { token });
114
+ s.stop('Token is valid');
115
+ return token;
116
+ } catch (err) {
117
+ s.fail('Token validation failed');
118
+ throw new CLIError(`Invalid token: ${err.message}`);
119
+ }
120
+ }
121
+
122
+ async function login(args, flags) {
123
+ if (flags.token) {
124
+ const token = flags.token;
125
+ await loginWithToken(token);
126
+ const payload = decodeJwtPayload(token);
127
+ const email = payload?.email || 'unknown';
128
+ const exp = payload?.exp ? payload.exp * 1000 : null;
129
+
130
+ config.setAuth(token, email, exp);
131
+ console.log('');
132
+ ui.success(`Logged in as ${ui.c.bold(email)}`);
133
+ if (exp) {
134
+ const days = Math.ceil((exp - Date.now()) / (1000 * 60 * 60 * 24));
135
+ ui.info(`Session expires in ${days} days`);
136
+ }
137
+ return;
138
+ }
139
+
140
+ try {
141
+ const token = await loginWithBrowser();
142
+ const payload = decodeJwtPayload(token);
143
+ const email = payload?.email || 'unknown';
144
+ const exp = payload?.exp ? payload.exp * 1000 : null;
145
+
146
+ config.setAuth(token, email, exp);
147
+ console.log('');
148
+ ui.success(`Logged in as ${ui.c.bold(email)}`);
149
+ if (exp) {
150
+ const days = Math.ceil((exp - Date.now()) / (1000 * 60 * 60 * 24));
151
+ ui.info(`Session expires in ${days} days`);
152
+ }
153
+ } catch (err) {
154
+ if (err.message.includes('timed out')) {
155
+ console.log('');
156
+ ui.warn('Browser login timed out. You can also login with a token:');
157
+ console.log('');
158
+ console.log(` 1. Go to ${ui.c.cyan(config.getAppUrl() + '/dashboard/settings')}`);
159
+ console.log(` 2. Copy your CLI token`);
160
+ console.log(` 3. Run: ${ui.c.bold('securenow login --token <YOUR_TOKEN>')}`);
161
+ console.log('');
162
+ } else {
163
+ throw err;
164
+ }
165
+ }
166
+ }
167
+
168
+ async function logout() {
169
+ const creds = config.loadCredentials();
170
+ config.clearCredentials();
171
+ if (creds.email) {
172
+ ui.success(`Logged out from ${ui.c.bold(creds.email)}`);
173
+ } else {
174
+ ui.success('Logged out');
175
+ }
176
+ }
177
+
178
+ async function whoami() {
179
+ const creds = config.loadCredentials();
180
+ const token = config.getToken();
181
+
182
+ if (!token) {
183
+ ui.error('Not logged in. Run `securenow login` to authenticate.');
184
+ process.exit(1);
185
+ }
186
+
187
+ const payload = decodeJwtPayload(token);
188
+
189
+ ui.heading('Current Session');
190
+ console.log('');
191
+ const pairs = [
192
+ ['Email', creds.email || payload?.email || 'unknown'],
193
+ ['User ID', payload?.sub || 'unknown'],
194
+ ['API', config.getApiUrl()],
195
+ ];
196
+ if (creds.expiresAt) {
197
+ const days = Math.ceil((creds.expiresAt - Date.now()) / (1000 * 60 * 60 * 24));
198
+ pairs.push(['Expires', days > 0 ? `in ${days} days` : ui.c.red('expired')]);
199
+ }
200
+ const defaultApp = config.getDefaultApp();
201
+ if (defaultApp) {
202
+ pairs.push(['Default App', defaultApp]);
203
+ }
204
+ ui.keyValue(pairs);
205
+ console.log('');
206
+ }
207
+
208
+ module.exports = { login, logout, whoami };