securenow 8.11.2 → 8.12.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/TELEMETRY-SQL.md CHANGED
@@ -81,10 +81,15 @@ Event properties are stored in the logs table:
81
81
  | `userId` | `attributes_string['enduser.id']` |
82
82
  | `sessionId` | `attributes_string['session.id']` |
83
83
  | `ip` | `attributes_string['http.client_ip']` |
84
- | `userAgent` | `attributes_string['http.user_agent']` |
84
+ | `userAgent` | `attributes_string['user_agent.original']` (HTTP spans use `http.user_agent`) |
85
85
  | String custom attribute | `attributes_string['<key>']` |
86
86
  | Numeric custom attribute | `attributes_number['<key>']` |
87
87
  | Boolean custom attribute | `attributes_bool['<key>']` |
88
+ | ASN of `ip` (when the instance has ASN enrichment) | `attributes_string['client.asn']`, `attributes_string['client.as_org']` |
89
+
90
+ Custom attribute keys that start with a reserved prefix — `event.`, `enduser.`, `session.`, `http.`, `service.`, `securenow.`, `client.` — are stored as `attr.<key>`. Name custom attributes without those prefixes (`auth_method`, `device_hash`, …). Events carry the timestamp the sender supplied; there is no separate ingest time and no event ID.
91
+
92
+ The logs `timestamp` is UInt64 nanoseconds: filter with `timestamp >= toUnixTimestamp64Nano(now64() - INTERVAL 15 MINUTE)`. `timestamp >= now() - INTERVAL …` is accepted by ClickHouse but always true on this table, so the rule would scan the whole retention period — the API rejects it on rule create/update.
88
93
 
89
94
  Never send raw passwords, tokens, OTPs, session secrets, email addresses, phone numbers, or other unnecessary personal data. Hash destinations when correlation is required.
90
95
 
package/app-config.js CHANGED
@@ -17,9 +17,13 @@ const fs = require('fs');
17
17
  const path = require('path');
18
18
  const os = require('os');
19
19
 
20
- const FREE_TRIAL_INSTANCE = 'https://ingest.securenow.ai';
20
+ // Shared SecureNow Cloud ingestion gateway (production collector for every
21
+ // paying account and for the 14-day trial).
22
+ const SECURENOW_CLOUD_INGEST = 'https://ingest.securenow.ai';
23
+ // Deprecated alias kept for consumers of securenow/app-config.
24
+ const FREE_TRIAL_INSTANCE = SECURENOW_CLOUD_INGEST;
21
25
  const DEFAULT_API_URL = 'https://api.securenow.ai';
22
- const DEFAULT_FIREWALL_API_URL = FREE_TRIAL_INSTANCE;
26
+ const DEFAULT_FIREWALL_API_URL = SECURENOW_CLOUD_INGEST;
23
27
  const LEGACY_SECURENOW_GATEWAY = 'https://api.securenow.ai/api/otlp';
24
28
  const CONFIG_SCHEMA_VERSION = 2;
