securenow 8.8.0 → 8.10.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/NPM_README.md CHANGED
@@ -306,6 +306,36 @@ X-SecureNow-App-Key: <app-uuid>
306
306
 
307
307
  CLI: `npx securenow event send auth.login.failure --user u_1 --ip 1.2.3.4 --attrs reason=bad_token`
308
308
 
309
+ ### Build alert SQL from the installed telemetry contract
310
+
311
+ The NPM package ships the exact ClickHouse table/field contract used for alert-rule authoring.
312
+ Agents should read it instead of guessing SQL columns:
313
+
314
+ ```bash
315
+ # Offline: installed SDK schema, tenant scope, timestamp types, event mappings, and templates
316
+ npx securenow telemetry schema --json
317
+
318
+ # Connected app: names/types/counts observed in real telemetry (never attribute values)
319
+ npx securenow telemetry fields --app <app-key> --env production --since 24h --json
320
+
321
+ # Prove candidate SQL against the backend before creating anything
322
+ npx securenow alerts rules validate --sql @rule.sql --app <app-key>
323
+ ```
324
+
325
+ The same local contract is available programmatically:
326
+
327
+ ```js
328
+ const telemetrySchema = require('securenow/telemetry-schema');
329
+ ```
330
+
331
+ Use the installed schema to draft SQL, live field discovery to verify that optional/custom
332
+ fields are actually emitted, and `alerts rules validate` to prove the query compiles and runs.
333
+ A field absent from the selected live window is **unverified**, not necessarily unsupported.
334
+ Mark detections `NEEDS_INSTRUMENTATION` when the repository must add a `track()` event,
335
+ `NEEDS_BASELINE` when only the threshold remains unknown, and `READY` only after field
336
+ verification plus SQL validation. See `node_modules/securenow/TELEMETRY-SQL.md` for tables,
337
+ timestamp handling, privacy rules, templates, and the full test-to-production lifecycle.
338
+
309
339
  ```bash
310
340
  # Create a custom detection rule from your own SQL (new in 8.1)
311
341
  npx securenow alerts rules create \
package/README.md CHANGED
@@ -223,6 +223,10 @@ npx securenow logs # tail logs
223
223
  npx securenow status # dashboard summary
224
224
  npx securenow doctor # diagnose config + connectivity
225
225
 
226
+ # Detection authoring contract
227
+ npx securenow telemetry schema --json
228
+ npx securenow telemetry fields --app <key> --env production --since 24h --json
229
+
226
230
  # Security
227
231
  npx securenow firewall status --env production
228
232
  npx securenow blocklist add 1.2.3.4 --reason "scanner"
@@ -363,6 +367,16 @@ After install, the `securenow` CLI is available via `npx securenow` or globally
363
367
  | `securenow analytics` | Response code analytics |
364
368
  | `securenow status` | Dashboard summary |
365
369
 
370
+ ### Detection authoring
371
+
372
+ | Command | Description |
373
+ |---|---|
374
+ | `securenow telemetry schema --json` | Offline, versioned tables, fields, event mappings, tenant scope, and SQL requirements |
375
+ | `securenow telemetry fields --app <key> --env production --since 24h --json` | Fields, types, and custom event names actually observed for an authorized app; values are never returned |
376
+ | `securenow alerts rules validate --sql @rule.sql --app <key>` | Compile and dry-run candidate SQL before creating a rule |
377
+
378
+ The machine-readable contract is also exported as `require('securenow/telemetry-schema')`; the full workflow and SQL examples are in [`TELEMETRY-SQL.md`](./TELEMETRY-SQL.md).
379
+
366
380
  ### Detect & Respond
367
381
 
368
382
  | Command | Description |
package/SKILL-API.md CHANGED
@@ -453,6 +453,29 @@ Requires peer dep: `npm install @google-cloud/compute`
453
453
 
454
454
  Logging is enabled by default (`config.logging.enabled: true`). Logs are exported to your OTLP collector alongside traces.
455
455
 
456
+ ### Telemetry contract for alert-rule agents
457
+
458
+ The package exports a versioned, machine-readable description of the ClickHouse tables,
459
+ timestamp types, map access, tenant scope, automatic fields, custom-event mapping, client-IP
460
+ expression, and safe SQL templates:
461
+
462
+ ```js
463
+ const telemetrySchema = require('securenow/telemetry-schema');
464
+ ```
465
+
466
+ The CLI exposes the same contract and can compare it with fields observed in the connected app:
467
+
468
+ ```bash
469
+ securenow telemetry schema --json
470
+ securenow telemetry fields --app <key> --env production --since 24h --json
471
+ securenow alerts rules validate --sql @rule.sql --app <key>
472
+ ```
473
+
474
+ The first command works offline. Live discovery returns field names/types/counts and event names,
475
+ never values. A coding agent should inspect the customer repository for routes and `track()` calls,
476
+ use the local contract to construct SQL, use live discovery to verify emitted fields, then validate
477
+ the candidate query. Full guidance is shipped in `TELEMETRY-SQL.md`.
478
+
456
479
  ### Get a Logger
457
480
 
458
481
  ```javascript
