securenow 8.23.0 → 8.25.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/app-config.js CHANGED
@@ -8,14 +8,16 @@
8
8
  * Named runtime files such as ./.securenow/credentials.staging.json and
9
9
  * ./.securenow/credentials.production.json are also accepted when the
10
10
  * canonical file is not present. Filename selection is deterministic and does
11
- * not read environment variables.
11
+ * not depend on NODE_ENV.
12
12
  * Every SDK setting has a file-backed equivalent so customers do not need
13
- * .env files.
13
+ * .env files. Hosts that cannot mount a file (Vercel, Railway, Render, Heroku,
14
+ * Fly) pass the same JSON in one variable, SECURENOW_CREDENTIALS.
14
15
  */
15
16
 
16
17
  const fs = require('fs');
17
18
  const path = require('path');
18
19
  const os = require('os');
20
+ const { readPm2Env } = require('./pm2-env');
19
21
 
20
22
  // Shared SecureNow Cloud ingestion gateway (production collector for every
21
23
  // paying account and for the 14-day trial).
@@ -42,6 +44,7 @@ const ADMIN_CREDENTIALS_FILENAME = 'admin.json';
42
44
  const DEFAULT_CONFIG = Object.freeze({
43
45
  logging: {
44
46
  enabled: true,
47
+ redact: true,
45
48
  },
46
49
  capture: {
47
50
  body: true,
@@ -94,6 +97,11 @@ const DEFAULT_CONFIG = Object.freeze({
94
97
  networking: {
95
98
  trustedProxies: [],
96
99
  },
100
+ sessions: {
101
+ guard: false,
102
+ capture: false,
103
+ cookieNames: [],
104
+ },
97
105
  });
98
106
 
99
107
  const CONFIG_EXPLANATIONS = Object.freeze({
@@ -103,6 +111,7 @@ const CONFIG_EXPLANATIONS = Object.freeze({
103
111
  'app.name': 'Human-readable app label shown in CLI output.',
104
112
  'app.routing': 'Telemetry uses the default SecureNow ingestion gateway and routes by app.key; runtime credentials do not expose per-instance collector URLs.',
105
113
  'config.logging.enabled': 'Secure default: console log forwarding is on. Set false to disable OTLP logs.',
114
+ 'config.logging.redact': 'Secure default: exported log lines are redacted. Credential fields (password, token, apiKey, authorization, cookie, OTP codes, card numbers...) in logged objects, JSON and key=value text, and secret shapes anywhere (JWTs, Bearer/Basic credentials, sk_/pk_/rk_/snk_ keys, card numbers, emails) become [REDACTED]; the terminal still shows the line as written. Set false to export lines as written.',
106
115
  'config.capture.body': 'Secure default: JSON, GraphQL, and form request bodies are captured with redaction.',
107
116
  'config.capture.multipart': 'Secure default: multipart text fields and file metadata are captured. File content is never buffered.',
108
117
  'config.capture.maxBodySize': 'Maximum body bytes captured per request. Default 10KB limits memory and sensitive data exposure.',
@@ -133,7 +142,10 @@ const CONFIG_EXPLANATIONS = Object.freeze({
133
142
  'config.firewall.cloudflare': 'Cloudflare API token/account id for Layer 4 WAF. Secrets: do not commit.',
134
143
  'config.firewall.aws': 'AWS WAF IP set configuration for Layer 4 WAF.',
135
144
  'config.firewall.gcp': 'GCP Cloud Armor policy configuration for Layer 4 WAF.',
136
- 'config.networking.trustedProxies': 'Additional proxies whose X-Forwarded-For headers should be trusted: IPs, IPv4 ranges (CIDR) or "cloudflare".',
145
+ 'config.networking.trustedProxies': 'Additional proxies whose X-Forwarded-For headers should be trusted: IPs, IPv4 ranges (CIDR) or "cloudflare". Private, loopback and Unix-socket connections are trusted by default.',
146
+ 'config.sessions.guard': 'If true, every request whose session or user was revoked gets a 401, in any framework, with no code (session = SHA-256 of the Bearer token or session cookie; user = JWT sub).',
147
+ 'config.sessions.capture': 'If true, requests with a session send session.seen (deduplicated per session and IP every 5 minutes) so SecureNow can detect a stolen session.',
148
+ 'config.sessions.cookieNames': 'Session cookie names to read besides next-auth, connect.sid, session and sid.',
137
149
  });
138
150
 
139
151
  function clone(value) {
@@ -240,7 +252,20 @@ function credentialRelativePaths() {
240
252
  return runtimeCredentialRelativePaths();
241
253
  }
242
254
 
243
- function resolveLocalFileFromRelativePaths(relativePaths) {
255
+ // Where to look for .securenow/ files and package.json. A PM2 process is searched from the app
256
+ // folder PM2 recorded and nowhere else: in cluster mode the worker's working directory is still
257
+ // the PM2 daemon's when the preload runs, so searching from it found nothing, or another
258
+ // project's credentials if the daemon was started inside that project.
259
+ function searchStartDirs() {
260
+ const pm2 = readPm2Env();
261
+ if (pm2 && (pm2.cwd || pm2.execPath)) {
262
+ const appDir = pm2.cwd ? path.resolve(pm2.cwd) : null;
263
+ return uniq([
264
+ appDir,
265
+ pm2.execPath ? path.dirname(path.resolve(appDir || '.', pm2.execPath)) : null,
266
+ ]);
267
+ }
268
+
244
269
  const starts = [];
245
270
  try {
246
271
  if (typeof process !== 'undefined' && process.cwd) starts.push(process.cwd());
@@ -250,8 +275,12 @@ function resolveLocalFileFromRelativePaths(relativePaths) {
250
275
  if (process.argv && process.argv[1]) starts.push(path.dirname(process.argv[1]));
251
276
  if (require.main && require.main.filename) starts.push(path.dirname(require.main.filename));
252
277
 
278
+ return uniq(starts);
279
+ }
280
+
281
+ function resolveLocalFileFromRelativePaths(relativePaths) {
253
282
  const candidates = relativePaths;
254
- for (const start of uniq(starts)) {
283
+ for (const start of searchStartDirs()) {
255
284
  const found = findUpFirstFile(start, candidates);
256
285
  if (found) return found;
257
286
  }
@@ -314,16 +343,7 @@ function runtimeCredentialsFromDocument(credentials) {
314
343
  }
315
344
 
316
345
  function resolvePackageJsonFile() {
317
- const starts = [];
318
- try {
319
- if (typeof process !== 'undefined' && process.cwd) starts.push(process.cwd());
320
- } catch {}
321
-
322
- if (process.env.INIT_CWD) starts.push(process.env.INIT_CWD);
323
- if (process.argv && process.argv[1]) starts.push(path.dirname(process.argv[1]));
324
- if (require.main && require.main.filename) starts.push(path.dirname(require.main.filename));
325
-
326
- for (const start of uniq(starts)) {
346
+ for (const start of searchStartDirs()) {
327
347
  const found = findUpFile(start, 'package.json');
328
348
  if (found) return found;
329
349
  }
@@ -347,7 +367,50 @@ function loadGlobalCredentials() {
347
367
  }
348
368
  }
349
369
 
370
+ const ENV_CREDENTIALS_VAR = 'SECURENOW_CREDENTIALS';
371
+ let _envCredentials = { raw: undefined, value: null };
372
+ let _warnedBadEnvCredentials = false;
373
+
374
+ // Runtime credentials passed in the environment, for hosts that cannot mount a file. The value is
375
+ // the JSON written by `npx securenow credentials runtime`, raw or base64-encoded (`--format env`
376
+ // prints it ready to paste). When set it takes precedence over credential files.
377
+ function loadEnvCredentials() {
378
+ const raw = typeof process !== 'undefined' && process.env ? process.env[ENV_CREDENTIALS_VAR] : undefined;
379
+ if (raw !== _envCredentials.raw) {
380
+ let value = null;
381
+ const text = raw == null ? '' : String(raw).trim();
382
+ if (text) {
383
+ try {
384
+ const json = text.startsWith('{') ? text : Buffer.from(text, 'base64').toString('utf8');
385
+ value = withCredentialDefaults(runtimeCredentialsFromDocument(JSON.parse(json.replace(/^\uFEFF/, ''))));
386
+ } catch {
387
+ value = null;
388
+ }
389
+ if (!value && !_warnedBadEnvCredentials) {
390
+ _warnedBadEnvCredentials = true;
391
+ try {
392
+ console.warn('[securenow] %s is set but is not runtime credentials JSON (raw or base64); ignoring it.', ENV_CREDENTIALS_VAR);
393
+ } catch {}
394
+ }
395
+ }
396
+ _envCredentials = { raw, value };
397
+ }
398
+ return _envCredentials.value ? clone(_envCredentials.value) : null;
399
+ }
400
+
401
+ // Where the runtime credentials came from, for the startup log: never their content.
402
+ function describeCredentialsSource() {
403
+ try {
404
+ if (loadEnvCredentials()) return `env:${ENV_CREDENTIALS_VAR}`;
405
+ return resolveLocalCredentialsFile() || resolveGlobalCredentialsFile() || 'none';
406
+ } catch {
407
+ return 'none';
408
+ }
409
+ }
410
+
350
411
  function loadCredentials() {
412
+ const fromEnv = loadEnvCredentials();
413
+ if (fromEnv) return fromEnv;
351
414
  try {
352
415
  const localFile = resolveLocalCredentialsFile();
353
416
  if (localFile) {
@@ -692,6 +755,13 @@ function resolveEndpoints(options = {}) {
692
755
  };
693
756
  }
694
757
 
758
+ function resolveSessionsOptions() {
759
+ return {
760
+ guard: boolConfig('sessions.guard', false),
761
+ capture: boolConfig('sessions.capture', false),
762
+ };
763
+ }
764
+
695
765
  function resolveFirewallEnabled() {
696
766
  return true;
697
767
  }
@@ -764,6 +834,7 @@ module.exports = {
764
834
  resolveOtlpHeaderString,
765
835
  resolveEndpoints,
766
836
  resolveFirewallEnabled,
837
+ resolveSessionsOptions,
767
838
  normalizeFirewallApiUrl,
768
839
  resolveFirewallApiUrl,
769
840
  resolveFirewallApiFallbacks,
@@ -781,6 +852,10 @@ module.exports = {
781
852
  resolveLocalAdminCredentialsFile,
782
853
  resolveGlobalAdminCredentialsFile,
783
854
  loadCredentials,
855
+ loadEnvCredentials,
856
+ describeCredentialsSource,
857
+ searchStartDirs,
858
+ ENV_CREDENTIALS_VAR,
784
859
  loadLocalCredentials,
785
860
  loadGlobalCredentials,
786
861
  loadPackageJsonName,
package/cidr.js CHANGED
@@ -2,7 +2,8 @@
2
2
 
3
3
  /**
4
4
  * Bitmask-based CIDR matching. No regex on user input — prevents ReDoS.
5
- * All operations use unsigned 32-bit integers for IPv4 addresses.
5
+ * IPv4 addresses are unsigned 32-bit integers; IPv6 addresses are 128-bit BigInts.
6
+ * An IPv4-mapped IPv6 address (::ffff:1.2.3.4) is treated as the IPv4 address it carries.
6
7
  */
7
8
 
8
9
  function ipToInt(ip) {
@@ -10,13 +11,99 @@ function ipToInt(ip) {
10
11
  if (parts.length !== 4) return null;
11
12
  let result = 0;
12
13
  for (let i = 0; i < 4; i++) {
14
+ if (!/^\d{1,3}$/.test(parts[i])) return null;
13
15
  const n = parseInt(parts[i], 10);
14
- if (isNaN(n) || n < 0 || n > 255) return null;
16
+ if (n > 255) return null;
15
17
  result = (result << 8) + n;
16
18
  }
17
19
  return result >>> 0;
18
20
  }
19
21
 
22
+ function intToIp(n) {
23
+ return [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff].join('.');
24
+ }
25
+
26
+ const V6_BITS = 128n;
27
+ const V6_ALL = (1n << V6_BITS) - 1n;
28
+
29
+ // '2001:db8::1' -> BigInt. Accepts '::' compression, an IPv4 tail ('::ffff:1.2.3.4'),
30
+ // brackets and a zone id ('fe80::1%eth0'). Returns null for anything else.
31
+ function ipv6ToBigInt(ip) {
32
+ let s = String(ip || '').trim();
33
+ if (s.startsWith('[') && s.endsWith(']')) s = s.slice(1, -1);
34
+ const zone = s.indexOf('%');
35
+ if (zone !== -1) s = s.slice(0, zone);
36
+ if (!s.includes(':') || s.length > 45) return null;
37
+
38
+ // An IPv4 tail ('::ffff:1.2.3.4') is the last two groups.
39
+ let tail = [];
40
+ const lastColon = s.lastIndexOf(':');
41
+ if (s.indexOf('.', lastColon) !== -1) {
42
+ const v4 = ipToInt(s.slice(lastColon + 1));
43
+ if (v4 === null) return null;
44
+ tail = [(v4 >>> 16) & 0xffff, v4 & 0xffff];
45
+ s = s.slice(0, lastColon + 1);
46
+ if (!s.endsWith('::')) s = s.slice(0, -1);
47
+ }
48
+
49
+ const halves = s.split('::');
50
+ if (halves.length > 2) return null;
51
+ const groupsOf = (part) => {
52
+ if (part === '') return [];
53
+ const out = [];
54
+ for (const g of part.split(':')) {
55
+ if (!/^[0-9a-f]{1,4}$/i.test(g)) return null;
56
+ out.push(parseInt(g, 16));
57
+ }
58
+ return out;
59
+ };
60
+ const head = groupsOf(halves[0]);
61
+ const rest = halves.length === 2 ? groupsOf(halves[1]) : [];
62
+ if (!head || !rest) return null;
63
+ const total = head.length + rest.length + tail.length;
64
+ // '::' stands for at least one zero group.
65
+ if (halves.length === 2 ? total > 7 : total !== 8) return null;
66
+
67
+ const all = head.concat(new Array(8 - total).fill(0), rest, tail);
68
+ let n = 0n;
69
+ for (const g of all) n = (n << 16n) | BigInt(g);
70
+ return n;
71
+ }
72
+
73
+ function bigIntToIpv6(n) {
74
+ const groups = [];
75
+ for (let i = 7; i >= 0; i--) groups.push(Number((n >> BigInt(i * 16)) & 0xffffn).toString(16));
76
+ // Compress the longest run of zero groups (RFC 5952).
77
+ let best = -1; let bestLen = 0;
78
+ for (let i = 0; i < 8;) {
79
+ if (groups[i] !== '0') { i++; continue; }
80
+ let j = i;
81
+ while (j < 8 && groups[j] === '0') j++;
82
+ if (j - i > bestLen && j - i > 1) { best = i; bestLen = j - i; }
83
+ i = j;
84
+ }
85
+ if (best === -1) return groups.join(':');
86
+ return `${groups.slice(0, best).join(':')}::${groups.slice(best + bestLen).join(':')}`;
87
+ }
88
+
89
+ /**
90
+ * Classifies an address: { family: 4, int } or { family: 6, big }, with a canonical `key`
91
+ * for exact lookups. An IPv4-mapped IPv6 address comes back as family 4. Null if not an IP.
92
+ */
93
+ function parseIp(ip) {
94
+ const s = String(ip || '').trim();
95
+ if (!s) return null;
96
+ const v4 = ipToInt(s);
97
+ if (v4 !== null) return { family: 4, int: v4, key: intToIp(v4) };
98
+ const big = ipv6ToBigInt(s);
99
+ if (big === null) return null;
100
+ if ((big >> 32n) === 0xffffn) {
101
+ const int = Number(big & 0xffffffffn) >>> 0;
102
+ return { family: 4, int, key: intToIp(int) };
103
+ }
104
+ return { family: 6, big, key: bigIntToIpv6(big) };
105
+ }
106
+
20
107
  function parseCidr(cidr) {
21
108
  const slash = cidr.indexOf('/');
22
109
  if (slash === -1) return null;
@@ -29,44 +116,72 @@ function parseCidr(cidr) {
29
116
  return { network: (network & mask) >>> 0, mask };
30
117
  }
31
118
 
119
+ /** '2001:db8::/32' -> { network, mask, bits } as BigInts. Null if not an IPv6 CIDR. */
120
+ function parseCidr6(cidr) {
121
+ const slash = String(cidr || '').indexOf('/');
122
+ if (slash === -1) return null;
123
+ const suffix = cidr.slice(slash + 1).trim();
124
+ if (!/^\d{1,3}$/.test(suffix)) return null;
125
+ const bits = parseInt(suffix, 10);
126
+ if (bits > 128) return null;
127
+ const big = ipv6ToBigInt(cidr.slice(0, slash));
128
+ if (big === null) return null;
129
+ const mask = bits === 0 ? 0n : (V6_ALL << (V6_BITS - BigInt(bits))) & V6_ALL;
130
+ return { network: big & mask, mask, bits };
131
+ }
132
+
32
133
  function matchesCidr(ipInt, cidrEntry) {
33
134
  return ((ipInt & cidrEntry.mask) >>> 0) === cidrEntry.network;
34
135
  }
35
136
 
36
137
  /**
37
- * Create a matcher from a list of IPs and CIDRs.
138
+ * Create a matcher from a list of IPv4/IPv6 addresses and CIDRs.
38
139
  * Returns { isBlocked(ip), stats() }.
39
- * Exact IPs use a Set for O(1) lookup; CIDRs use array scan (typically < 100 entries).
140
+ * Exact IPs use a Set of canonical forms for O(1) lookup ('2001:DB8:0::1' and '2001:db8::1'
141
+ * are the same entry); CIDRs use an array scan (typically < 100 entries).
40
142
  */
41
143
  function createMatcher(ipList) {
42
144
  const exactSet = new Set();
43
- const cidrRanges = [];
145
+ const cidr4 = [];
146
+ const cidr6 = [];
44
147
 
45
- for (const entry of ipList) {
148
+ for (const entry of ipList || []) {
46
149
  const trimmed = (entry || '').trim();
47
150
  if (!trimmed) continue;
48
151
 
49
152
  if (trimmed.includes('/')) {
50
- const parsed = parseCidr(trimmed);
51
- if (parsed) cidrRanges.push(parsed);
153
+ const v4 = parseCidr(trimmed);
154
+ if (v4) { cidr4.push(v4); continue; }
155
+ const v6 = parseCidr6(trimmed);
156
+ if (!v6) continue;
157
+ // ::ffff:0:0/96 and narrower are IPv4 ranges written in IPv6 form.
158
+ if (v6.bits >= 96 && (v6.network >> 32n) === 0xffffn) {
159
+ const bits = v6.bits - 96;
160
+ const mask = bits === 0 ? 0 : (~((1 << (32 - bits)) - 1)) >>> 0;
161
+ cidr4.push({ network: (Number(v6.network & 0xffffffffn) & mask) >>> 0, mask });
162
+ } else {
163
+ cidr6.push(v6);
164
+ }
52
165
  } else {
53
- const normalized = trimmed.replace(/^::ffff:/, '');
54
- exactSet.add(normalized);
166
+ const parsed = parseIp(trimmed);
167
+ exactSet.add(parsed ? parsed.key : trimmed);
55
168
  }
56
169
  }
57
170
 
58
171
  function isBlocked(ip) {
59
172
  if (!ip) return false;
60
- const normalized = ip.replace(/^::ffff:/, '');
173
+ const parsed = parseIp(ip);
174
+ if (!parsed) return exactSet.has(String(ip).trim());
61
175
 
62
- if (exactSet.has(normalized)) return true;
176
+ if (exactSet.has(parsed.key)) return true;
63
177
 
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
- }
178
+ if (parsed.family === 4) {
179
+ for (const cidr of cidr4) {
180
+ if (matchesCidr(parsed.int, cidr)) return true;
181
+ }
182
+ } else {
183
+ for (const cidr of cidr6) {
184
+ if ((parsed.big & cidr.mask) === cidr.network) return true;
70
185
  }
71
186
  }
72
187
 
@@ -74,10 +189,14 @@ function createMatcher(ipList) {
74
189
  }
75
190
 
76
191
  function stats() {
77
- return { exact: exactSet.size, cidr: cidrRanges.length, total: exactSet.size + cidrRanges.length };
192
+ const cidr = cidr4.length + cidr6.length;
193
+ return { exact: exactSet.size, cidr, total: exactSet.size + cidr };
78
194
  }
79
195
 
80
196
  return { isBlocked, stats };
81
197
  }
82
198
 
83
- module.exports = { ipToInt, parseCidr, matchesCidr, createMatcher };
199
+ module.exports = {
200
+ ipToInt, intToIp, parseCidr, matchesCidr, createMatcher,
201
+ ipv6ToBigInt, bigIntToIpv6, parseCidr6, parseIp,
202
+ };
@@ -73,6 +73,17 @@ async function runtime(_args, flags) {
73
73
  warn('Runtime API key is missing. Run `npx securenow app connect` or `npx securenow api-key create` before generating production runtime credentials.');
74
74
  }
75
75
 
76
+ // For hosts that inject environment variables but cannot mount a file (Vercel, Railway,
77
+ // Render, Heroku, Fly): one line to paste as a secret. The SDK reads SECURENOW_CREDENTIALS
78
+ // (raw JSON or base64) before any credentials file.
79
+ if (flags.format === 'env' || flags['env-var']) {
80
+ const { ENV_CREDENTIALS_VAR } = appConfig;
81
+ const encoded = Buffer.from(JSON.stringify(creds), 'utf8').toString('base64');
82
+ console.log(`${ENV_CREDENTIALS_VAR}=${encoded}`);
83
+ console.error(`! This line contains the runtime API key. Store it as a secret variable named ${ENV_CREDENTIALS_VAR}; do not commit it.`);
84
+ return;
85
+ }
86
+
76
87
  if (flags.stdout) {
77
88
  console.log(JSON.stringify(creds, null, 2));
78
89
  return;
package/cli/init.js CHANGED
@@ -5,7 +5,26 @@ const path = require('path');
5
5
  const ui = require('./ui');
6
6
  const config = require('./config');
7
7
 
8
+ // The import sits inside the nodejs block, without webpackIgnore: Next.js drops the block from the
9
+ // Edge build and traces securenow and its dependencies into standalone output (webpackIgnore hid
10
+ // them from tracing, so a standalone image shipped without OpenTelemetry and every request failed).
11
+ // The try/catch keeps the app serving if SecureNow cannot start.
8
12
  const INSTRUMENTATION = `export async function register() {
13
+ if (process.env.NEXT_RUNTIME === 'nodejs') {
14
+ try {
15
+ const securenowNext = await import('securenow/nextjs');
16
+ const registerSecureNow = securenowNext.registerSecureNow || securenowNext.default?.registerSecureNow;
17
+ registerSecureNow({ captureBody: true });
18
+ await import('securenow/nextjs-auto-capture');
19
+ } catch (error) {
20
+ console.error('[securenow] SecureNow did not start; the app is running without it:', error);
21
+ }
22
+ }
23
+ }
24
+ `;
25
+
26
+ // What init wrote before 8.24.0: replaced automatically when a file still holds exactly this.
27
+ const LEGACY_INSTRUMENTATION = `export async function register() {
9
28
  if (process.env.NEXT_RUNTIME !== 'nodejs') return;
10
29
 
11
30
  const securenowNext = await import(/* webpackIgnore: true */ 'securenow/nextjs');
@@ -15,6 +34,10 @@ const INSTRUMENTATION = `export async function register() {
15
34
  }
16
35
  `;
17
36
 
37
+ function isLegacyInstrumentation(content) {
38
+ return String(content || '').replace(/\r\n/g, '\n').trim() === LEGACY_INSTRUMENTATION.trim();
39
+ }
40
+
18
41
  function detectProject(dir) {
19
42
  const pkgPath = path.join(dir, 'package.json');
20
43
  if (!fs.existsSync(pkgPath)) return { framework: 'unknown' };
@@ -39,6 +62,22 @@ function findInstrumentationFile(dir) {
39
62
  return null;
40
63
  }
41
64
 
65
+ // Next.js loads instrumentation.* from the folder that holds app/ or pages/: src/ in a src layout,
66
+ // where a file at the project root is never loaded.
67
+ function usesSrcDir(dir) {
68
+ const inSrc = ['src/app', 'src/pages'].some((d) => fs.existsSync(path.join(dir, d)));
69
+ const inRoot = ['app', 'pages'].some((d) => fs.existsSync(path.join(dir, d)));
70
+ return inSrc && !inRoot;
71
+ }
72
+
73
+ function instrumentationIn(folder) {
74
+ for (const name of ['instrumentation.ts', 'instrumentation.js']) {
75
+ const p = path.join(folder, name);
76
+ if (fs.existsSync(p)) return p;
77
+ }
78
+ return null;
79
+ }
80
+
42
81
  function findNextConfig(dir) {
43
82
  for (const name of ['next.config.js', 'next.config.mjs', 'next.config.ts']) {
44
83
  const p = path.join(dir, name);
@@ -78,6 +117,7 @@ async function init(_args, flags) {
78
117
  } else {
79
118
  initNode(project);
80
119
  }
120
+ checkPm2Ecosystem(dir);
81
121
 
82
122
  console.log('');
83
123
  ui.success('Setup complete.');
@@ -108,17 +148,49 @@ function initCredentials(flags) {
108
148
  }
109
149
 
110
150
  async function initNextJs(dir, project, flags) {
111
- const useTs = flags.javascript ? false : flags.typescript ? true : hasTypeScript(dir);
151
+ const useTs = (flags.javascript || flags.js) ? false : (flags.typescript || flags.ts) ? true : hasTypeScript(dir);
112
152
  const ext = useTs ? 'ts' : 'js';
113
-
114
- const existing = findInstrumentationFile(dir);
115
- if (existing) {
116
- ui.info(`instrumentation file already exists: ${path.relative(dir, existing)}`);
117
- printAgentPrompt('instrumentation', path.basename(existing), nextMajor(project), project);
153
+ const inSrc = flags.root ? false : flags.src ? true : usesSrcDir(dir);
154
+ const folder = inSrc ? path.join(dir, 'src') : dir;
155
+ const otherFolder = inSrc ? dir : path.join(dir, 'src');
156
+ const rel = (p) => path.relative(dir, p).split(path.sep).join('/');
157
+
158
+ // In a src/ layout, Next.js 14 and 15 load only src/instrumentation.*; 16 also loads a root file.
159
+ const major = nextMajor(project);
160
+ const existing = instrumentationIn(folder) || (inSrc && major >= 16 ? instrumentationIn(otherFolder) : null);
161
+ const misplaced = existing ? null : instrumentationIn(otherFolder);
162
+ if (existing && isLegacyInstrumentation(fs.readFileSync(existing, 'utf8'))) {
163
+ fs.writeFileSync(existing, INSTRUMENTATION, 'utf8');
164
+ ui.success(`Updated ${rel(existing)}: standalone builds now include SecureNow's dependencies`);
165
+ } else if (existing && flags.force) {
166
+ fs.copyFileSync(existing, `${existing}.bak`);
167
+ fs.writeFileSync(existing, INSTRUMENTATION, 'utf8');
168
+ ui.success(`Replaced ${rel(existing)} (--force); the previous file is in ${rel(existing)}.bak`);
169
+ } else if (existing) {
170
+ ui.info(`instrumentation file already exists: ${rel(existing)}`);
171
+ printAgentPrompt('instrumentation', rel(existing), nextMajor(project), project);
118
172
  } else {
119
- const filePath = path.join(dir, `instrumentation.${ext}`);
120
- fs.writeFileSync(filePath, INSTRUMENTATION, 'utf8');
121
- ui.success(`Created instrumentation.${ext}`);
173
+ const filePath = path.join(folder, `instrumentation.${ext}`);
174
+ if (misplaced) {
175
+ // Next.js never loads a file in the other folder, so SecureNow would not start.
176
+ const ours = isLegacyInstrumentation(fs.readFileSync(misplaced, 'utf8'))
177
+ || fs.readFileSync(misplaced, 'utf8') === INSTRUMENTATION;
178
+ if (ours || flags.force) {
179
+ if (!ours) fs.copyFileSync(misplaced, `${misplaced}.bak`);
180
+ fs.mkdirSync(folder, { recursive: true });
181
+ fs.writeFileSync(filePath, INSTRUMENTATION, 'utf8');
182
+ fs.unlinkSync(misplaced);
183
+ ui.success(`Moved ${rel(misplaced)} to ${rel(filePath)}: Next.js loads instrumentation from ${inSrc ? 'src/' : 'the project root'} in this project${ours ? '' : ` (the previous file is in ${rel(misplaced)}.bak)`}`);
184
+ } else {
185
+ ui.warn(`${rel(misplaced)} is in the wrong folder: this project keeps app/ or pages/ ${inSrc ? 'in src/' : 'at the root'}, and Next.js loads instrumentation from there (${rel(filePath)}), so SecureNow would not start.`);
186
+ ui.info('Move it there (or rerun with --force to write SecureNow\'s file there and keep yours as .bak).');
187
+ printAgentPrompt('instrumentation', rel(filePath), major, project);
188
+ }
189
+ } else {
190
+ fs.mkdirSync(folder, { recursive: true });
191
+ fs.writeFileSync(filePath, INSTRUMENTATION, 'utf8');
192
+ ui.success(`Created ${rel(filePath)}`);
193
+ }
122
194
  }
123
195
 
124
196
  const configPath = findNextConfig(dir);
@@ -134,7 +206,12 @@ async function initNextJs(dir, project, flags) {
134
206
  }
135
207
  } else {
136
208
  const newConfigPath = path.join(dir, 'next.config.mjs');
137
- fs.writeFileSync(newConfigPath, `/** @type {import('next').NextConfig} */
209
+ fs.writeFileSync(newConfigPath, nextMajor(project) < 15 ? NEXT14_CONFIG : NEXT_CONFIG, 'utf8');
210
+ ui.success('Created next.config.mjs');
211
+ }
212
+ }
213
+
214
+ const NEXT_CONFIG = `/** @type {import('next').NextConfig} */
138
215
  const nextConfig = {
139
216
  serverExternalPackages: ['securenow'],
140
217
  outputFileTracingIncludes: {
@@ -143,10 +220,22 @@ const nextConfig = {
143
220
  };
144
221
 
145
222
  export default nextConfig;
146
- `, 'utf8');
147
- ui.success('Created next.config.mjs');
148
- }
149
- }
223
+ `;
224
+
225
+ // Next.js 14 keeps these under experimental and needs the instrumentation hook switched on.
226
+ const NEXT14_CONFIG = `/** @type {import('next').NextConfig} */
227
+ const nextConfig = {
228
+ experimental: {
229
+ instrumentationHook: true,
230
+ serverComponentsExternalPackages: ['securenow'],
231
+ outputFileTracingIncludes: {
232
+ '/*': ['./node_modules/securenow/**/*'],
233
+ },
234
+ },
235
+ };
236
+
237
+ export default nextConfig;
238
+ `;
150
239
 
151
240
  function patchNextConfig(configPath, major) {
152
241
  const content = fs.readFileSync(configPath, 'utf8');
@@ -239,13 +328,13 @@ function printAgentPrompt(kind, filename, major, project) {
239
328
  ? `Use the project scripts for verification when appropriate: ${commands}. Ask before starting long-running dev/start servers, and ask which command to use if these scripts are not the right customer workflow.`
240
329
  : 'Ask the customer which command starts, builds, and tests this app because package.json does not expose an obvious script.',
241
330
  kind === 'instrumentation'
242
- ? `Merge this into ${filename}: in register(), return unless process.env.NEXT_RUNTIME === "nodejs"; then dynamically import "securenow/nextjs" and "securenow/nextjs-auto-capture" with /* webpackIgnore: true */ so Next does not bundle OpenTelemetry internals. Preserve all existing instrumentation.`
331
+ ? `Merge this into ${filename}: inside register(), in an if (process.env.NEXT_RUNTIME === "nodejs") { ... } block, wrapped in try/catch so the app keeps running if SecureNow cannot start, dynamically import "securenow/nextjs" (call its registerSecureNow({ captureBody: true })) and then "securenow/nextjs-auto-capture". Do not add webpackIgnore comments and do not use an early return: keeping the imports inside the nodejs block lets Next.js drop them from the Edge build and trace securenow's dependencies into standalone output. Preserve all existing instrumentation.`
243
332
  : null,
244
333
  kind === 'next-config' && major >= 15
245
334
  ? `Update ${filename} while preserving existing config: add securenow to serverExternalPackages, e.g. serverExternalPackages: [...(existing || []), "securenow"], and include SecureNow runtime files for standalone output with outputFileTracingIncludes: { "/*": ["./node_modules/securenow/**/*"] }.`
246
335
  : null,
247
336
  kind === 'next-config' && major < 15
248
- ? `Update ${filename} while preserving existing config: enable experimental.instrumentationHook and add securenow to experimental.serverComponentsExternalPackages.`
337
+ ? `Update ${filename} while preserving existing config (Next.js 14): set experimental.instrumentationHook to true, add securenow to experimental.serverComponentsExternalPackages, and add experimental.outputFileTracingIncludes: { "/*": ["./node_modules/securenow/**/*"] } for standalone output.`
249
338
  : null,
250
339
  'Verify with: npx securenow env, npx securenow test-span, npx securenow status, plus the selected build/test command when available.',
251
340
  'Only smoke-test an HTTP URL when the user provides a local or production URL, the running app prints one, or project config clearly defines one. Do not invent localhost ports or production domains; ask for the URL and skip URL probes if none is available.',
@@ -267,6 +356,19 @@ function initNuxt(dir) {
267
356
  }
268
357
  }
269
358
 
359
+ // In PM2 cluster mode Node resolves a bare `-r securenow/register` from the PM2 daemon's folder,
360
+ // so workers started from elsewhere (pm2 resurrect at boot) crash with "Cannot find module".
361
+ function checkPm2Ecosystem(dir) {
362
+ for (const name of ['ecosystem.config.cjs', 'ecosystem.config.js', 'ecosystem.config.mjs']) {
363
+ const file = path.join(dir, name);
364
+ if (!fs.existsSync(file)) continue;
365
+ const content = fs.readFileSync(file, 'utf8');
366
+ if (content.includes('securenow/register') && !/require\.resolve\(\s*['"]securenow\/register['"]\s*\)/.test(content)) {
367
+ ui.warn(`${name}: pass the preload as an absolute path, node_args: ['-r', require.resolve('securenow/register')]. A bare '-r securenow/register' is resolved from the PM2 daemon's folder in cluster mode, and the workers crash when the daemon starts elsewhere (pm2 resurrect at boot).`);
368
+ }
369
+ }
370
+ }
371
+
270
372
  function initNode(project) {
271
373
  const scripts = project.pkg.scripts || {};
272
374
  const startScript = scripts.start || '';
@@ -293,4 +395,6 @@ function initNode(project) {
293
395
  }
294
396
  }
295
397
 
296
- module.exports = { init };
398
+ module.exports = {
399
+ INSTRUMENTATION,
400
+ isLegacyInstrumentation, init };