25
29
  const CREDENTIAL_FILE_ENVIRONMENTS = Object.freeze([
@@ -58,7 +62,6 @@ const DEFAULT_CONFIG = Object.freeze({
58
62
  noUuid: null,
59
63
  strict: false,
60
64
  testSpan: false,
61
- hideBanner: false,
62
65
  },
63
66
  firewall: {
64
67
  enabled: true,
@@ -112,7 +115,6 @@ const CONFIG_EXPLANATIONS = Object.freeze({
112
115
  'config.runtime.noUuid': 'null means auto: true when app.key is present. Set true/false only for advanced routing needs.',
113
116
  'config.runtime.strict': 'If true, PM2/cluster workers exit when no app identity is resolvable.',
114
117
  'config.runtime.testSpan': 'If true, emit a startup smoke span. Prefer `npx securenow test-span` for manual checks.',
115
- 'config.runtime.hideBanner': 'Hide the free-trial response banner when using the managed free-trial collector.',
116
118
  'config.firewall.enabled': 'Deprecated local hint. Runtime firewall enforcement is controlled by the SecureNow dashboard/API app environment toggle; the SDK starts sync whenever apiKey is present.',
117
119
  'config.firewall.apiUrl': 'Optional SecureNow firewall control-plane base URL. Leave unset/default so hosted SDKs sync through the SecureNow ingest gateway.',
118
120
  'config.firewall.versionCheckInterval': 'Seconds between lightweight firewall version/ETag checks.',
@@ -440,13 +442,13 @@ function normalizeInstanceEndpoint(value) {
440
442
  if (value === undefined || value === null || value === '') return value;
441
443
  const endpoint = String(value).trim().replace(/\/$/, '');
442
444
  if (!endpoint) return endpoint;
443
- if (endpoint === LEGACY_SECURENOW_GATEWAY) return FREE_TRIAL_INSTANCE;
445
+ if (endpoint === LEGACY_SECURENOW_GATEWAY) return SECURENOW_CLOUD_INGEST;
444
446
 
445
447
  try {
446
448
  const parseable = /^[a-z][a-z0-9+.-]*:\/\//i.test(endpoint) ? endpoint : `https://${endpoint}`;
447
449
  const url = new URL(parseable);
448
450
  if (url.port === '4318' && url.hostname.toLowerCase().endsWith('.securenow.ai')) {
449
- return FREE_TRIAL_INSTANCE;
451
+ return SECURENOW_CLOUD_INGEST;
450
452
  }
451
453
  } catch {}
452
454
 
@@ -626,7 +628,7 @@ function resolveApiKey() {
626
628
  function resolveInstance() {
627
629
  const fromConfig = pick(resolveConfigPath('otel.endpoint'));
628
630
  if (fromConfig) return normalizeInstanceEndpoint(fromConfig);
629
- return FREE_TRIAL_INSTANCE;
631
+ return SECURENOW_CLOUD_INGEST;
630
632
  }
631
633
 
632
634
  function resolveAll() {
@@ -725,6 +727,7 @@ function resolveFirewallOptions() {
725
727
  }
726
728
 
727
729
  module.exports = {
730
+ SECURENOW_CLOUD_INGEST,
728
731
  FREE_TRIAL_INSTANCE,
729
732
  DEFAULT_API_URL,
730
733
  DEFAULT_FIREWALL_API_URL,
package/cidr.js CHANGED
@@ -1,83 +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 };
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/apps.js CHANGED
@@ -4,11 +4,31 @@ const { api, requireAuth } = require('./client');
4
4
  const config = require('./config');
5
5
  const ui = require('./ui');
6
6
 
7
- const FREE_TRIAL_URL = 'https://ingest.securenow.ai';
7
+ // Shared SecureNow Cloud collector (the production ingestion gateway).
8
+ const SECURENOW_CLOUD_URL = 'https://ingest.securenow.ai';
9
+ const SECURENOW_CLOUD_NAME = 'SecureNow Cloud';
8
10
  const DOMAIN_REGEX = /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
9
11
 
10
12
  function instanceUrl(inst) {
11
- return FREE_TRIAL_URL;
13
+ return SECURENOW_CLOUD_URL;
14
+ }
15
+
16
+ function formatUsd(amount) {
17
+ const n = Number(amount);
18
+ if (!Number.isFinite(n)) return null;
19
+ return Number.isInteger(n) ? `$${n}` : `$${n.toFixed(2)}`;
20
+ }
21
+
22
+ // POST /applications and /applications/bulk return `billing` for the account.
23
+ // Per-app subscribers pay for every app, so tell them the new monthly total.
24
+ // Human mode only: --json output stays exactly what it was.
25
+ function printBillingNotice(billing, flags) {
26
+ if (!billing || !billing.isPerAppSubscriber || (flags && flags.json)) return;
27
+ const count = Number(billing.appCount) || 0;
28
+ const price = formatUsd(billing.perAppPrice) || '$99';
29
+ const total = formatUsd(billing.estimatedMonthlyTotal);
30
+ const totalText = total ? ` → ${total}/month` : '';
31
+ ui.info(`Billing: this account now has ${count} app${count !== 1 ? 's' : ''}${totalText} (${price}/app, prorated).`);
12
32
  }
13
33
 
14
34
  async function fetchInstanceMap() {
@@ -51,7 +71,7 @@ async function list(args, flags) {
51
71
  const rows = apps.map(app => {
52
72
  const inst = app.instanceId ? instMap[app.instanceId] : null;
53
73
  const url = instanceUrl(inst);
54
- const instLabel = inst ? `${inst.name} ${ui.c.dim(url)}` : `${ui.c.green('Free Trial')} ${ui.c.dim(url)}`;
74
+ const instLabel = inst ? `${inst.name} ${ui.c.dim(url)}` : `${ui.c.green(SECURENOW_CLOUD_NAME)} ${ui.c.dim(url)}`;
55
75
  return [
56
76
  app.name + (app.key === defaultApp ? ui.c.cyan(' (default)') : ''),
57
77
  ui.c.dim(app.key),
@@ -120,7 +140,7 @@ async function create(args, flags) {
120
140
  ['Name', app.name],
121
141
  ['Key', ui.c.green(ui.c.bold(app.key))],
122
142
  ['ID', ui.c.dim(app._id)],
123
- ['Instance', inst ? `${inst.name} ${ui.c.dim(`(${envUrl})`)}` : `${ui.c.green('Free Trial')} ${ui.c.dim(`(${envUrl})`)}`],
143
+ ['Instance', inst ? `${inst.name} ${ui.c.dim(`(${envUrl})`)}` : `${ui.c.green(SECURENOW_CLOUD_NAME)} ${ui.c.dim(`(${envUrl})`)}`],
124
144
  ['Hosts', app.hosts?.length ? app.hosts.join(', ') : ui.c.dim('none')],
125
145
  ]);
126
146
 
@@ -130,6 +150,11 @@ async function create(args, flags) {
130
150
  console.log(` ${ui.c.dim('(writes .securenow/runtime.json - no env var needed)')}`);
131
151
  console.log('');
132
152
 
153
+ if (!flags.json && result.billing?.isPerAppSubscriber) {
154
+ printBillingNotice(result.billing, flags);
155
+ console.log('');
156
+ }
157
+
133
158
  if (flags.json) {
134
159
  ui.json({ ...app, instanceUrl: envUrl });
135
160
  }
@@ -155,9 +180,9 @@ async function pickInstance() {
155
180
  return null;
156
181
  }
157
182
 
158
- const FREE_TRIAL_LABEL = `${ui.c.green('Free Trial')} ${ui.c.dim('— SecureNow managed instance (no setup needed)')}`;
183
+ const CLOUD_LABEL = `${ui.c.green(SECURENOW_CLOUD_NAME)} ${ui.c.dim('— managed collector, no setup needed')}`;
159
184
 
160
- const choices = [{ label: FREE_TRIAL_LABEL, value: null }];
185
+ const choices = [{ label: CLOUD_LABEL, value: null }];
161
186
 
162
187
  for (const inst of instances) {
163
188
  const status = inst.status === 'active' ? ui.c.green('●') : ui.c.red('●');
@@ -233,7 +258,7 @@ async function info(args, flags) {
233
258
  ui.keyValue([
234
259
  ['Key', ui.c.bold(app.key)],
235
260
  ['ID', ui.c.dim(app._id)],
236
- ['Instance', inst ? `${inst.name} ${ui.c.dim(`(${envUrl})`)}` : `${ui.c.green('Free Trial')} ${ui.c.dim(`(${envUrl})`)}`],
261
+ ['Instance', inst ? `${inst.name} ${ui.c.dim(`(${envUrl})`)}` : `${ui.c.green(SECURENOW_CLOUD_NAME)} ${ui.c.dim(`(${envUrl})`)}`],
237
262
  ['Hosts', app.hosts?.length ? app.hosts.join(', ') : ui.c.dim('none')],
238
263
  ['Created', app.createdAt ? new Date(app.createdAt).toLocaleString() : '—'],
239
264
  ['Updated', app.updatedAt ? new Date(app.updatedAt).toLocaleString() : '—'],
@@ -295,7 +320,7 @@ async function setDefault(args) {
295
320
  name: app.name || null,
296
321
  });
297
322
  ui.success(`Default application set to ${ui.c.bold(app.name || key)}`);
298
- ui.info(`SDK will use ${ui.c.dim(FREE_TRIAL_URL)} for telemetry; the gateway routes by app key.`);
323
+ ui.info(`SDK will use ${ui.c.dim(SECURENOW_CLOUD_URL)} for telemetry; the gateway routes by app key.`);
299
324
  } catch {
300
325
  // If we can't reach the API (offline / invalid key), still set the CLI default.
301
326
  ui.success(`Default application set to ${ui.c.bold(key)}`);
@@ -418,6 +443,7 @@ async function discoverAndAdd(domains, instanceId, flags) {
418
443
  ]);
419
444
  ui.table(['Name', 'Key', 'Hosts'], rows);
420
445
  }
446
+ printBillingNotice(result.billing, flags);
421
447
  console.log('');
422
448
  } catch (err) {
423
449
  createSpinner.fail('Failed to create applications');
@@ -535,6 +561,7 @@ async function scan(args, flags) {
535
561
  });
536
562
  totalCreated = result.count || subdomainNames.length;
537
563
  createSpinner.stop(`Created ${totalCreated} application${totalCreated !== 1 ? 's' : ''}`);
564
+ printBillingNotice(result.billing, flags);
538
565
  } catch (err) {
539
566
  createSpinner.fail('Failed to create applications');
540
567
  throw err;
@@ -564,6 +591,7 @@ async function scan(args, flags) {
564
591
  });
565
592
  totalCreated = result.count || selected.length;
566
593
  createSpinner.stop(`Created ${totalCreated} application${totalCreated !== 1 ? 's' : ''}`);
594
+ printBillingNotice(result.billing, flags);
567
595
  } catch (err) {
568
596
  createSpinner.fail('Failed to create applications');
569
597
  throw err;
package/cli/client.js CHANGED
@@ -1,55 +1,55 @@
1
- 'use strict';
2
-
3
- const https = require('https');
4
- const http = require('http');
5
- const { URL } = require('url');
6
- const config = require('./config');
7
- const ui = require('./ui');
8
-
9
- function buildQueryString(params) {
10
- if (!params) return '';
11
- const entries = Object.entries(params).filter(([, v]) => v != null && v !== '');
12
- if (!entries.length) return '';
13
- return '?' + entries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&');
14
- }
15
-
16
- function request(method, endpoint, { body, query, token, raw } = {}) {
17
- const baseUrl = config.getApiUrl();
18
- const qs = buildQueryString(query);
19
- const urlStr = `${baseUrl}/api${endpoint}${qs}`;
20
- const url = new URL(urlStr);
21
- const mod = url.protocol === 'https:' ? https : http;
22
-
23
- const authToken = token || config.getToken();
24
-
25
- return new Promise((resolve, reject) => {
26
- const options = {
27
- hostname: url.hostname,
28
- port: url.port || (url.protocol === 'https:' ? 443 : 80),
29
- path: url.pathname + url.search,
30
- method,
31
- headers: { 'Content-Type': 'application/json', 'User-Agent': 'securenow-cli' },
32
- };
33
-
34
- if (authToken) {
35
- options.headers['Authorization'] = `Bearer ${authToken}`;
36
- }
37
-
38
- const req = mod.request(options, (res) => {
39
- let data = '';
40
- res.on('data', (chunk) => (data += chunk));
41
- res.on('end', () => {
42
- if (raw) {
43
- return resolve({ status: res.statusCode, headers: res.headers, body: data });
44
- }
45
-
46
- let parsed;
47
- try {
48
- parsed = JSON.parse(data);
49
- } catch {
50
- parsed = data;
51
- }
52
-
1
+ 'use strict';
2
+
3
+ const https = require('https');
4
+ const http = require('http');
5
+ const { URL } = require('url');
6
+ const config = require('./config');
7
+ const ui = require('./ui');
8
+
9
+ function buildQueryString(params) {
10
+ if (!params) return '';
11
+ const entries = Object.entries(params).filter(([, v]) => v != null && v !== '');
12
+ if (!entries.length) return '';
13
+ return '?' + entries.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&');
14
+ }
15
+
16
+ function request(method, endpoint, { body, query, token, raw } = {}) {
17
+ const baseUrl = config.getApiUrl();
18
+ const qs = buildQueryString(query);
19
+ const urlStr = `${baseUrl}/api${endpoint}${qs}`;
20
+ const url = new URL(urlStr);
21
+ const mod = url.protocol === 'https:' ? https : http;
22
+
23
+ const authToken = token || config.getToken();
24
+
25
+ return new Promise((resolve, reject) => {
26
+ const options = {
27
+ hostname: url.hostname,
28
+ port: url.port || (url.protocol === 'https:' ? 443 : 80),
29
+ path: url.pathname + url.search,
30
+ method,
31
+ headers: { 'Content-Type': 'application/json', 'User-Agent': 'securenow-cli' },
32
+ };
33
+
34
+ if (authToken) {
35
+ options.headers['Authorization'] = `Bearer ${authToken}`;
36
+ }
37
+
38
+ const req = mod.request(options, (res) => {
39
+ let data = '';
40
+ res.on('data', (chunk) => (data += chunk));
41
+ res.on('end', () => {
42
+ if (raw) {
43
+ return resolve({ status: res.statusCode, headers: res.headers, body: data });
44
+ }
45
+
46
+ let parsed;
47
+ try {
48
+ parsed = JSON.parse(data);
49
+ } catch {
50
+ parsed = data;
51
+ }
52
+
53
53
  if (res.statusCode === 401) {
54
54
  reject(new CLIError('Admin auth is missing or expired. Run `securenow admin login` to re-authenticate. Runtime app credentials are unrelated.', 401));
55
55
  return;
@@ -59,58 +59,58 @@ function request(method, endpoint, { body, query, token, raw } = {}) {
59
59
  reject(new CLIError(`${msg}. Admin auth is connected, but this user/plan may lack permission for this control-plane operation. Runtime app connection is unrelated.`, 403));
60
60
  return;
61
61
  }
62
- if (res.statusCode >= 400) {
63
- const msg = parsed?.error || parsed?.message || `Request failed (HTTP ${res.statusCode})`;
64
- const details = parsed?.details || parsed?.unauthorizedKeys;
65
- const err = new CLIError(details ? `${msg} — ${details}` : msg, res.statusCode);
66
- reject(err);
67
- return;
68
- }
69
-
70
- resolve(parsed);
71
- });
72
- });
73
-
74
- req.on('error', (err) => {
75
- if (err.code === 'ECONNREFUSED') {
76
- reject(new CLIError(`Cannot connect to ${baseUrl}. Is the API server running?`));
77
- } else if (err.code === 'ENOTFOUND') {
78
- reject(new CLIError(`Cannot resolve ${url.hostname}. Check your internet connection.`));
79
- } else {
80
- reject(new CLIError(`Network error: ${err.message}`));
81
- }
82
- });
83
-
84
- if (body) {
85
- req.write(JSON.stringify(body));
86
- }
87
- req.end();
88
- });
89
- }
90
-
91
- class CLIError extends Error {
92
- constructor(message, statusCode) {
93
- super(message);
94
- this.name = 'CLIError';
95
- this.statusCode = statusCode;
96
- }
97
- }
98
-
99
- function requireAuth() {
62
+ if (res.statusCode >= 400) {
63
+ const msg = parsed?.error || parsed?.message || `Request failed (HTTP ${res.statusCode})`;
64
+ const details = parsed?.details || parsed?.unauthorizedKeys;
65
+ const err = new CLIError(details ? `${msg} — ${details}` : msg, res.statusCode);
66
+ reject(err);
67
+ return;
68
+ }
69
+
70
+ resolve(parsed);
71
+ });
72
+ });
73
+
74
+ req.on('error', (err) => {
75
+ if (err.code === 'ECONNREFUSED') {
76
+ reject(new CLIError(`Cannot connect to ${baseUrl}. Is the API server running?`));
77
+ } else if (err.code === 'ENOTFOUND') {
78
+ reject(new CLIError(`Cannot resolve ${url.hostname}. Check your internet connection.`));
79
+ } else {
80
+ reject(new CLIError(`Network error: ${err.message}`));
81
+ }
82
+ });
83
+
84
+ if (body) {
85
+ req.write(JSON.stringify(body));
86
+ }
87
+ req.end();
88
+ });
89
+ }
90
+
91
+ class CLIError extends Error {
92
+ constructor(message, statusCode) {
93
+ super(message);
94
+ this.name = 'CLIError';
95
+ this.statusCode = statusCode;
96
+ }
97
+ }
98
+
99
+ function requireAuth() {
100
100
  const token = config.getToken();
101
101
  if (!token) {
102
102
  ui.error('Admin auth is not connected. Run `securenow admin login` first.');
103
103
  process.exit(1);
104
104
  }
105
- return token;
106
- }
107
-
108
- const api = {
109
- get: (endpoint, opts) => request('GET', endpoint, opts),
110
- post: (endpoint, body, opts) => request('POST', endpoint, { body, ...opts }),
111
- put: (endpoint, body, opts) => request('PUT', endpoint, { body, ...opts }),
112
- patch: (endpoint, body, opts) => request('PATCH', endpoint, { body, ...opts }),
113
- delete: (endpoint, opts) => request('DELETE', endpoint, opts),
114
- };
115
-
116
- module.exports = { api, CLIError, requireAuth, buildQueryString };
105
+ return token;
106
+ }
107
+
108
+ const api = {
109
+ get: (endpoint, opts) => request('GET', endpoint, opts),
110
+ post: (endpoint, body, opts) => request('POST', endpoint, { body, ...opts }),
111
+ put: (endpoint, body, opts) => request('PUT', endpoint, { body, ...opts }),
112
+ patch: (endpoint, body, opts) => request('PATCH', endpoint, { body, ...opts }),
113
+ delete: (endpoint, opts) => request('DELETE', endpoint, opts),
114
+ };
115
+
116
+ module.exports = { api, CLIError, requireAuth, buildQueryString };
@@ -497,7 +497,7 @@ async function doctor(_args, flags) {
497
497
  } else if (cfg.instance === 'https://api.securenow.ai/api/otlp') {
498
498
  warnings.push('Using the legacy SecureNow telemetry gateway path. Regenerate credentials so telemetry uses https://ingest.securenow.ai.');
499
499
  } else if (cfg.instance === 'https://freetrial.securenow.ai:4318') {
500
- warnings.push('Using the legacy free-trial collector directly. Regenerate credentials so telemetry flows through https://ingest.securenow.ai.');
500
+ warnings.push('Using the legacy SecureNow Cloud collector address directly. Regenerate credentials so telemetry flows through https://ingest.securenow.ai.');
501
501
  }
502
502
  if (!cfg.apiKey && token) {
503
503
  warnings.push('Admin CLI/MCP auth is connected, but the runtime API key is missing. Run `npx securenow app connect` or `npx securenow api-key create` to refresh .securenow/runtime.json.');
@@ -555,6 +555,10 @@ async function eventSend(args, flags) {
555
555
  if (Object.keys(attributes).length) ev.attributes = attributes;
556
556
 
557
557
  const headers = { 'Content-Type': 'application/json', ...cfg.headers };
558
+ // `--env` must win over the environment stored with the runtime credentials.
559
+ if ((flags.env || flags.environment) && cfg.deploymentEnvironment) {
560
+ headers['x-securenow-environment'] = cfg.deploymentEnvironment;
561
+ }
558
562
  const spin = ui.spinner(`Sending event to ${eventsEndpoint}`);
559
563
  try {
560
564
  const res = await httpRequest({ endpoint: eventsEndpoint, headers, body: JSON.stringify({ events: [ev] }) });