package/SKILL-CLI.md CHANGED
@@ -250,9 +250,27 @@ MCP parity:
250
250
 
251
251
  Write tools still require `confirm:true` plus a reason. False positives should stay restrictive to the app, alert rule, path, method/status, user-agent, body pattern, or other exact evidence the AI report supports. Notification IP investigations can include `metadata.matchedSubdetectors`; prefer that exact field over inferring which subdetector fired from the title or sample body.
252
252
 
253
- ### Alerts
254
-
255
- ```bash
253
+ ### Alerts
254
+
255
+ Before authoring any alert SQL, load the contract shipped by the installed package and compare
256
+ it with fields actually observed for the selected application:
257
+
258
+ ```bash
259
+ securenow telemetry schema --json
260
+ securenow telemetry schema logs --json
261
+ securenow telemetry schema traces --json
262
+ securenow telemetry schema events --json
263
+ securenow telemetry fields --app <key> --env production --since 24h --source all --json
264
+ ```
265
+
266
+ `telemetry schema` is offline and versioned with the SDK. `telemetry fields` requires admin
267
+ auth and telemetry access; it returns names, map scopes, data types, occurrence counts, and
268
+ custom event types, but never customer attribute values. Absence means "not observed in this
269
+ window," not "unsupported." Use only documented fields, and label a rule `READY` only when
270
+ its fields were observed and `alerts rules validate` succeeds. Otherwise label it
271
+ `NEEDS_INSTRUMENTATION`, `NEEDS_BASELINE`, or `NEEDS_CUSTOMER_DECISION` as appropriate.
272
+
273
+ ```bash
256
274
  securenow alerts # list alert rules (default)
257
275
  securenow alerts rules # list alert rules (columns: Status, Applications, Schedule)
258
276
  securenow alerts rules validate \ # dry-run a candidate query BEFORE creating any rule
@@ -0,0 +1,98 @@
1
+ # SecureNow telemetry and alert SQL contract
2
+
3
+ This document is the canonical starting point for humans and coding agents that construct SecureNow alert rules from a customer codebase. The same contract is available as machine-readable JSON:
4
+
5
+ ```bash
6
+ npx securenow telemetry schema --json
7
+ ```
8
+
9
+ The SDK contract tells you which tables and fields are supported. Live field discovery tells you which fields the connected application has actually emitted:
10
+
11
+ ```bash
12
+ npx securenow telemetry fields --app <app-key> --env production --since 24h --json
13
+ ```
14
+
15
+ Do not treat a documented optional or custom field as present until it appears in live discovery or the codebase contains the corresponding instrumentation.
16
+
17
+ ## Alert-rule SQL requirements
18
+
19
+ - Use ClickHouse SQL.
20
+ - Query `signoz_logs.distributed_logs_v2` for custom events and logs.
21
+ - Query `signoz_traces.distributed_signoz_index_v3` for HTTP spans and traces.
22
+ - Keep `__USER_APP_KEYS__` in every customer-scoped query.
23
+ - For logs, scope with `resources_string['service.name'] IN (__USER_APP_KEYS__)`.
24
+ - For traces, scope with `` `resource_string_service$$name` IN (__USER_APP_KEYS__) ``.
25
+ - Bound the query with a time window.
26
+ - Return an `ip` column and exclude empty IPs when the rule can trigger IP remediation.
27
+ - Use the correct timestamp representation: log timestamps are UInt64 nanoseconds; trace timestamps are `DateTime64(9)`.
28
+ - Validate SQL against the connected SecureNow backend before creating the rule.
29
+
30
+ ## Authoring workflow
31
+
32
+ ```bash
33
+ # 1. Read the exact contract shipped with the installed SDK.
34
+ npx securenow telemetry schema --json
35
+
36
+ # 2. Discover actual fields and types emitted by this app.
37
+ npx securenow telemetry fields --app <app-key> --env production --since 24h --json
38
+
39
+ # 3. Avoid duplicating existing coverage.
40
+ npx securenow alerts rules --json
41
+
42
+ # 4. Validate the candidate before any write.
43
+ npx securenow alerts rules validate --sql @rule.sql --app <app-key>
44
+
45
+ # 5. Create in detect-only mode and dry-run it.
46
+ npx securenow alerts rules create --name "Auth: OTP send abuse" --sql @rule.sql --apps <app-key>
47
+ npx securenow alerts rules test <rule-id> --mode dry_run --wait
48
+
49
+ # New custom rules start in detect-only test mode. Promote only after reviewing real matches and false positives.
50
+ npx securenow alerts rules promote <rule-id>
51
+ ```
52
+
53
+ `telemetry schema` works offline. `telemetry fields`, alert-rule validation, and rule management require admin authentication and access to the selected app.
54
+
55
+ ## Custom security events
56
+
57
+ The SDK accepts arbitrary event names and scalar custom attributes:
58
+
59
+ ```js
60
+ const { track } = require('securenow/events');
61
+
62
+ track('auth.otp.send.result', {
63
+ userId,
64
+ sessionId,
65
+ ip,
66
+ attributes: {
67
+ channel: 'sms',
68
+ provider: 'twilio',
69
+ provider_result: 'accepted',
70
+ destination_hash: sha256(normalizedDestination),
71
+ cost_units: 1,
72
+ },
73
+ });
74
+ ```
75
+
76
+ Event properties are stored in the logs table:
77
+
78
+ | SDK property | Alert SQL field |
79
+ |---|---|
80
+ | Event type | `attributes_string['event.type']` |
81
+ | `userId` | `attributes_string['enduser.id']` |
82
+ | `sessionId` | `attributes_string['session.id']` |
83
+ | `ip` | `attributes_string['http.client_ip']` |
84
+ | `userAgent` | `attributes_string['http.user_agent']` |
85
+ | String custom attribute | `attributes_string['<key>']` |
86
+ | Numeric custom attribute | `attributes_number['<key>']` |
87
+ | Boolean custom attribute | `attributes_bool['<key>']` |
88
+
89
+ Never send raw passwords, tokens, OTPs, session secrets, email addresses, phone numbers, or other unnecessary personal data. Hash destinations when correlation is required.
90
+
91
+ ## Readiness labels for generated detections
92
+
93
+ - `READY`: every queried field was observed and the SQL validation passed.
94
+ - `NEEDS_INSTRUMENTATION`: the codebase must add one or more `track()` events or attributes.
95
+ - `NEEDS_BASELINE`: fields and SQL are valid, but the threshold needs production traffic history.
96
+ - `NEEDS_CUSTOMER_DECISION`: cost limits or mitigation behavior require customer approval.
97
+
98
+ Static repository analysis can produce candidate SQL and exact instrumentation TODOs. Only live field discovery plus SQL validation can justify the `READY` label.
package/cli/security.js CHANGED
@@ -48,6 +48,15 @@ async function alertRulesRoute(args, flags) {
48
48
  if (sub === 'set-sql' || sub === 'edit-sql') {
49
49
  return alertRuleSetSql(args.slice(1), flags);
50
50
  }
51
+ if (sub === 'versions' || sub === 'history') {
52
+ return alertRuleVersions(args.slice(1), flags);
53
+ }
54
+ if (sub === 'version') {
55
+ return alertRuleVersionShow(args.slice(1), flags);
56
+ }
57
+ if (sub === 'rollback' || sub === 'revert') {
58
+ return alertRuleRollback(args.slice(1), flags);
59
+ }
51
60
  if (sub === 'delete' || sub === 'rm' || sub === 'remove') {
52
61
  return alertRuleDelete(args.slice(1), flags);
53
62
  }
@@ -464,6 +473,113 @@ async function alertRuleSetSql(args, flags) {
464
473
  }
465
474
  }
466
475
 
476
+ // List a rule's configuration version history (newest first). Each material update (SQL,
477
+ // instant config, settings), duplicate, and rollback is a version; rollback restores a
478
+ // prior snapshot and is itself recorded as a new version.
479
+ async function alertRuleVersions(args, flags) {
480
+ requireAuth();
481
+ const id = args[0];
482
+ if (!id) {
483
+ ui.error('Usage: securenow alerts rules versions <rule-id> [--limit N] [--page N]');
484
+ process.exit(1);
485
+ }
486
+ const query = {};
487
+ if (flags.limit) query.limit = flags.limit;
488
+ if (flags.page) query.page = flags.page;
489
+ const s = ui.spinner('Fetching version history');
490
+ let data;
491
+ try {
492
+ data = await api.get(`/alert-rules/${id}/versions`, Object.keys(query).length ? { query } : undefined);
493
+ s.stop(`Found ${data.total ?? (data.versions || []).length} version${(data.total ?? 0) === 1 ? '' : 's'}`);
494
+ } catch (err) {
495
+ s.fail(`Failed to fetch versions: ${err.message}`);
496
+ process.exit(1);
497
+ }
498
+ if (flags.json) { ui.json(data); return; }
499
+ const rows = data.versions || [];
500
+ if (rows.length === 0) { ui.info('No versions recorded yet.'); return; }
501
+ console.log('');
502
+ ui.info(`Current: v${data.currentVersion}`);
503
+ for (const v of rows) {
504
+ const marker = v.isCurrent ? ui.c.green('→') : ' ';
505
+ const when = v.createdAt ? new Date(v.createdAt).toISOString().replace('T', ' ').slice(0, 19) : '';
506
+ const extra = v.changeType === 'rollback' && v.rolledBackFrom ? ` (from v${v.rolledBackFrom})` : '';
507
+ console.log(`${marker} ${ui.c.bold('v' + v.version).padEnd(14)} ${String(v.changeType + extra).padEnd(22)} ${ui.c.dim(when)} ${v.createdByName || ''}`);
508
+ if (v.reason) console.log(` ${ui.c.dim(ui.truncate(v.reason, 90))}`);
509
+ }
510
+ console.log('');
511
+ ui.info(`Show: securenow alerts rules version ${ui.truncate(id, 12)} <n> | Roll back: securenow alerts rules rollback ${ui.truncate(id, 12)} <n>`);
512
+ }
513
+
514
+ // Show one version's full material snapshot (diff-able SQL / instant / settings).
515
+ async function alertRuleVersionShow(args, flags) {
516
+ requireAuth();
517
+ const id = args[0];
518
+ const version = args[1];
519
+ if (!id || !version) {
520
+ ui.error('Usage: securenow alerts rules version <rule-id> <version-number>');
521
+ process.exit(1);
522
+ }
523
+ const s = ui.spinner(`Fetching v${version}`);
524
+ let data;
525
+ try {
526
+ data = await api.get(`/alert-rules/${id}/versions/${version}`);
527
+ s.stop(`Loaded v${version}`);
528
+ } catch (err) {
529
+ s.fail(`Failed: ${err.message}`);
530
+ process.exit(1);
531
+ }
532
+ if (flags.json) { ui.json(data); return; }
533
+ const v = data.version || {};
534
+ const snap = v.snapshot || {};
535
+ console.log('');
536
+ ui.success(`v${v.version}${v.isCurrent ? ' (current)' : ''} — ${v.changeType}`);
537
+ console.log(` When ${v.createdAt || ''}`);
538
+ if (v.createdByName) console.log(` By ${v.createdByName}`);
539
+ if (v.reason) console.log(` Reason ${v.reason}`);
540
+ if (v.rolledBackFrom) console.log(` Restored v${v.rolledBackFrom}`);
541
+ console.log('');
542
+ console.log(` Name ${snap.name || ''}`);
543
+ console.log(` Mode ${snap.mode || ''} / ${snap.executionMode || ''} severity ${snap.severity || '-'}`);
544
+ if (snap.instant && snap.instant.enabled) {
545
+ console.log(` Instant enabled block=${snap.instant.block} (${(snap.instant.conditions || []).length} condition${(snap.instant.conditions || []).length === 1 ? '' : 's'})`);
546
+ }
547
+ if (snap.query && snap.query.sqlQuery) {
548
+ console.log(' SQL:');
549
+ console.log(snap.query.sqlQuery.split('\n').map((l) => ' ' + ui.c.dim(l)).join('\n'));
550
+ }
551
+ }
552
+
553
+ // Roll a rule back to a prior version — restores that version's full snapshot and records it
554
+ // as a new version (append-only history). System (read-only template) rules can't be rolled back.
555
+ async function alertRuleRollback(args, flags) {
556
+ requireAuth();
557
+ const id = args[0];
558
+ const version = args[1] || flags.version;
559
+ if (!id || !version) {
560
+ ui.error('Usage: securenow alerts rules rollback <rule-id> <version-number> [--reason "..."] [--yes]');
561
+ process.exit(1);
562
+ }
563
+ if (!flags.yes && !flags.force) {
564
+ const ok = await ui.confirm(`Roll back rule ${ui.truncate(id, 12)} to v${version}? Restores that config and creates a new version.`);
565
+ if (!ok) { ui.info('Cancelled'); return; }
566
+ }
567
+ const body = { version: Number(version) };
568
+ if (flags.reason) body.reason = String(flags.reason);
569
+ const s = ui.spinner(`Rolling back to v${version}`);
570
+ let data;
571
+ try {
572
+ data = await api.post(`/alert-rules/${id}/rollback`, body);
573
+ s.stop(`Rolled back to v${version}`);
574
+ } catch (err) {
575
+ s.fail(`Failed to roll back: ${err.message}`);
576
+ process.exit(1);
577
+ }
578
+ if (flags.json) { ui.json(data); return; }
579
+ ui.success(`Restored from v${data.rolledBackFrom} → now at v${data.newVersion}`);
580
+ ui.info(`History: securenow alerts rules versions ${ui.truncate(id, 12)}`);
581
+ }
582
+
467
583
  async function alertRuleDelete(args, flags) {
468
584
  requireAuth();
469
585
  const id = args[0];
@@ -967,6 +1083,58 @@ async function blocklistRemove(args, flags) {
967
1083
  }
968
1084
  }
969
1085
 
1086
+ // Approve a block that the AI prepared but parked pending customer approval
1087
+ // (e.g. the autopilot held it under block_risk_score_below_policy). Enforces the IP.
1088
+ async function blocklistApprove(args, flags) {
1089
+ requireAuth();
1090
+ const id = args[0];
1091
+ if (!id) {
1092
+ ui.error('Usage: securenow blocklist approve <pending-block-id> [--reason <reason>] [--yes]');
1093
+ process.exit(1);
1094
+ }
1095
+ if (!flags.force && !flags.yes) {
1096
+ const ok = await ui.confirm('Approve this pending block? The IP will be enforced by the firewall.');
1097
+ if (!ok) { ui.info('Cancelled'); return; }
1098
+ }
1099
+ const s = ui.spinner('Approving pending block');
1100
+ try {
1101
+ const body = {};
1102
+ if (flags.reason) body.reason = flags.reason;
1103
+ const data = await api.post(`/blocklist/${id}/approve`, body);
1104
+ s.stop('Pending block approved — IP now enforced');
1105
+ if (flags.json) ui.json(data);
1106
+ } catch (err) {
1107
+ s.fail('Failed to approve pending block');
1108
+ throw err;
1109
+ }
1110
+ }
1111
+
1112
+ // Reject a pending block: the IP is NOT blocked, the entry is removed, and a scoped
1113
+ // no-block decision is recorded so auto-block won't re-block this IP for the same rule.
1114
+ async function blocklistReject(args, flags) {
1115
+ requireAuth();
1116
+ const id = args[0];
1117
+ if (!id) {
1118
+ ui.error('Usage: securenow blocklist reject <pending-block-id> [--reason <reason>] [--yes]');
1119
+ process.exit(1);
1120
+ }
1121
+ if (!flags.force && !flags.yes) {
1122
+ const ok = await ui.confirm('Reject this pending block? The IP will NOT be blocked; a scoped no-block decision is recorded for the rule that proposed it.');
1123
+ if (!ok) { ui.info('Cancelled'); return; }
1124
+ }
1125
+ const s = ui.spinner('Rejecting pending block');
1126
+ try {
1127
+ const body = {};
1128
+ if (flags.reason) body.reason = flags.reason;
1129
+ const data = await api.post(`/blocklist/${id}/reject`, body);
1130
+ s.stop('Pending block rejected — IP cleared (scoped no-block decision recorded)');
1131
+ if (flags.json) ui.json(data);
1132
+ } catch (err) {
1133
+ s.fail('Failed to reject pending block');
1134
+ throw err;
1135
+ }
1136
+ }
1137
+
970
1138
  async function blocklistStats(args, flags) {
971
1139
  requireAuth();
972
1140
  const s = ui.spinner('Fetching blocklist stats');
@@ -1666,6 +1834,8 @@ module.exports = {
1666
1834
  blocklistList,
1667
1835
  blocklistAdd,
1668
1836
  blocklistRemove,
1837
+ blocklistApprove,
1838
+ blocklistReject,
1669
1839
  blocklistStats,
1670
1840
  revokeRoute,
1671
1841
  allowlistList,
@@ -0,0 +1,199 @@
1
+ 'use strict';
2
+
3
+ const schemaContract = require('../telemetry-schema.json');
4
+ const { api, requireAuth } = require('./client');
5
+ const config = require('./config');
6
+ const appConfig = require('../app-config');
7
+ const ui = require('./ui');
8
+
9
+ function parseDurationToMinutes(value) {
10
+ if (value == null || value === '') return 24 * 60;
11
+ const match = String(value).trim().match(/^(\d+)\s*(m|h|d)?$/i);
12
+ if (!match) return null;
13
+ const amount = Number.parseInt(match[1], 10);
14
+ const unit = String(match[2] || 'm').toLowerCase();
15
+ if (amount < 1) return null;
16
+ if (unit === 'm') return amount;
17
+ if (unit === 'h') return amount * 60;
18
+ if (unit === 'd') return amount * 24 * 60;
19
+ return null;
20
+ }
21
+
22
+ function filteredSchema(section) {
23
+ const selected = String(section || 'all').toLowerCase();
24
+ if (selected === 'all') return schemaContract;
25
+ if (selected === 'logs' || selected === 'traces') {
26
+ return {
27
+ contract: schemaContract.contract,
28
+ schemaVersion: schemaContract.schemaVersion,
29
+ dialect: schemaContract.dialect,
30
+ ruleContract: schemaContract.ruleContract,
31
+ table: schemaContract.tables[selected],
32
+ clientIp: schemaContract.clientIp,
33
+ templates: schemaContract.templates,
34
+ };
35
+ }
36
+ if (selected === 'events') {
37
+ return {
38
+ contract: schemaContract.contract,
39
+ schemaVersion: schemaContract.schemaVersion,
40
+ dialect: schemaContract.dialect,
41
+ ruleContract: schemaContract.ruleContract,
42
+ storageTable: schemaContract.tables.logs,
43
+ customEvents: schemaContract.customEvents,
44
+ templates: { eventFailuresByIp: schemaContract.templates.eventFailuresByIp },
45
+ };
46
+ }
47
+ return null;
48
+ }
49
+
50
+ async function schema(args, flags) {
51
+ const section = args[0] || flags.source || 'all';
52
+ const contract = filteredSchema(section);
53
+ if (!contract) {
54
+ ui.error('Unknown schema section. Use all, logs, traces, or events.');
55
+ process.exitCode = 1;
56
+ return;
57
+ }
58
+
59
+ if (flags.json) {
60
+ ui.json(contract);
61
+ return;
62
+ }
63
+
64
+ console.log('');
65
+ ui.heading('SecureNow Telemetry & Alert SQL Contract');
66
+ console.log('');
67
+ ui.keyValue([
68
+ ['Schema version', schemaContract.schemaVersion],
69
+ ['SQL dialect', schemaContract.dialect],
70
+ ['Tenant placeholder', schemaContract.ruleContract.tenantPlaceholder],
71
+ ['Required result', 'ip column for IP-remediation rules'],
72
+ ]);
73
+
74
+ const tables = section === 'all'
75
+ ? Object.entries(schemaContract.tables)
76
+ : section === 'events'
77
+ ? [['logs', schemaContract.tables.logs]]
78
+ : [[section, schemaContract.tables[section]]];
79
+ console.log('');
80
+ ui.subheading('Tables');
81
+ console.log('');
82
+ ui.table(
83
+ ['Signal', 'Table', 'Timestamp', 'Tenant scope'],
84
+ tables.map(([name, table]) => [
85
+ name,
86
+ table.name,
87
+ `${table.timestamp.type} (${table.timestamp.unit})`,
88
+ table.tenantExpression,
89
+ ])
90
+ );
91
+
92
+ if (section === 'all' || section === 'events') {
93
+ console.log('');
94
+ ui.subheading('Custom event mapping');
95
+ console.log('');
96
+ ui.table(
97
+ ['SDK property', 'SQL field'],
98
+ Object.entries(schemaContract.customEvents.propertyMapping).map(([property, field]) => [property, field])
99
+ );
100
+ }
101
+
102
+ console.log('');
103
+ ui.info('Machine-readable: securenow telemetry schema --json');
104
+ ui.info('Live verification: securenow telemetry fields --app <key> --env production --since 24h --json');
105
+ ui.info('Full guide: node_modules/securenow/TELEMETRY-SQL.md');
106
+ console.log('');
107
+ }
108
+
109
+ async function fields(args, flags) {
110
+ requireAuth();
111
+ const appKey = flags.app || config.getDefaultApp() || appConfig.resolveAppKey();
112
+ if (!appKey) {
113
+ ui.error('No app specified. Use --app <key>, run `securenow app connect`, or set a default app.');
114
+ process.exitCode = 1;
115
+ return;
116
+ }
117
+
118
+ const sinceMinutes = parseDurationToMinutes(flags.since || '24h');
119
+ if (sinceMinutes == null || sinceMinutes > 30 * 24 * 60) {
120
+ ui.error('Invalid --since value. Use a duration such as 30m, 24h, or 7d (maximum 30d).');
121
+ process.exitCode = 1;
122
+ return;
123
+ }
124
+ const source = String(flags.source || args[0] || 'all').toLowerCase();
125
+ if (!['all', 'logs', 'traces'].includes(source)) {
126
+ ui.error('Invalid --source. Use all, logs, or traces.');
127
+ process.exitCode = 1;
128
+ return;
129
+ }
130
+ const parsedLimit = Number.parseInt(flags.limit || '200', 10);
131
+ if (!Number.isFinite(parsedLimit) || parsedLimit < 1 || parsedLimit > 500) {
132
+ ui.error('--limit must be between 1 and 500.');
133
+ process.exitCode = 1;
134
+ return;
135
+ }
136
+ const environment = flags.env || flags.environment || 'production';
137
+
138
+ const spinner = ui.spinner('Discovering observed telemetry fields');
139
+ try {
140
+ const data = await api.get('/telemetry/fields', {
141
+ query: {
142
+ appKeys: appKey,
143
+ environment,
144
+ sinceMinutes,
145
+ source,
146
+ limit: parsedLimit,
147
+ },
148
+ });
149
+ spinner.stop(`Found ${(data.fields || []).length} observed field${(data.fields || []).length === 1 ? '' : 's'}`);
150
+ if (flags.json) {
151
+ ui.json(data);
152
+ return;
153
+ }
154
+
155
+ console.log('');
156
+ ui.heading('Observed Telemetry Fields');
157
+ console.log('');
158
+ ui.keyValue([
159
+ ['App', appKey],
160
+ ['Environment', (data.environments || [environment]).join(', ')],
161
+ ['Window', `${sinceMinutes} minutes`],
162
+ ['Source', source],
163
+ ]);
164
+ console.log('');
165
+ ui.table(
166
+ ['Signal', 'Scope', 'Type', 'Field', 'Occurrences'],
167
+ (data.fields || []).map((field) => [
168
+ field.source,
169
+ field.fieldScope,
170
+ field.dataType,
171
+ field.field,
172
+ String(field.occurrences || 0),
173
+ ])
174
+ );
175
+
176
+ if ((data.eventTypes || []).length > 0) {
177
+ console.log('');
178
+ ui.subheading('Observed custom event types');
179
+ console.log('');
180
+ ui.table(
181
+ ['Event type', 'Occurrences'],
182
+ data.eventTypes.map((event) => [event.eventType, String(event.occurrences || 0)])
183
+ );
184
+ }
185
+ console.log('');
186
+ ui.info(data.note || 'Absence in this window does not prove a field can never be emitted.');
187
+ console.log('');
188
+ } catch (error) {
189
+ spinner.fail('Failed to discover telemetry fields');
190
+ throw error;
191
+ }
192
+ }
193
+
194
+ module.exports = {
195
+ schema,
196
+ fields,
197
+ parseDurationToMinutes,
198
+ filteredSchema,
199
+ };